Files
windmill/backend/windmill-api/src/jobs.rs
T

12993 lines
464 KiB
Rust

/*
* 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.
*/
pub use windmill_api_jobs::execution::*;
pub use windmill_api_jobs::query::*;
pub use windmill_api_jobs::types::*;
pub use windmill_api_sse::*;
use axum::body::Body;
use futures::{StreamExt, TryFutureExt};
use itertools::Itertools;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
use windmill_api_flows::flows::validate_operator_composed_flow;
use windmill_common::assets::AssetUsageAccessType;
use windmill_common::auth::TOKEN_PREFIX_LEN;
#[cfg(feature = "run_inline")]
use windmill_common::client::AuthedClient;
use windmill_common::db::UserDbWithAuthed;
use windmill_common::error::JsonResult;
use windmill_common::flow_status::{JobResult, RestartedFrom};
use windmill_common::jobs::{
format_completed_job_result, format_result, is_safe_log_file_path, is_valid_entrypoint_name,
sanitize_untrusted_args, DynamicInput, ENTRYPOINT_OVERRIDE,
};
#[cfg(feature = "run_inline")]
use windmill_common::jobs::{
InlineScriptTarget, RunInlinePreviewScriptFnParams, RunInlineScriptFnParams,
};
use windmill_common::roles::AuthorLevel;
use windmill_common::runnable_settings::{
ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings,
};
#[cfg(feature = "run_inline")]
use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAssetParams};
use windmill_common::scripts::{ScriptModule, ScriptRunnableSettingsInline};
use windmill_common::triggers::TriggerMetadata;
use windmill_common::utils::{RunnableKind, WarnAfterExt};
use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
use windmill_common::workspace_dependencies::{
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
};
use windmill_common::workspaces::{
check_operator_can_build, check_user_against_rule, BuilderKind, ProtectionRuleKind,
RuleCheckResult,
};
use windmill_common::DYNAMIC_INPUT_CACHE;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
use windmill_object_store::upload_artifact_to_store;
#[cfg(feature = "run_inline")]
use windmill_parser::asset_parser::AssetKind;
use windmill_types::s3::BundleFormat;
#[cfg(feature = "run_inline")]
use windmill_worker::get_worker_internal_server_inline_utils;
use windmill_common::variables::get_workspace_key;
use crate::db::OptJobAuthed;
use crate::triggers::trigger_helpers::{FlowId, ScriptId};
use crate::{
add_webhook_allowed_origin,
args::{self, RawWebhookArgs},
auth::{OptTokened, Tokened},
concurrency_groups::join_concurrency_key,
csrf::CrossSiteGetGuard,
db::{ApiAuthed, DB},
triggers::trigger_helpers::RunnableId,
users::{
get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed,
},
utils::{build_scope_path_predicate, check_scopes, content_plain, require_super_admin},
};
use anyhow::Context;
use axum::{
extract::{Json, Path, Query},
response::{IntoResponse, Response},
routing::{get, post},
Extension, Router,
};
use chrono::Utc;
use hmac::Mac;
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use sql_builder::prelude::*;
use sqlx::types::JsonRawValue;
use sqlx::{types::Uuid, FromRow, Postgres, Transaction};
use tower_http::cors::{Any, CorsLayer};
use urlencoding::encode;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::worker::to_raw_value;
use windmill_common::{
cache,
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{
add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue,
Suspend,
},
jobs::{
script_path_to_payload, CompletedJob, JobKind, JobPayload, OnBehalfOf, QueuedJob, RawCode,
},
oauth2::HmacSha256,
query_builders,
scripts::{ScriptHash, ScriptLang},
users::username_to_permissioned_as,
utils::{
not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType,
StripPath,
},
};
use windmill_common::{
get_flow_path_for_version_authed, get_flow_version_info_from_version,
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
get_script_info_for_hash, utils::empty_as_none, ScriptHashInfo, BASE_URL,
};
use windmill_queue::{
get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs, PushArgsOwned,
PushIsolationLevel,
};
pub fn workspaced_service() -> Router {
let cors = CorsLayer::new()
.allow_methods([http::Method::GET, http::Method::POST])
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_origin(Any);
// Cloud events abuse control headers
let ce_headers =
ServiceBuilder::new().layer(axum::middleware::from_fn(add_webhook_allowed_origin));
Router::new()
.route("/run_progress/{id}", get(get_run_progress))
.route("/run_assets/{id}", get(list_run_assets))
.route("/dbt_graph/{id}", get(get_dbt_run_graph))
.route("/dbt_column_lineage/{id}", get(get_dbt_run_column_lineage))
.route("/dbt_resumable/{id}", get(get_dbt_resumable))
.route(
"/dbt_resumable_script/p/{*script_path}",
get(get_dbt_resumable_for_script),
)
.route(
"/run/f/{*script_path}",
post(run_flow_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/fv/{version}",
post(run_flow_by_version)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/batch_rerun_jobs",
post(batch_rerun_jobs)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/workflow_as_code/{job_id}/{entrypoint}",
post(run_workflow_as_code)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/wac/inline_checkpoint/{job_id}",
post(wac_inline_checkpoint).layer(cors.clone()),
)
.route(
"/restart/f/{job_id}",
post(restart_flow).head(|| async { "" }).layer(cors.clone()),
)
.route(
"/run/p/{*script_path}",
post(run_script_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_wait_result/p/{*script_path}",
post(run_wait_result_script_by_path)
.get(run_wait_result_job_by_path_get)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_wait_result/h/{hash}",
post(run_wait_result_script_by_hash)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_wait_result/f/{*script_path}",
post(run_wait_result_flow_by_path)
.get(run_wait_result_flow_by_path_get)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_wait_result/fv/{version}",
post(run_wait_result_flow_by_version)
.get(run_wait_result_flow_by_version_get)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/f/{*script_path}",
get(stream_flow_by_path)
.post(stream_flow_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/fv/{version}",
get(stream_flow_by_version)
.post(stream_flow_by_version)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/p/{*script_path}",
get(stream_script_by_path)
.post(stream_script_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/h/{hash}",
get(stream_script_by_hash)
.post(stream_script_by_hash)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/h/{hash}",
post(run_job_by_hash)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route("/run/preview", post(run_preview_script))
.route("/run_inline/preview", post(run_inline_preview_script))
.route(
"/run_inline/p/{*script_path}",
post(run_inline_script_by_path),
)
.route("/run_inline/h/{hash}", post(run_inline_script_by_hash))
.route(
"/run_wait_result/preview",
post(run_wait_result_preview_script),
)
.route(
"/run/preview_bundle",
post(run_bundle_preview_script).layer(axum::extract::DefaultBodyLimit::disable()),
)
.route("/add_batch_jobs/{n}", post(add_batch_jobs))
.route("/run/preview_flow", post(run_preview_flow_job))
.route("/run/agent/{*path}", post(crate::agent_runs::run_agent))
.route(
"/run_wait_result/preview_flow",
post(run_wait_result_preview_flow),
)
.route("/run/dynamic_select", post(run_dynamic_select))
.route("/list", get(list_jobs))
.route("/oldest", get(get_oldest_job))
.route("/asset_dispatch_edges", get(list_asset_dispatch_edges))
.route(
"/list_selected_job_groups",
// We use post because sending a huge array as a query param can produce
// URLs that may be too long
post(list_selected_job_groups),
)
.route("/list_filtered_uuids", get(list_filtered_job_uuids))
.route("/queue/list", get(list_queue_jobs))
.route("/queue/export", get(crate::jobs_export::export_queued_jobs))
.route(
"/queue/import",
post(crate::jobs_export::import_queued_jobs),
)
.route("/queue/count", get(count_queue_jobs))
.route("/queue/list_filtered_uuids", get(list_filtered_uuids))
.route("/queue/position/{timestamp}", get(get_queue_position))
.route("/queue/scheduled_for/{id}", get(get_scheduled_for))
.route("/queue/cancel_selection", post(cancel_selection))
.route("/queue/run_now/{id}", post(run_queued_job_now))
.route("/completed/count", get(count_completed_jobs))
.route("/completed/count_jobs", get(count_completed_jobs_detail))
.route(
"/completed/list",
get(list_completed_jobs).layer(cors.clone()),
)
.route(
"/completed/export",
get(crate::jobs_export::export_completed_jobs).layer(cors.clone()),
)
.route(
"/completed/import",
post(crate::jobs_export::import_completed_jobs).layer(cors.clone()),
)
.route("/delete", post(crate::jobs_export::delete_jobs))
.route(
"/completed/get/{id}",
get(get_completed_job).layer(cors.clone()),
)
.route(
"/completed/get_result/{id}",
get(get_completed_job_result).layer(cors.clone()),
)
.route(
"/completed/get_result_maybe/{id}",
get(get_completed_job_result_maybe).layer(cors.clone()),
)
.route(
"/completed/get_timing/{id}",
get(get_completed_job_timing).layer(cors.clone()),
)
.route(
"/completed/delete/{id}",
post(delete_completed_job).layer(cors.clone()),
)
.route(
"/completed/resolve",
post(resolve_completed_jobs).layer(cors.clone()),
)
.route(
"/completed/unresolve",
post(unresolve_completed_jobs).layer(cors.clone()),
)
.route(
"/flow/resume/{id}",
post(resume_suspended_flow_as_owner).layer(cors.clone()),
)
.route(
"/job_signature/{job_id}/{resume_id}",
get(create_job_signature).layer(cors.clone()),
)
.route(
"/flow/user_states/{job_id}/{key}",
get(get_flow_user_state)
.post(set_flow_user_state)
.layer(cors.clone()),
)
.route(
"/resume_urls/{job_id}/{resume_id}",
get(get_resume_urls).layer(cors.clone()),
)
.route(
"/wac_approval_urls/{job_id}/{step_key}",
get(get_wac_approval_urls).layer(cors.clone()),
)
.route(
"/worker_is_draining/{id}",
get(worker_is_draining).layer(cors.clone()),
)
.route(
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
)
.route(
"/job_view_token/{id}",
get(get_job_view_token).layer(cors.clone()),
)
.route(
"/job_public_view_token/{id}",
get(get_job_public_view_token).layer(cors.clone()),
)
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/dependencies_async", post(run_dependencies_job_async))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
.route(
"/run/flow_dependencies_async",
post(run_flow_dependencies_job_async),
)
.route(
"/send_email_with_instance_smtp",
post(send_email_with_instance_smtp),
)
.route("/get_otel_traces/{id}", get(get_otel_traces))
}
pub fn workspace_unauthed_service() -> Router {
Router::new()
.route(
"/resume/{job_id}/{resume_id}/{secret}",
get(resume_suspended_job),
)
.route(
"/resume/{job_id}/{resume_id}/{secret}",
post(resume_suspended_job),
)
.route(
"/cancel/{job_id}/{resume_id}/{secret}",
get(cancel_suspended_job),
)
.route(
"/cancel/{job_id}/{resume_id}/{secret}",
post(cancel_suspended_job),
)
.route(
"/get_flow/{job_id}/{resume_id}/{secret}",
get(get_suspended_job_flow),
)
.route("/get_root_job_id/{id}", get(get_root_job))
.route("/get/{id}", get(get_job))
.route("/get_logs/{id}", get(get_job_logs))
.route("/get_flow_all_logs/{id}", get(get_flow_all_logs))
.route(
"/get_flow_all_logs_structured/{id}",
get(get_flow_all_logs_structured),
)
.route("/get_flow_all_results/{id}", get(get_flow_all_results))
.route(
"/get_completed_logs_tail/{id}",
get(get_completed_job_logs_tail),
)
.route("/get_args/{id}", get(get_args))
.route("/queue/get_started_at_by_ids", post(get_started_at_by_ids))
.route("/get_flow_debug_info/{id}", get(get_flow_job_debug_info))
.route("/completed/get/{id}", get(get_completed_job))
.route("/completed/get_result/{id}", get(get_completed_job_result))
.route(
"/completed/get_result_maybe/{id}",
get(get_completed_job_result_maybe),
)
.route("/completed/get_timing/{id}", get(get_completed_job_timing))
.route("/dispatch_events/{id}", get(get_dispatch_events))
.route("/getupdate/{id}", get(get_job_update))
.route("/getupdate_sse/{id}", get(get_job_update_sse))
.route("/get_log_file/{*file_path}", get(get_log_file))
.route("/queue/cancel/{id}", post(cancel_job_api))
.route(
"/queue/cancel_persistent/{*script_path}",
post(cancel_persistent_script_api),
)
.route("/queue/force_cancel/{id}", post(force_cancel))
.route("/flow/resume_suspended/{job_id}", post(resume_suspended))
.route("/flow/approval_info/{job_id}", get(get_approval_info))
}
pub fn global_root_service() -> Router {
Router::new()
.route("/db_clock", get(get_db_clock))
.route("/completed/count_by_tag", get(count_by_tag))
}
#[derive(Deserialize)]
struct JsonPath {
pub json_path: Option<String>,
pub suspended_job: Option<Uuid>,
pub resume_id: Option<u32>,
pub secret: Option<String>,
pub approver: Option<String>,
}
async fn get_result_by_id(
OptViewToken(view_token): OptViewToken,
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
// Reading a node's result requires being able to read the flow itself (the node
// belongs to it). Gate on the flow's visibility (created_by / RLS / root
// inheritance) before resolving via the root DB.
require_job_update_read_access(
&db,
&user_db,
&authed,
&w_id,
&flow_id,
view_token.as_deref(),
)
.await?;
// The node is resolved up through the enclosing flows, so a read path that admitted
// `flow_id` as a step must also admit the run it is a step of. A path-scoped
// `jobs:read` does not reach this route; a `jobs:run` scope beside it does.
if windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref())
.is_some_and(|c| c.has_read_paths())
{
let top = sqlx::query_scalar::<_, Uuid>(
"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT id FROM chain WHERE parent_job IS NULL",
)
.bind(flow_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {flow_id} not found")))?;
require_job_within_read_scope(&db, &authed, &w_id, &top).await?;
}
let res =
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
.await?;
log_job_view(&db, Some(&authed), Some(&tokened.token), &w_id, &flow_id).await?;
Ok(Json(res))
}
/// Mint a stateless "share read link" token for a job. Only a caller who can already
/// read the job (creator / RLS / flow ancestor / admin) may mint it. The returned
/// `{job_id}.{hmac}` is passed back as the `view_token` query param on the run page's
/// reads, granting an authenticated member read of this job and its flow subtree.
async fn get_job_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
// No `view_token` here: minting requires the caller's own read access, so a share
// link cannot be used to mint further links. `require_job_read_access` also
// enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't
// mint a transferable link for a job outside its allowed tags.
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
let hmac = generate_view_token(&w_id, id, VIEW_TOKEN_DOMAIN, &db).await?;
Ok(format!("{id}.{hmac}"))
}
/// Public flavor of [`get_job_view_token`]: the resulting link additionally grants read of
/// the job (and its flow subtree) to logged-out visitors, who land on the minimal public
/// run page. Read access is necessary but not sufficient — exposing workspace data to the
/// anonymous internet is a privileged action, gated like an app's anonymous execution mode.
async fn get_job_public_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
// Anonymous readers of a public link carry no scope to confine, so the link would
// reach out-of-scope descendants of an in-scope job (mixed-tag flow trees). A tag
// scope is a hard restriction: refuse rather than silently narrow the link.
if get_scope_tags(&authed).is_some() {
return Err(Error::PermissionDenied(
"A tag-scoped token cannot share a run publicly: the resulting link is read \
anonymously and could not carry the tag restriction to the run's steps."
.to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::RestrictPublicRunSharing,
&authed.username,
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let hmac = generate_view_token(&w_id, id, PUBLIC_VIEW_TOKEN_DOMAIN, &db).await?;
// The link is stateless and permanent, so the mint is the only moment this is
// observable: audit it unconditionally rather than through the opt-in job-view log.
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&AuditAuthor::from(&authed),
"jobs.share_publicly",
ActionKind::Create,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!("{id}.{hmac}"))
}
async fn get_root_job(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> windmill_common::error::JsonResult<String> {
let res = compute_root_job_for_flow(&db, &w_id, id).await?;
Ok(Json(res))
}
/// Whether the worker running this job has received its shutdown signal. A draining worker
/// never interrupts its job, so a long-running script polls this to exit on its own terms.
async fn worker_is_draining(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> windmill_common::error::JsonResult<bool> {
// A job always may ask about itself. Anyone else must be able to see the job: the
// caller's row policies on v2_job decide, narrowed by the token's tag filter.
let draining = if authed.job_id == Some(id) {
sqlx::query_scalar!(
"SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker
WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL",
id,
w_id
)
.fetch_optional(&db)
.await?
} else {
let tags = get_scope_tags(&authed)
.map(|tags| tags.into_iter().map(str::to_string).collect::<Vec<_>>());
let mut tx = user_db.begin(&authed).await?;
let draining = sqlx::query_scalar!(
"SELECT wp.draining FROM v2_job j
JOIN v2_job_queue q ON q.id = j.id
JOIN worker_ping wp ON wp.worker = q.worker
WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL
AND ($3::text[] IS NULL OR j.tag = ANY($3))",
id,
w_id,
tags.as_deref()
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
draining
}
.unwrap_or(false);
windmill_common::feature_usage::log_feature_usage(
"worker_draining",
"check",
if draining { "draining" } else { "not_draining" },
);
Ok(Json(draining))
}
async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
let root_job = sqlx::query_scalar!(
r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
job_id,
w_id
)
.fetch_one(db)
.await?;
Ok(root_job.to_string())
}
async fn get_db_clock(Extension(db): Extension<DB>) -> windmill_common::error::JsonResult<i64> {
Ok(Json(now_from_db(&db).await?.timestamp_millis()))
}
async fn cancel_job_api(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// Cancelling needs the same per-job access as reading: own job, admin, or RLS-visible
// directly/through a flow ancestor — which also confines app embed tokens to the
// component runs they launched. No `view_token`: a share link grants read, never the
// right to kill someone else's run. Anonymous callers are instead confined to
// anonymous-created jobs by `cancel_job`'s `require_anonymous`.
if let Some(authed) = opt_authed.as_ref() {
require_job_cancel_access(&db, &user_db, authed, &w_id, &id).await?;
}
let tx = db.begin().await?;
let audit_author: AuditAuthor = match opt_authed.as_ref() {
Some(authed) => (authed).into(),
None => AuditAuthor {
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: opt_tokened
.token
.map(|s| s[0..TOKEN_PREFIX_LEN].to_string()),
},
};
let (mut tx, job_option) = tokio::time::timeout(
std::time::Duration::from_secs(120),
windmill_queue::cancel_job(
&audit_author.username,
reason,
id,
&w_id,
tx,
&db,
false,
opt_authed.is_none(),
),
)
.await
.map_err(|e| {
Error::internal_err(format!(
"timeout after 120s while cancelling job {id} in {w_id}: {e:#}"
))
})??;
if let Some(id) = job_option {
audit_log(
&mut *tx,
&audit_author,
"jobs.cancel",
ActionKind::Delete,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(id.to_string())
} else {
tx.commit().await?;
if job_is_complete(&db, id, &w_id).await.unwrap_or(false) {
return Ok(format!("queued job id {} is already completed", id));
} else {
return Err(error::Error::NotFound(format!(
"queued job id {} does not exist",
id
)));
}
}
}
async fn cancel_persistent_script_api(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<()> {
let audit_author: AuditAuthor = match opt_authed {
Some(authed) => {
let path = script_path.to_path();
if !windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref())
.is_none_or(|c| windmill_api_auth::scopes::cancel_confinement_admits(&c, path))
{
return Err(Error::NotFound(format!("Script {path} not found")));
}
(&authed).into()
}
None => {
return Err(Error::BadRequest(format!(
"Cancelling persistent script require to be logged in and member of {w_id}"
)))
}
};
let cancelled_job_ids = windmill_queue::cancel_persistent_script_jobs(
&audit_author.username,
reason,
script_path.to_path(),
&w_id,
&db,
)
.await?;
audit_log(
&db,
&audit_author,
"jobs.cancel_persistent",
ActionKind::Delete,
&w_id,
Some(script_path.to_path()),
Some(
[(
"job_ids",
cancelled_job_ids
.into_iter()
.map(|uuid: Uuid| uuid.to_string())
.collect::<Vec<_>>()
.join(",")
.as_str(),
)]
.into(),
),
)
.await?;
Ok(())
}
/// Bounds the ancestor walk below so a cyclic `parent_job` chain cannot spin forever.
/// `cancel_job` itself is unbounded, so a chain longer than this would leave the two
/// disagreeing about which job gets killed — hence the fail-closed error.
const FORCE_CANCEL_MAX_ANCESTOR_DEPTH: i32 = 500;
/// The job a force-cancel of `id` actually kills: `cancel_job(force_cancel = true)` walks
/// up to the highest still-queued ancestor and cancels that one instead. Falls back to
/// `id` when it is not queued (the cancel is then a no-op anyway).
async fn force_cancel_target(db: &DB, w_id: &str, id: Uuid) -> error::Result<Uuid> {
let target = sqlx::query!(
r#"WITH RECURSIVE queued_ancestors AS (
SELECT j.id, j.parent_job, 0 AS depth
FROM v2_job j JOIN v2_job_queue q USING (id)
WHERE j.id = $1 AND j.workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job, a.depth + 1
FROM queued_ancestors a
JOIN v2_job j ON j.id = a.parent_job AND j.workspace_id = $2
JOIN v2_job_queue q ON q.id = j.id
WHERE a.depth < $3
)
SELECT id AS "id!", depth AS "depth!" FROM queued_ancestors ORDER BY depth DESC LIMIT 1"#,
id,
w_id,
FORCE_CANCEL_MAX_ANCESTOR_DEPTH,
)
.fetch_optional(db)
.await?;
match target {
None => Ok(id),
// Truncated: we cannot prove which job the cancel would reach, so refuse rather
// than authorize an ancestor that may not be the one killed.
Some(r) if r.depth >= FORCE_CANCEL_MAX_ANCESTOR_DEPTH => Err(Error::internal_err(format!(
"flow nesting above job {id} is too deep to authorize a force cancel"
))),
Some(r) => Ok(r.id),
}
}
async fn force_cancel(
OptAuthed(opt_authed): OptAuthed,
tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// Same per-job access as `cancel_job_api`, but on the job force-cancel actually kills.
// Read visibility is inherited *down* the flow chain, so gating on `id` would let a
// caller who can only see an inner step kill a root flow hidden from them.
if let Some(authed) = opt_authed.as_ref() {
let target = force_cancel_target(&db, &w_id, id).await?;
require_job_cancel_access(&db, &user_db, authed, &w_id, &target).await?;
}
let tx = db.begin().await?;
let audit_author: AuditAuthor = match opt_authed.as_ref() {
Some(authed) => (authed).into(),
None => AuditAuthor {
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: tokened.token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string()),
},
};
let (mut tx, job_option) = tokio::time::timeout(
std::time::Duration::from_secs(120),
windmill_queue::cancel_job(
&audit_author.username,
reason,
id,
&w_id,
tx,
&db,
true,
opt_authed.is_none(),
),
)
.await
.map_err(|e| {
Error::internal_err(format!(
"timeout after 120s while cancelling job {id} in {w_id}: {e:#}"
))
})??;
if let Some(id) = job_option {
audit_log(
&mut *tx,
&audit_author,
"jobs.force_cancel",
ActionKind::Delete,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(id.to_string())
} else {
tx.commit().await?;
if job_is_complete(&db, id, &w_id).await.unwrap_or(false) {
return Ok(format!("queued job id {} is already completed", id));
} else {
return Err(error::Error::NotFound(format!(
"queued job id {} does not exist",
id
)));
}
}
}
/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id.
/// A job with a concurrency limit still goes through the usual check at pull time and is
/// re-scheduled again if its key is still saturated.
async fn run_queued_job_now(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
refuse_upcoming_schedule_tick(&db, &w_id, id).await?;
let mut tx = db.begin().await?;
let previous = sqlx::query_scalar!(
"UPDATE v2_job_queue q SET scheduled_for = now()
FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev
WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL
AND q.scheduled_for > now()
RETURNING prev.scheduled_for",
id,
w_id,
)
.fetch_optional(&mut *tx)
.await?;
let Some(previous) = previous else {
tx.commit().await?;
return Err(
if sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
id,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false)
{
Error::BadRequest(format!(
"job {id} is not waiting for a future start: it is running, suspended, \
canceled or already due"
))
} else {
Error::NotFound(format!("queued job id {id} does not exist"))
},
);
};
audit_log(
&mut *tx,
&authed,
"jobs.run_now",
ActionKind::Update,
&w_id,
Some(&id.to_string()),
Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()),
)
.await?;
tx.commit().await?;
windmill_queue::append_logs(
&id,
&w_id,
format!(
"\nStart moved from {previous} to now by {}\n",
authed.display_username()
),
&db.clone().into(),
)
.await;
Ok(id.to_string())
}
/// A schedule queues its next tick when the current one completes, computed from the
/// completion time. Starting a tick that is not yet due would therefore queue that same
/// tick again and run the schedule twice. A tick pushed past its time by a concurrency
/// limit no longer sits on a cron occurrence, so it may still be started early.
async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> {
let Some(tick) = sqlx::query!(
"SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone
FROM v2_job j
JOIN v2_job_queue q USING (id)
JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger
WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'
AND j.parent_job IS NULL AND q.scheduled_for > now()",
id,
w_id,
)
.fetch_optional(db)
.await?
else {
return Ok(());
};
let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?;
let tz =
chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?;
let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz);
if sched.find_next(&just_before)? == tick.scheduled_for {
return Err(Error::BadRequest(format!(
"job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \
directly instead",
tick.path
)));
}
Ok(())
}
#[derive(Serialize)]
struct QueuePosition {
position: Option<i64>,
}
async fn get_queue_position(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((_w_id, scheduled_for)): Path<(String, i64)>,
) -> error::Result<Json<QueuePosition>> {
// First check if the job exists and is in queue
// Count jobs that are scheduled before this job and are not suspended
let count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) as count
FROM v2_job_queue
WHERE scheduled_for < to_timestamp($1::bigint / 1000.0)
AND running = false
AND suspend_until IS NULL",
scheduled_for,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
Ok(Json(QueuePosition { position: Some(count + 1) }))
}
async fn get_scheduled_for(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Json<i64>> {
let scheduled_for = sqlx::query_scalar!(
"SELECT scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
id,
w_id,
)
.fetch_optional(&db)
.await?;
let scheduled_for = not_found_if_none(scheduled_for, "QueuedJob", &id.to_string())?;
Ok(Json(scheduled_for.timestamp_millis()))
}
/// Per-relation progress of one job, for a graph that moves while it runs.
///
/// The worker records these as it goes -- `running` when a model starts,
/// `materialized` or `failed` when it ends -- but nothing rendered them: the
/// asset graph carries a relation's identity, not what a particular run is doing
/// to it. A retry rewrites the same rows, so a node moves back to `running` and
/// on to its new outcome without anything extra here.
#[derive(Serialize, Debug)]
struct AssetProgress {
asset_kind: windmill_common::assets::AssetKind,
asset_path: String,
status: String,
row_count: Option<i64>,
error: Option<String>,
}
/// Which project version a dbt view pins to for this job, once the caller has
/// been shown to be entitled to it.
///
/// `Ok(None)` is "answer unpinned", not a refusal: a job that stored no graph of
/// its own — and one that has aged out of retention — is served the deployed
/// version rather than an error, so a run page keeps drawing after the run is
/// gone. Pinning needs the full job-read contract, which is why it lives on
/// `require_job_read_access` here rather than as a parameter on `/assets/*`.
/// See docs/dbt-runtime.md.
async fn dbt_pinned_run(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
job_id: Uuid,
view_token: Option<&str>,
) -> error::Result<Option<windmill_api_assets::PinnedRun>> {
// The scope domain comes from the URL segment, so `/jobs` asks a scoped token
// for `jobs:read` alone while the body returned is asset data. Both are
// required: the job gate below reaches this run, this reaches assets at all.
check_scopes(authed, || "assets:read".to_string())?;
let job = sqlx::query!(
r#"SELECT created_by, runnable_path,
CASE WHEN kind = 'script' THEN runnable_id END AS script_hash,
-- Whether this job PARSED a graph of its own with no version
-- behind it: the dbt editor refreshing its buffer. That graph is
-- reachable no other way, so the job pins to it; every other
-- versionless job keeps answering with the workspace graph.
EXISTS (SELECT 1 FROM dbt_graph_snapshot g
WHERE g.workspace_id = $2 AND g.job_id = $1
AND g.script_hash IS NULL) AS "editor_graph!"
FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
job_id,
w_id
)
.fetch_optional(db)
.await?;
// Unpinned rather than 404 for a job that is gone. Reachable only with
// `assets:read`, which is exactly what the unpinned route would have cost
// for the same answer.
let Some(job) = job else {
return Ok(None);
};
require_job_read_access(
db,
user_db,
authed,
w_id,
&job_id,
&job.created_by,
view_token,
)
.await?;
// A preview or flow job names no deployed version, so there is usually no
// graph to pin to and the workspace one answers. The exception is a job that
// parsed one itself, which is what the dbt editor's refresh is: its graph
// belongs to that job alone and nothing else can reach it.
Ok(job
.runnable_path
.filter(|_| job.script_hash.is_some() || job.editor_graph)
.map(|path| windmill_api_assets::PinnedRun {
job_id,
script_path: path,
script_hash: job.script_hash,
}))
}
/// The asset graph as one run saw it.
async fn get_dbt_run_graph(
authed: ApiAuthed,
OptViewToken(view_token): OptViewToken,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(q): Query<windmill_api_assets::GraphQuery>,
) -> error::JsonResult<windmill_api_assets::AssetGraphResponse> {
let pinned =
dbt_pinned_run(&authed, &db, &user_db, &w_id, job_id, view_token.as_deref()).await?;
windmill_api_assets::asset_graph_for(&authed, &w_id, user_db, db, q, pinned).await
}
/// The column lineage a set of relations sits in as one run saw it — the same
/// pin as `get_dbt_run_graph`, for the trace drawn beside a node of that graph.
async fn get_dbt_run_column_lineage(
authed: ApiAuthed,
OptViewToken(view_token): OptViewToken,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(pairs): Query<Vec<(String, String)>>,
) -> error::JsonResult<windmill_api_assets::ColumnLineageResponse> {
let q = windmill_api_assets::ColumnLineageQuery::from_query_pairs(pairs)?;
let pinned =
dbt_pinned_run(&authed, &db, &user_db, &w_id, job_id, view_token.as_deref()).await?;
windmill_api_assets::dbt_column_lineage_for(&authed, &w_id, user_db, q, pinned).await
}
/// Whether a `dbt retry` submitted by this caller would resume THIS run.
///
/// One failure is saved per script per execution principal, so a page showing an
/// older failed run cannot tell whether resuming would reach it or a later one —
/// and offering it there would submit a retry the worker refuses. Answers about
/// this run alone: the id when it is the one, `null` otherwise, so no other run's
/// id leaves through a page that only needs a yes or no.
async fn get_dbt_resumable(
authed: ApiAuthed,
OptViewToken(view_token): OptViewToken,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
) -> error::JsonResult<Option<Uuid>> {
let Some(job) = sqlx::query!(
"SELECT created_by, runnable_path FROM v2_job
WHERE id = $1 AND workspace_id = $2",
job_id,
&w_id
)
.fetch_optional(&db)
.await?
else {
return Ok(Json(None));
};
require_job_read_access(
&db,
&user_db,
&authed,
&w_id,
&job_id,
&job.created_by,
view_token.as_deref(),
)
.await?;
let Some(script_path) = job.runnable_path else {
return Ok(Json(None));
};
// The CALLER's principal, not the one this run executed as: a retry is a new
// job submitted by whoever is reading, so a run of Alice's that Bob may read
// is not one Bob's retry could resume.
let Some(permissioned_as) = dbt_retry_principal(&user_db, &authed, &w_id, &script_path).await?
else {
return Ok(Json(None));
};
let resumable = resumable_run(&db, &w_id, &script_path, &permissioned_as).await?;
Ok(Json(resumable.filter(|id| *id == job_id)))
}
/// Which run a `dbt retry` of this SCRIPT would resume for this caller, if any.
///
/// The run form asks: `dbt_retry_job` is required for a retry and a job id is not
/// something to type from memory.
async fn get_dbt_resumable_for_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
) -> error::JsonResult<Option<Uuid>> {
// The cutoff `require_job_read_access` applies for the sibling routes, which
// this one cannot reach through: user-authored app JS holds an app-embed
// token, confined to the jobs it launched, and this answers with a job id.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
return Ok(Json(None));
}
let path = script_path.to_path();
let Some(permissioned_as) = dbt_retry_principal(&user_db, &authed, &w_id, path).await? else {
return Ok(Json(None));
};
let Some(job_id) = resumable_run(&db, &w_id, path, &permissioned_as).await? else {
return Ok(Json(None));
};
// Only a run this caller may READ. Everyone running an `on_behalf_of` script
// shares one principal, so the saved failure can be a run of the owner's that
// this caller cannot open — and answering with its id would be this endpoint
// handing over what to resume, when resuming republishes that run's
// arguments. Whoever can read the run keeps the prefill, which is the case
// the form exists for.
let Some(created_by) = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
&w_id
)
.fetch_optional(&db)
.await?
else {
return Ok(Json(None));
};
if require_job_read_access(&db, &user_db, &authed, &w_id, &job_id, &created_by, None)
.await
.is_err()
{
return Ok(Json(None));
}
Ok(Json(Some(job_id)))
}
/// The identity a run of this script submitted by this caller would execute as, which is the key
/// `dbt_run_state` is saved under — the recorded `on_behalf_of` when the script has one and the
/// caller otherwise, the same choice `run_script_by_path` makes. `None` when the caller cannot
/// see the script.
async fn dbt_retry_principal(
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
path: &str,
) -> error::Result<Option<String>> {
// Through the user db: which of a script's runs you could resume is for
// whoever can see the script.
let mut tx = user_db.clone().begin(authed).await?;
let script = sqlx::query!(
"SELECT on_behalf_of FROM script
WHERE path = $1 AND workspace_id = $2 AND archived = false AND deleted = false
ORDER BY created_at DESC LIMIT 1",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Ok(script.map(|s| {
s.on_behalf_of
.unwrap_or_else(|| username_to_permissioned_as(&authed.username))
}))
}
/// The saved failure for this script and principal, when there is one to resume.
/// A run that left nothing retryable is saved too — so a retry can say the run
/// succeeded rather than that nothing is saved — but it is not resumable.
async fn resumable_run(
db: &DB,
w_id: &str,
path: &str,
permissioned_as: &str,
) -> error::Result<Option<Uuid>> {
Ok(sqlx::query_scalar!(
"SELECT job_id FROM dbt_run_state
WHERE workspace_id = $1 AND script_path = $2 AND permissioned_as = $3 AND retryable",
w_id,
path,
permissioned_as
)
.fetch_optional(db)
.await?
.flatten())
}
/// Lives with the job routes, not the asset ones, because it is job-scoped and
/// `require_job_read_access` is what gates it: `materialized_partition` has no
/// RLS, and RLS alone would not enforce a scoped token's tag filter or the
/// app-embed cutoff, which that helper adds on top.
async fn get_run_progress(
authed: ApiAuthed,
// A shared run page carries its access in this token, not in the caller's
// own grants: without it a share-link viewer gets the graph and is refused
// the progress that colours it.
OptViewToken(view_token): OptViewToken,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
) -> error::JsonResult<Vec<AssetProgress>> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
&w_id
)
.fetch_optional(&db)
.await?;
let Some(created_by) = created_by else {
return Ok(Json(vec![]));
};
require_job_read_access(
&db,
&user_db,
&authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
// `dbt_run_progress`, not `materialized_partition`: the latter is keyed by
// relation and its `job_id` names only the last writer, so two runs of one
// project building the same models would take rows from each other and this
// response would lose nodes.
let rows = sqlx::query!(
"SELECT asset_kind AS \"asset_kind: windmill_common::assets::AssetKind\", asset_path,
status::text AS \"status!\", row_count, error
FROM dbt_run_progress
WHERE workspace_id = $1 AND job_id = $2",
w_id,
job_id
)
.fetch_all(&mut *tx)
.await?;
// An agent worker reaches the database only through the API, and records its
// outcomes with the shared `record_materialization`, which writes the
// relation-keyed table alone. Falling back to it there keeps those runs
// showing progress; it is the racy source, but an agent run that has no rows
// of its own is strictly better served by it than by nothing.
let rows = if rows.is_empty() {
sqlx::query!(
"SELECT asset_kind AS \"asset_kind: windmill_common::assets::AssetKind\", asset_path,
status::text AS \"status!\", row_count, error
FROM materialized_partition
WHERE workspace_id = $1 AND job_id = $2",
w_id,
job_id
)
.fetch_all(&mut *tx)
.await?
.into_iter()
.map(|r| AssetProgress {
asset_kind: r.asset_kind,
asset_path: r.asset_path,
status: r.status,
row_count: r.row_count,
error: r.error,
})
.collect::<Vec<_>>()
} else {
rows.into_iter()
.map(|r| AssetProgress {
asset_kind: r.asset_kind,
asset_path: r.asset_path,
status: r.status,
row_count: r.row_count,
error: r.error,
})
.collect::<Vec<_>>()
};
tx.commit().await?;
Ok(Json(rows))
}
#[derive(Serialize)]
struct RunAsset {
path: String,
kind: windmill_common::assets::AssetKind,
#[serde(skip_serializing_if = "Option::is_none")]
access_type: Option<AssetUsageAccessType>,
}
#[derive(Serialize)]
struct RunAssets {
assets: Vec<RunAsset>,
truncated: bool,
}
/// A fan-out run — a forloop writing one object per iteration — touches as many
/// assets as it has steps, and the whole list would land in one response and one
/// list in the browser. Cap it, and say so rather than serving a prefix that
/// reads like the whole answer.
const RUN_ASSETS_CAP: usize = 1000;
/// Assets a run touched, as recorded by runtime detection, aggregated over the
/// whole job tree: the recorder attributes an asset to the job that performed
/// the operation, which for a flow step or a workflow-as-code task is not the
/// job the user opened. Lives with the job routes for the same reason
/// `run_progress` does — `asset` has no RLS, and `require_job_read_access` is
/// what applies the view token, a scoped token's tag filter and the app-embed
/// cutoff.
async fn list_run_assets(
authed: ApiAuthed,
OptViewToken(view_token): OptViewToken,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
) -> error::JsonResult<RunAssets> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
&w_id
)
.fetch_optional(&db)
.await?;
let Some(created_by) = created_by else {
return Ok(Json(RunAssets { assets: vec![], truncated: false }));
};
require_job_read_access(
&db,
&user_db,
&authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
// Walked on `db`, like the flow tree is: the gate above is what authorizes the
// run, and it grants access the caller's own RLS does not have — a view token,
// or a job the caller launched that runs as someone else. Re-filtering the tree
// through `user_db` would drop exactly those, and hand a share-link viewer the
// empty tab this endpoint exists to fix. The tag scope is the one restriction
// that must still hold per job, since the gate only checked the root. It gates
// which jobs' assets are read, not which are walked through: the gate admits a
// job on its own tag, so an out-of-scope job in the middle of the tree must not
// hide a descendant the caller could have asked for directly.
let scope_tags = get_scope_tags(&authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec());
let rows = sqlx::query!(
r#"WITH RECURSIVE job_tree AS (
SELECT id, tag FROM v2_job WHERE id = $2 AND workspace_id = $1
UNION
SELECT j.id, j.tag FROM v2_job j JOIN job_tree t ON j.parent_job = t.id
WHERE j.workspace_id = $1
)
SELECT
a.path,
a.kind AS "kind!: windmill_common::assets::AssetKind",
-- Several jobs of the tree touch one asset, each recording its own
-- access. A job that recorded none contributes nothing rather than
-- erasing a sibling's, so an all-null group is the only unknown one.
-- Grouping here, not in Rust, is what makes LIMIT count assets: the
-- retention keeps up to ten job rows per asset.
COALESCE(bool_or(a.usage_access_type IN ('r', 'rw')), false) AS "any_read!",
COALESCE(bool_or(a.usage_access_type IN ('w', 'rw')), false) AS "any_write!"
FROM asset a JOIN job_tree t ON a.usage_path = t.id::text
WHERE a.workspace_id = $1 AND a.usage_kind = 'job'
AND ($3::text[] IS NULL OR t.tag = ANY($3))
GROUP BY a.path, a.kind
ORDER BY a.path, a.kind
LIMIT $4"#,
w_id,
job_id,
scope_tags.as_deref(),
// One asset past the cap is how the response learns it was cut.
RUN_ASSETS_CAP as i64 + 1
)
.fetch_all(&db)
.await?;
let truncated = rows.len() > RUN_ASSETS_CAP;
let assets = rows
.into_iter()
.take(RUN_ASSETS_CAP)
.map(|row| RunAsset {
path: row.path,
kind: row.kind,
access_type: match (row.any_read, row.any_write) {
(true, true) => Some(AssetUsageAccessType::RW),
(true, false) => Some(AssetUsageAccessType::R),
(false, true) => Some(AssetUsageAccessType::W),
(false, false) => None,
},
})
.collect();
Ok(Json(RunAssets { assets, truncated }))
}
async fn get_flow_job_debug_info(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
tokened_o: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job = GetQuery::new()
.with_auth(&opt_authed)
.fetch_queued((&db).into(), &id, &w_id)
.await?;
if let Some(job) = job {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&job.created_by,
view_token.as_deref(),
)
.await?;
}
let is_flow = job.is_flow();
if job.is_flow_step || !is_flow {
return Err(error::Error::BadRequest(
"This endpoint is only for root flow jobs".to_string(),
));
}
let leaf_jobs: HashMap<String, JobResult> = job
.leaf_jobs
.clone()
.and_then(|x| serde_json::from_value(x).ok())
.unwrap_or_else(HashMap::new);
let mut jobs = HashMap::new();
let id = job.id.clone();
jobs.insert("root_job".to_string(), Job::QueuedJob(job));
let mut job_ids = vec![];
let jobs_with_root = sqlx::query_scalar!(
"SELECT id FROM v2_job WHERE workspace_id = $1 and flow_innermost_root_job = $2",
&w_id,
&id,
)
.fetch_all(&db)
.await?;
for job in jobs_with_root {
job_ids.push(job);
}
for job in leaf_jobs.iter() {
match job.1 {
JobResult::ListJob(jobs) => job_ids.extend(jobs.to_owned()),
JobResult::SingleJob(job) => job_ids.push(job.clone()),
}
}
for job_id in job_ids {
let job = GetQuery::new()
.with_auth(&opt_authed)
.fetch(&db, &job_id, &w_id)
.await;
if let Ok(job) = job {
jobs.insert(job.id().to_string(), job);
}
}
log_job_view(
&db,
opt_authed.as_ref(),
tokened_o.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(jobs).into_response())
} else {
Err(error::Error::NotFound(format!(
"QueuedJob {} not found",
id
)))
}
}
async fn list_selected_job_groups(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(uuids): Json<Vec<Uuid>>,
) -> error::Result<Response> {
let mut tx = user_db.begin(&authed).await?;
// Single-step-flows wrap either a script or a flow. The wrapped runnable type
// sits in raw_flow.modules under id='a' (id='main' is also tolerated), and the
// wrapped script's hash (when pinned) sits in the same module's value.hash.
// We project singlestepflow rows onto the script/flow grouping the rest of the
// query uses, and use the wrapped hash (when present) so the per-version
// schemas subquery resolves real schemas instead of returning nulls. A
// path-based schema fallback covers flow-wrapped singlestepflow (no version
// pinning) and any singlestepflow whose pinned hash has since been deleted.
let results = sqlx::query_scalar!(
r#"WITH normalized AS (
SELECT
jb.id,
jb.workspace_id,
jb.runnable_path,
CASE
WHEN jb.kind IN ('flow', 'script') THEN jb.kind::text
WHEN jb.kind = 'singlestepflow' THEN
COALESCE(
(SELECT m->'value'->>'type'
FROM jsonb_array_elements(jb.raw_flow->'modules') m
WHERE m->>'id' IN ('a', 'main')
LIMIT 1),
'script'
)
ELSE NULL
END AS norm_kind,
COALESCE(
jb.runnable_id,
CASE WHEN jb.kind = 'singlestepflow' THEN
(SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint
FROM jsonb_array_elements(jb.raw_flow->'modules') m
WHERE m->>'id' IN ('a', 'main')
AND m->'value'->>'hash' IS NOT NULL
LIMIT 1)
END
) AS effective_hash
FROM v2_job jb
WHERE jb.kind IN ('flow', 'script', 'singlestepflow')
AND jb.workspace_id = $1 AND jb.id = ANY($2)
)
SELECT jsonb_build_object(
'kind', n.norm_kind,
'script_path', n.runnable_path,
'latest_schema', COALESCE(
(SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.workspace_id = $1 AND s.path = n.runnable_path AND n.norm_kind = 'script' ORDER BY s.path, s.created_at DESC),
(SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path AND n.norm_kind = 'flow')
),
'schemas', ARRAY(
SELECT jsonb_build_object(
'script_hash', CASE WHEN COALESCE(s.hash, f.id) IS NULL THEN NULL ELSE LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0') END,
'job_ids', ARRAY_AGG(DISTINCT n2.id),
'schema', COALESCE(
(ARRAY_AGG(COALESCE(s.schema, f.schema)))[1],
CASE WHEN n.norm_kind = 'script' THEN
(SELECT DISTINCT ON (s2.path) s2.schema FROM script s2 WHERE s2.workspace_id = $1 AND s2.path = n.runnable_path ORDER BY s2.path, s2.created_at DESC)
END,
CASE WHEN n.norm_kind = 'flow' THEN
(SELECT fv.schema FROM flow LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path)
END
)
) FROM normalized n2
LEFT JOIN script s ON s.hash = n2.effective_hash AND n2.norm_kind = 'script'
LEFT JOIN flow_version f ON f.id = n2.effective_hash AND n2.norm_kind = 'flow'
WHERE n2.id = ANY(ARRAY_AGG(n.id))
GROUP BY COALESCE(s.hash, f.id)
)
) FROM normalized n
WHERE n.norm_kind IS NOT NULL
GROUP BY n.norm_kind, n.runnable_path"#,
&w_id,
&uuids
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(results).into_response())
}
#[derive(Deserialize)]
struct GetJobQuery {
pub no_logs: Option<bool>,
pub no_code: Option<bool>,
pub approval_token: Option<String>,
}
/// Authorize an *authenticated* caller to read a single job's data
/// (full job / args / result / logs / live updates).
///
/// Single-job read endpoints query through the root `DB` (RLS-bypassing), filtered
/// only by job id + workspace (+ token scope tags). That is required for the
/// unauthenticated approval / public-trigger / anonymous-job flows, but for a
/// logged-in user it meant any workspace member — e.g. a viewer with no ACL on the
/// runnable — could read another user's job args/result/logs simply by obtaining the
/// job UUID, even though the same job is hidden from them in `jobs/list`
/// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read)
///
/// Unauthenticated callers go through [`require_unauthed_job_read_access`] instead;
/// this gate applies only when a user is authenticated. Access is granted when:
/// - the caller created the job (`created_by`) — covers app components, webhooks and
/// the caller's own runs, whose `permissioned_as` is the policy identity rather
/// than the caller, so they would otherwise fail the RLS probe; or
/// - the job is visible to the caller under the same RLS as `jobs/list`, probed on
/// `v2_job` via `user_db` (admins BYPASSRLS).
///
/// Optional share-read-link token (validated by [`validate_view_token`]). Read from
/// the `view_token` query parameter — needed for `EventSource`/SSE and direct links,
/// which can't set headers — falling back to the `X-View-Token` header, which lets the
/// frontend attach it to every generated-client request via a single interceptor
/// instead of threading it through each call. Read independently of each handler's own
/// `Query<T>` extractor (axum allows only one typed `Query`).
pub struct OptViewToken(pub Option<String>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OptViewToken {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let from_query = parts.uri.query().and_then(|q| {
serde_urlencoded::from_str::<Vec<(String, String)>>(q)
.ok()
.and_then(|pairs| {
pairs
.into_iter()
.find(|(k, _)| k == "view_token")
.map(|(_, v)| v)
})
});
let token = from_query.or_else(|| {
parts
.headers
.get("x-view-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
Ok(OptViewToken(token))
}
}
/// Otherwise returns 404 — matching `scripts/get` and avoiding existence disclosure.
pub(crate) async fn require_job_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
require_job_access(
db, user_db, authed, w_id, job_id, created_by, view_token, true,
)
.await
}
async fn require_job_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
read_confined: bool,
) -> error::Result<()> {
// Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a
// scoped token: it must never read a job outside its allowed tags, regardless of
// how authorization is otherwise satisfied (created_by / view token / RLS). Most
// read handlers also tag-filter their data query, but some (result_by_id,
// get_flow_job_debug_info, get_otel_traces) do not, so enforce it here — before
// the grants below — so a share token can't be used to escape the tag scope.
// `get_scope_tags` is `None` for unscoped callers (the common case), so this adds
// no query for normal sessions/tokens.
if let Some(tags) = get_scope_tags(authed) {
let in_scope = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
job_id,
w_id,
&tags.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
)
.fetch_one(db)
.await?
== Some(true);
if !in_scope {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
}
// A path-scoped `jobs:run` or `jobs:read` token is likewise hard-restricted to the
// runnables it names, ahead of every grant below — the token is handed out for one
// thing, so it must not read jobs of anything else merely because its owner could.
if read_confined {
require_job_within_read_scope(db, authed, w_id, job_id).await?;
}
// Fast path: you can always read a job you launched. This is also load-bearing
// for apps — a component job runs as the app policy's `permissioned_as`, but its
// `created_by` is the launching viewer, so the RLS probe below would hide it.
if created_by == authed.username {
return Ok(());
}
// App embed tokens (the sandboxed app iframe) carry the viewer's identity so the
// app can read its own component runs — which are stamped `created_by == viewer`
// and so already returned above. They must NOT inherit the viewer's *broader*
// job access (share links, folder ACLs, admin RLS): user-authored app JS holds
// this token, and letting it reach any job merely visible to the viewer would
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
// A guest stops here too: it has no membership behind it, so a share token whose
// audience is the workspace's members must not read for it either.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
|| windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
{
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
// `username_override` is derived from the token *label* (`username_override_from_label`),
// which is fully user-controlled with no uniqueness/ownership check (webhook-/http-/
// email-/ws- trigger tokens, `ephemeral-script-end-user-*`, and the generic `label-*`
// all flow through it). A bare `username_override == created_by` match is therefore
// forgeable: any member can mint a token with a colliding label and read another
// principal's jobs (IDOR — results/args/logs with resolved secrets). Bind the grant to
// a non-forgeable attribute instead: the job must actually run as the caller's own
// identity, i.e. its `permissioned_as_email` (the token owner's email, never set from
// the label) equals `authed.email`. This still admits every legitimate same-owner
// re-read (trigger tokens reading their own webhook/http/email jobs, the
// ephemeral-script-end-user worker token, and jobs whose stored `created_by` is a
// `label-*` override) while denying cross-principal collisions. The DB hit only happens
// when an override is present and matches, so the common session/token path stays
// query-free.
if authed
.username_override
.as_deref()
.is_some_and(|u| u == created_by)
{
let job_email = sqlx::query_scalar!(
"SELECT permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?;
if job_email.as_deref() == Some(authed.email.as_str()) {
return Ok(());
}
}
// Share read link: a valid view token minted by someone with read access grants
// this authenticated member read of the shared job and its flow subtree. Either
// audience does — the public one is a superset of the member one.
if let Some(token) = view_token {
if validate_view_token(db, w_id, job_id, token)
.await?
.is_some()
{
return Ok(());
}
}
// The probe below (chain walk + an RLS-scoped transaction) is comparatively
// expensive and the same (caller, job) is hit repeatedly — e.g. `getupdate`
// polling of a run you can see but did not launch, or an admin watching many
// runs. Cache the boolean outcome. All job-side inputs to the decision
// (created_by, runnable_path, permissioned_as, visible_to_owner, flow lineage)
// are immutable after creation, and every mutable caller-side input
// (is_admin / username / username_override / groups / folders) is folded into
// the key — so a permission change yields a new key rather than a stale hit, and
// no TTL is needed (size-bounded LRU).
let cache_key = job_read_access_cache_key(authed, w_id, job_id);
let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) {
visible
} else {
// Visibility is inherited along the flow hierarchy: if you can read ANY flow
// that (transitively) contains this job, you can read the job. A step runs as
// its flow's `permissioned_as` but its `runnable_path` is the inner runnable's
// — which the caller may have no direct ACL on — and the flow-run UI fetches
// each step by id, so gating purely on the step's own RLS visibility would
// break inspecting a flow you can see but did not launch. We therefore probe
// RLS visibility of the job OR any of its `parent_job` ancestors (admins
// BYPASSRLS) — the same visibility as `jobs/list`.
let chain_ids = job_ancestor_chain_ids(db, w_id, job_id).await?;
let mut tx = user_db.clone().begin(authed).await?;
let visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&chain_ids[..],
w_id,
)
.fetch_one(&mut *tx)
.await?
== Some(true);
tx.commit().await?;
JOB_READ_ACCESS_CACHE.insert(cache_key, visible);
visible
};
if visible {
return Ok(());
}
// Denied. Distinguish "the run exists but you lack access" (actionable: ask a
// colleague for a share link) from "no such run", so the UI can guide the user.
// Only authenticated members reach this point and job UUIDs are non-enumerable,
// so disclosing mere existence to a member is an acceptable trade-off for the UX.
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&[*job_id][..],
w_id,
)
.fetch_one(db)
.await?
== Some(true);
if exists {
Err(Error::PermissionDenied(format!(
"You do not have access to run {job_id}. Ask a user who can see it to open the run and \
share a read-only link with you (the \"Share\" button on the run page)."
)))
} else {
Err(Error::NotFound(format!("Job {job_id} not found")))
}
}
/// Confines a path-scoped `jobs:run:<kind>:<path>` token to jobs of the runnables it may
/// start. Such a token is minted per script/flow for a webhook or CI caller, which needs
/// to start that runnable and poll the resulting job — nothing more. Without this, the
/// by-id read routes it reaches for polling (`RUN_WHITELISTED_GET_PATHS`) would serve
/// it the args/result/logs of any job its owner's identity can see, defeating the path
/// confinement the scope exists to provide.
///
/// The scope may be satisfied by the job itself or by any of its `parent_job` ancestors:
/// a flow step's `runnable_path` is the inner runnable's, so a `jobs:run:flows:<flow>`
/// token inspecting its own run must still reach the steps beneath it.
///
/// An `apps:run|write:<app>` scope is a start grant too, so a job an app launched
/// (`trigger_kind = 'app'`, an app-provenance stamp `/jobs/run` cannot forge) satisfies
/// the confinement for a token scoped to that app. Without this, a token holding both
/// could start an app's inline-script component but not read the run back — those jobs
/// are `AppScript`/`Preview` kinds that no `jobs:run` scope can name.
///
/// A `jobs:read:<paths>` scope confines the same way, by path alone: the job, or one of
/// its ancestors, must be a run of a deployed script, flow or agent at a named path.
/// Only those kinds count, because a preview's `runnable_path` is whatever its caller
/// sent and would otherwise let any preview pass for a named runnable.
///
/// No-op — and no query — for every caller whose job reads are not confined (see
/// `job_read_confinement`), which is all sessions, unscoped tokens and tokens whose
/// `jobs:read` names no path.
async fn require_job_within_read_scope(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
) -> error::Result<()> {
let Some(confinement) =
windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref())
else {
return Ok(());
};
// A job whose token is restricted by `job_token_scopes` still reads its own flow run
// (step results, user state), which the route check lets through whatever the
// scopes. A read path names what else it may read, not a fence around its own run.
if let Some(own_job) = authed.job_id {
if confinement.has_read_paths()
&& windmill_api_auth::auth::job_in_same_flow_run(db, own_job, *job_id).await
{
return Ok(());
}
}
// `scope_kind` is the runnable kind a `jobs:run:<kind>:<path>` scope can name, or
// NULL for a job no such scope reaches directly (previews, flow-inlined scripts) —
// those are still readable as a step of a matching flow, through their ancestors.
// A dependency job's path is the one its `jobs:run:dependencies:<path>` check
// admitted, but it is filed under `dependencies`, so it never passes for a run of
// the deployed runnable at that path. A `singlestepflow` wraps either a script or a flow, so it
// projects onto the wrapped runnable the same way the batch-rerun query does. An agent
// run is a preview of the one-step flow `agent_runs::agent_step_flow` builds, filed under the
// agent's path (`<path>.chat` for a chat turn); the editor's own runs of it look the same,
// and are runs of that agent too.
let chain = sqlx::query!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT
CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '')
ELSE j.runnable_path END AS runnable_path,
CASE
WHEN a.agent THEN 'agents'
WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'
WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'
WHEN j.kind IN ('dependencies', 'flowdependencies') THEN 'dependencies'
WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN
CASE WHEN COALESCE(
(SELECT m->'value'->>'type'
FROM jsonb_array_elements(j.raw_flow->'modules') m
WHERE m->>'id' IN ('a', 'main')
LIMIT 1),
'script'
) = 'flow' THEN 'flows' ELSE 'scripts' END
END AS scope_kind,
CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app
FROM v2_job j JOIN chain c ON c.id = j.id,
LATERAL (SELECT j.kind = 'flowpreview'
AND j.raw_flow->'modules'->1 IS NULL
AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a
WHERE j.workspace_id = $2"#,
job_id,
w_id,
)
.fetch_all(db)
.await?;
let runs_app = build_scope_path_predicate(authed, "apps", "run");
let in_scope =
chain.iter().any(
|job| match (job.runnable_path.as_deref(), job.scope_kind.as_deref()) {
(Some(runnable_path), Some(kind)) if confinement.admits(kind, runnable_path) => {
true
}
_ => job.launched_by_app.as_deref().is_some_and(&runs_app),
},
);
if in_scope {
Ok(())
} else {
Err(Error::NotFound(format!("Job {job_id} not found")))
}
}
/// The `ids` a path-scoped `jobs:cancel:<paths>` token may cancel; all of them for
/// every caller whose cancels are not path-confined (see `job_cancel_path_confinement`).
///
/// A job is admitted when it, or any of its `parent_job` ancestors, is a run of a
/// deployed script, flow or agent whose path the scope names: cancelling a flow's step
/// is within a scope on the flow. Only those kinds count, because a preview's
/// `runnable_path` is whatever its caller sent and would otherwise let any preview
/// impersonate an in-scope runnable. Agent runs are previews filed under the agent's
/// path, recognized the same way `require_job_within_read_scope` does.
async fn filter_jobs_within_cancel_scope(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
ids: Vec<Uuid>,
) -> error::Result<Vec<Uuid>> {
let Some(confinement) =
windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref())
else {
return Ok(ids);
};
let chain = sqlx::query!(
r#"WITH RECURSIVE chain(origin, id, parent_job) AS (
SELECT id, id, parent_job FROM v2_job WHERE id = ANY($1) AND workspace_id = $2
UNION ALL
SELECT c.origin, j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT c.origin AS "origin!",
CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '')
ELSE j.runnable_path END AS "runnable_path!"
FROM chain c JOIN v2_job j ON j.id = c.id,
LATERAL (SELECT j.kind = 'flowpreview'
AND j.raw_flow->'modules'->1 IS NULL
AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a
WHERE j.workspace_id = $2 AND j.runnable_path IS NOT NULL
AND (a.agent OR j.kind IN ('script', 'script_hub', 'unassigned_script', 'flow',
'unassigned_flow', 'singlestepflow', 'unassigned_singlestepflow'))"#,
&ids,
w_id,
)
.fetch_all(db)
.await?;
let admitted: HashSet<Uuid> = chain
.into_iter()
.filter(|r| {
windmill_api_auth::scopes::cancel_confinement_admits(&confinement, &r.runnable_path)
})
.map(|r| r.origin)
.collect();
Ok(ids.into_iter().filter(|id| admitted.contains(id)).collect())
}
async fn require_job_within_cancel_scope(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
job_id: Uuid,
) -> error::Result<()> {
if filter_jobs_within_cancel_scope(db, authed, w_id, vec![job_id])
.await?
.is_empty()
{
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
Ok(())
}
/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level
/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive).
/// Falls back to `[job_id]` if the row is absent so callers still run their probe.
async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Result<Vec<Uuid>> {
let chain_ids = sqlx::query_scalar!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT id AS "id!" FROM chain"#,
job_id,
w_id,
)
.fetch_all(db)
.await?;
Ok(if chain_ids.is_empty() {
vec![*job_id]
} else {
chain_ids
})
}
/// Who a share read link was minted for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ViewTokenAudience {
/// Read of the job subtree for an authenticated workspace member.
Member,
/// The same read, additionally granted to logged-out visitors.
Public,
}
/// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is
/// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants
/// read of that job and its whole flow subtree, so the run page can present a single
/// link that also renders the flow's steps. Returns the token's audience iff the
/// signature is valid AND `accessed_job_id` is the shared job or one of its descendants.
async fn validate_view_token(
db: &DB,
w_id: &str,
accessed_job_id: &Uuid,
token: &str,
) -> error::Result<Option<ViewTokenAudience>> {
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
return Ok(None);
};
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
return Ok(None);
};
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
return Ok(None);
};
// Constant-time verification (same domains as `generate_view_token`, mirroring
// `verify_suspended_secret`); avoids the timing side-channel of comparing the
// hex strings with `!=`.
let key = get_workspace_key(w_id, db).await?;
let verify = |domain: &[u8]| -> error::Result<bool> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(shared_id.as_bytes());
mac.update(domain);
Ok(mac.verify_slice(&provided_bytes).is_ok())
};
let audience = if verify(VIEW_TOKEN_DOMAIN)? {
ViewTokenAudience::Member
} else if verify(PUBLIC_VIEW_TOKEN_DOMAIN)? {
ViewTokenAudience::Public
} else {
return Ok(None);
};
if accessed_job_id == &shared_id {
return Ok(Some(audience));
}
// The token authorizes the shared job's subtree: accessed must descend from it,
// i.e. the shared job is among accessed's ancestors.
let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?;
Ok(chain.contains(&shared_id).then_some(audience))
}
/// Whether `token` is a *public* share link covering `job_id`. This is the only thing
/// that lets a logged-out caller read a job that was not itself run anonymously.
async fn public_view_token_grants(
db: &DB,
w_id: &str,
job_id: &Uuid,
token: Option<&str>,
) -> error::Result<bool> {
let Some(token) = token else {
return Ok(false);
};
Ok(validate_view_token(db, w_id, job_id, token).await? == Some(ViewTokenAudience::Public))
}
/// Read gate for logged-out callers, the counterpart of [`require_job_read_access`]: a job
/// run anonymously is public by construction, and a public share link publishes any other.
async fn require_unauthed_job_read_access(
db: &DB,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
if created_by == "anonymous" || public_view_token_grants(db, w_id, job_id, view_token).await? {
return Ok(());
}
Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
))
}
/// The read gate of every handler that serves both audiences (the `jobs_u` router):
/// [`require_job_read_access`] when logged in, [`require_unauthed_job_read_access`] when not.
async fn require_opt_authed_job_read_access(
db: &DB,
user_db: &UserDB,
opt_authed: &Option<ApiAuthed>,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
match opt_authed {
Some(authed) => {
require_job_read_access(db, user_db, authed, w_id, job_id, created_by, view_token).await
}
None => require_unauthed_job_read_access(db, w_id, job_id, created_by, view_token).await,
}
}
lazy_static::lazy_static! {
/// Caches the result of the `require_job_read_access` RLS visibility probe,
/// keyed by the caller's authorization-relevant identity plus the job id (see
/// [`job_read_access_cache_key`]). No TTL: the cached decision is a pure function
/// of immutable job-side state and the caller-side state encoded in the key, so a
/// permission change re-keys rather than going stale. Size-bounded LRU.
static ref JOB_READ_ACCESS_CACHE: Cache<[u8; 32], bool> = Cache::new(50_000);
}
/// Key for [`JOB_READ_ACCESS_CACHE`]: a SHA-256 over every caller-side input that
/// affects job-read visibility (admin flag, username, username override, the sorted
/// group set, and the sorted folder set the caller has any grant on — RLS reads from
/// all of them) plus the workspace and job id. Sorting makes the key order-independent;
/// each variable-length field is length-prefixed so no choice of input values can make
/// two distinct identities hash equal (e.g. `["a","bc"]` vs `["ab","c"]`).
fn job_read_access_cache_key(authed: &ApiAuthed, w_id: &str, job_id: &Uuid) -> [u8; 32] {
let mut hasher = Sha256::new();
// Length-prefix every variable-length field (u32 BE) to make the encoding injective.
let field = |hasher: &mut Sha256, bytes: &[u8]| {
hasher.update((bytes.len() as u32).to_be_bytes());
hasher.update(bytes);
};
hasher.update([authed.is_admin as u8]);
field(&mut hasher, authed.username.as_bytes());
field(
&mut hasher,
authed.username_override.as_deref().unwrap_or("").as_bytes(),
);
let mut groups: Vec<&str> = authed.groups.iter().map(String::as_str).collect();
groups.sort_unstable();
hasher.update((groups.len() as u32).to_be_bytes());
for g in groups {
field(&mut hasher, g.as_bytes());
}
let mut folders: Vec<&str> = authed.folders.iter().map(|f| f.0.as_str()).collect();
folders.sort_unstable();
hasher.update((folders.len() as u32).to_be_bytes());
for f in folders {
field(&mut hasher, f.as_bytes());
}
field(&mut hasher, w_id.as_bytes());
hasher.update(job_id.as_bytes());
hasher.finalize().into()
}
/// [`require_job_read_access`] for callers (job-update poll / SSE) that haven't
/// already loaded `created_by` — fetches it (root DB, by id+workspace) first.
async fn require_job_update_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
view_token: Option<&str>,
) -> error::Result<()> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await
}
/// The per-job check of the cancel routes. A `jobs:cancel` grant stands on its own, like
/// `jobs:write`: its paths confine it (`filter_jobs_within_cancel_scope`), not the
/// token's `jobs:run` scopes, which bound what a run token may read. Intersecting the two
/// would not hold anyway, since the token can mint itself a child holding only the
/// cancel scope.
async fn require_job_cancel_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
) -> error::Result<()> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let read_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref());
require_job_access(
db,
user_db,
authed,
w_id,
job_id,
&created_by,
None,
read_confined,
)
.await?;
require_job_within_cancel_scope(db, authed, w_id, *job_id).await
}
/// Whether a validated approval token should grant the job-read bypass. The token alone
/// is sufficient unless the current approval step has `user_auth_required`, in which case
/// only an authorized approver may read the job (and thus its args/flow inputs).
async fn approval_token_grants_view(
db: &DB,
w_id: &str,
flow_id: Uuid,
opt_authed: &Option<ApiAuthed>,
) -> error::Result<bool> {
#[derive(sqlx::FromRow)]
struct FlowAuthRow {
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
}
let row = sqlx::query_as::<_, FlowAuthRow>(
"SELECT j.runnable_path as script_path, j.permissioned_as_email as email, s.flow_status
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(flow_id)
.bind(w_id)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(false);
};
// approval_conditions are stored at the top of flow_status for both classic and WAC flows.
let approval_conditions = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions").cloned())
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v).ok());
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
if !user_auth_required {
return Ok(true);
}
Ok(can_approve_step(
opt_authed,
&approval_conditions,
row.script_path.as_deref(),
row.email.as_str(),
))
}
async fn get_job(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetJobQuery { no_logs, no_code, approval_token }): Query<GetJobQuery>,
) -> error::Result<Response> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
// A valid approval token on the same (workspace, flow) grants read access
// so the approval page can render job metadata without login. The approval
// URL usually carries the flow id directly — try that first and only
// resolve the parent flow if the direct check fails.
let approved_flow_id: Option<Uuid> = if let Some(ref token) = approval_token {
if validate_approval_token(&db, token, id, &w_id).await.is_ok() {
Some(id)
} else if let Ok(flow_id) = get_flow_id_for_job(&db, id).await {
if flow_id != id
&& validate_approval_token(&db, token, flow_id, &w_id)
.await
.is_ok()
{
Some(flow_id)
} else {
None
}
} else {
None
}
} else {
None
};
// A valid token grants the read bypass, but only to an authorized approver when the
// current approval step requires auth — otherwise the token (which lives in the shareable
// approval URL) would leak the flow inputs to anyone holding the link.
let has_valid_approval_token = if let Some(flow_id) = approved_flow_id {
approval_token_grants_view(&db, &w_id, flow_id, &opt_authed).await?
} else {
false
};
let public_view_grant = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
let mut get = GetQuery::new()
.with_in_tags(tags.as_ref())
.with_public_view_grant(public_view_grant);
if !has_valid_approval_token {
get = get.with_auth(&opt_authed);
}
if no_code.unwrap_or(false) {
get = get.without_code();
}
if no_logs.unwrap_or(false) {
get = get.without_logs();
}
let mut job = get.fetch(&db, &id, &w_id).await?;
job.fetch_outstanding_wait_time(&db).await?;
// A valid approval token is itself the capability; otherwise the caller must pass the
// same visibility as `jobs/list` (see `require_job_read_access`), or hold a public
// share link when logged out — which `public_view_grant` already established above,
// so skip re-deriving it here: this handler is what the public run page polls.
if has_valid_approval_token || public_view_grant {
// Both grants skip the gate below, and with it the run-scope confinement that
// gate carries. That confinement is a hard restriction, so re-apply it: holding
// an approval link for a job must not let a scoped token read one outside the
// runnables it may start.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_read_scope(&db, authed, &w_id, &id).await?;
}
} else {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
job.created_by(),
view_token.as_deref(),
)
.await?;
}
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(job).into_response())
}
macro_rules! get_job_query {
("v2_job_completed", $($opts:tt)*) => {
get_job_query!(
@impl "v2_job_completed", ($($opts)*),
"v2_job_completed.duration_ms, v2_job_completed.completed_at, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, \
v2_job.labels, \
EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = v2_job.id) as is_retry, \
EXISTS(SELECT 1 FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved, \
(SELECT resolved_by FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_by, \
(SELECT resolved_at FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_at, \
(SELECT note FROM job_resolution WHERE job_id = v2_job_completed.id) as resolution_note, \
(SELECT automatic FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_automatically, \
CASE WHEN result is null or pg_column_size(result) < 90000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result",
"",
)
};
("v2_job_queue", $($opts:tt)*) => {
get_job_query!(
@impl "v2_job_queue", ($($opts)*),
"scheduled_for, running, ping as last_ping, suspend, suspend_until, same_worker, pre_run_error, visible_to_owner, \
flow_innermost_root_job AS root_job, flow_leaf_jobs AS leaf_jobs, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, \
script_entrypoint_override, v2_job.labels, \
EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = v2_job.id) as is_retry",
"LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id",
)
};
(@impl $table:literal, (with_logs: $with_logs:expr, $($rest:tt)*), $additional_fields:literal, $additional_joins:literal, $($args:tt)*) => {
if $with_logs {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, logs = "right(job_logs.logs, 20000)", $($args)*)
} else {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, logs = "null", $($args)*)
}
};
(@impl $table:literal, (with_code: $with_code:expr, $($rest:tt)*), $additional_fields:literal, $additional_joins:literal, $($args:tt)*) => {
if $with_code {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, lock = "raw_lock", code = "raw_code", $($args)*)
} else {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, lock = "null", code = "null", $($args)*)
}
};
(@impl $table:literal, (with_flow: $with_flow:expr, $($rest:tt)*), $additional_fields:literal, $additional_joins:literal, $($args:tt)*) => {
if $with_flow {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, flow = "raw_flow", $($args)*)
} else {
get_job_query!(@impl $table, ($($rest)*), $additional_fields, $additional_joins, flow = "null", $($args)*)
}
};
(@impl $table:literal, (), $additional_fields:literal, $additional_joins:literal, $($args:tt)*) => {
const_format::formatcp!(
"SELECT \
{table}.id, {table}.workspace_id, parent_job, v2_job.created_by, v2_job.created_at, started_at, v2_job.runnable_id as script_hash, v2_job.runnable_path as script_path, \
CASE WHEN args is null THEN NULL
WHEN pg_column_size(args) < 90000 THEN
CASE WHEN jsonb_typeof(args) = 'object' THEN args
ELSE jsonb_build_object('value', args)
END
ELSE '{{\"reason\": \"WINDMILL_TOO_BIG\"}}'::jsonb END as args, flow_status, workflow_as_code_status, \
{logs} as logs, {code} as raw_code, canceled_by is not null as canceled, canceled_by, canceled_reason, kind as job_kind, \
CASE WHEN trigger_kind = 'schedule'::job_trigger_kind THEN trigger END AS schedule_path, v2_job.trigger_kind, permissioned_as, \
{flow} as raw_flow, flow_step_id IS NOT NULL AS is_flow_step, script_lang as language, \
{lock} as raw_lock, permissioned_as_email as email, visible_to_owner, memory_peak as mem_peak, v2_job.tag, v2_job.priority, preprocessed, worker,\
{additional_fields} \
FROM {table}
INNER JOIN v2_job ON v2_job.id = {table}.id \
{additional_joins} \
LEFT JOIN job_logs ON {table}.id = job_id \
WHERE {table}.id = $1 AND {table}.workspace_id = $2 AND ($3::text[] IS NULL OR v2_job.tag = ANY($3))",
table = $table,
additional_fields = $additional_fields,
additional_joins = $additional_joins,
$($args)*
)
}
}
#[derive(Copy, Clone)]
struct GetQuery<'a> {
with_logs: bool,
with_code: bool,
with_flow: bool,
with_auth: Option<&'a Option<ApiAuthed>>,
with_in_tags: Option<&'a Vec<&'a str>>,
/// A public share link covering this job was presented: lifts the logged-out
/// restriction in [`Self::check_auth`], the way a view token lifts the ACL check
/// in `require_job_read_access` for a member.
with_public_view_grant: bool,
}
impl<'a> GetQuery<'a> {
fn new() -> Self {
Self {
with_logs: true,
with_code: true,
with_flow: true,
with_auth: None,
with_in_tags: None,
with_public_view_grant: false,
}
}
fn without_logs(self) -> Self {
Self { with_logs: false, ..self }
}
fn without_code(self) -> Self {
Self { with_code: false, ..self }
}
#[allow(dead_code)]
fn without_flow(self) -> Self {
Self { with_flow: false, ..self }
}
fn with_auth(self, auth: &'a Option<ApiAuthed>) -> Self {
Self { with_auth: Some(auth), ..self }
}
fn with_in_tags(self, in_tags: Option<&'a Vec<&str>>) -> Self {
Self { with_in_tags: in_tags, ..self }
}
fn with_public_view_grant(self, granted: bool) -> Self {
Self { with_public_view_grant: granted, ..self }
}
fn check_auth(&self, email: Option<&str>) -> error::Result<()> {
if let Some(email) = email {
if self.with_auth.is_some_and(|x| x.is_none())
&& email != "anonymous"
&& !self.with_public_view_grant
{
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
}
Ok(())
}
/// Resolve job raw values.
/// This fetch the raw values from the cache and update the job accordingly.
///
/// # Details
/// Most of the raw values (code, lock and flow) had been removed from the `job`, `queue` and
/// `completed_job` tables. Only remains ones for "preview" jobs (i.e. [`JobKind::Preview`],
/// [`JobKind::FlowPreview`] and [`JobKind::Dependencies`]). [`JobKind::Flow`] as well but only
/// when pushed from an un-updated workers.
/// This function is used to make the above change transparent for the API, as the returned jobs
/// will have the raw values as if they were still in the tables.
async fn resolve_raw_values<T: JobCommon>(
&self,
db: &DB,
id: Uuid,
hash: Option<ScriptHash>,
job: &mut JobExtended<T>,
) {
let (raw_code, raw_lock, raw_flow) = (
job.raw_code.take(),
job.raw_lock.take(),
job.raw_flow.take(),
);
if self.with_flow {
// Try to fetch the flow from the cache, fallback to the preview flow.
// NOTE: This could check for the job kinds instead of the `or_else` but it's not
// necessary as `fetch_flow` return early if the job kind is not a preview one.
cache::job::fetch_flow(db, job.job_kind(), hash)
.or_else(|_| cache::job::fetch_preview_flow(db, &id, raw_flow))
.await
.ok()
.inspect(|data| job.raw_flow = Some(sqlx::types::Json(data.raw_flow.clone())));
}
if self.with_code
&& matches!(
job.job_kind(),
JobKind::Preview | JobKind::FlowScript | JobKind::AppScript
)
{
// Try to fetch the code from the cache, fallback to the preview code.
// `fetch_script` resolves FlowScript / AppScript via their runnable_id; for
// Preview jobs it returns early and we fall through to `fetch_preview_script`.
let conn = Connection::from(db.clone());
cache::job::fetch_script(db.clone(), job.job_kind(), hash)
.or_else(|_| cache::job::fetch_preview_script(&conn, &id, raw_lock, raw_code))
.await
.ok()
.inspect(|data| {
(job.raw_lock, job.raw_code) = (data.lock.clone(), Some(data.code.clone()))
});
}
}
async fn fetch_queued(
self,
db: &DB,
job_id: &Uuid,
workspace_id: &str,
) -> error::Result<Option<JobExtended<QueuedJob>>> {
let query = get_job_query!("v2_job_queue",
with_logs: self.with_logs,
with_code: self.with_code,
with_flow: self.with_flow,
);
let query = sqlx::query_as::<_, JobExtended<QueuedJob>>(query)
.bind(job_id)
.bind(workspace_id)
.bind(self.with_in_tags);
let mut job = query.fetch_optional(db).await?;
self.check_auth(job.as_ref().map(|job| job.created_by.as_str()))?;
if let Some(job) = job.as_mut() {
self.resolve_raw_values(&db, job.id, job.script_hash, job)
.await;
}
if self.with_flow {
job = resolve_maybe_value(db, workspace_id, self.with_code, job, |job| {
job.raw_flow.as_mut()
})
.await?;
}
Ok(job)
}
async fn fetch_completed(
self,
db: &DB,
job_id: &Uuid,
workspace_id: &str,
) -> error::Result<Option<JobExtended<CompletedJob>>> {
let query = get_job_query!("v2_job_completed",
with_logs: self.with_logs,
with_code: self.with_code,
with_flow: self.with_flow,
);
// tracing::info!("query: {}", query);
let query = sqlx::query_as::<_, JobExtended<CompletedJob>>(query)
.bind(job_id)
.bind(workspace_id)
.bind(self.with_in_tags);
let mut cjob = query.fetch_optional(db).await?;
self.check_auth(cjob.as_ref().map(|job| job.created_by.as_str()))?;
if let Some(job) = cjob.as_mut() {
self.resolve_raw_values(db, job.id, job.script_hash, job)
.await;
}
if self.with_flow {
cjob = resolve_maybe_value(db, workspace_id, self.with_code, cjob, |job| {
job.raw_flow.as_mut()
})
.await?;
}
if let Some(mut cjob) = cjob {
cjob.inner = format_completed_job_result(cjob.inner);
return Ok(Some(cjob));
}
Ok(cjob)
}
async fn fetch(self, db: &DB, job_id: &Uuid, workspace_id: &str) -> error::Result<Job> {
let cjob = self
.fetch_completed(db.into(), job_id, workspace_id)
.await?
.map(Job::CompletedJob);
match cjob {
Some(cjob) => Ok(cjob),
None => {
let job_maybe = self
.fetch_queued(db.into(), job_id, workspace_id)
.await?
.map(Job::QueuedJob);
// potential race condition here, if the job was in queue and completed right after the fetch completed, so we need to check one last time
if let Some(job) = job_maybe {
return Ok(job);
} else {
let cjob2 = self
.fetch_completed(db.into(), job_id, workspace_id)
.await?
.map(Job::CompletedJob);
not_found_if_none(cjob2, "Job", job_id.to_string())
}
}
}
}
}
#[cfg(all(feature = "instance_smtp", feature = "enterprise"))]
async fn send_workspace_trigger_failure_email_notification(
db: &DB,
w_id: &str,
job_id: &Uuid,
trigger_path: Option<&str>,
runnable_path: Option<&str>,
email_recipients: &Vec<String>,
error: &Value,
) -> Result<String, Error> {
let smtp_config = match load_smtp_config(db).await? {
Some(config) => config,
None => {
tracing::info!(
"SMTP not configured, skipping workspace trigger failure email notification"
);
return Err(anyhow::anyhow!(
"SMTP not configured, skipping workspace trigger failure email notification"
)
.into());
}
};
let runnable_path = runnable_path.as_deref().unwrap_or("Unknown");
let (trigger_kind, trigger_path) = if let Some(trigger_path) = trigger_path.as_deref() {
match trigger_path.split_once('/') {
Some((trigger_kind, trigger_path)) => {
tracing::debug!(
"Workspace trigger job {} is a {:?} trigger",
&job_id,
trigger_kind
);
(Some(trigger_kind), Some(trigger_path))
}
_ => (None, None),
}
} else {
(None, None)
};
let base_url = BASE_URL.load();
let job_url = format!("{}/run/{}?workspace={}", base_url, &job_id, w_id);
let trigger_kind_str = trigger_kind.unwrap_or("Unknown").to_string().to_uppercase();
let subject = format!(
"Windmill Job Failed: {} in workspace {}",
runnable_path, w_id
);
let error_details = serde_json::to_string_pretty(&error)
.unwrap_or_else(|_| format!("Unable to serialize error: {:?}", error));
let trigger_info = if trigger_kind.is_some() && trigger_path.is_some() {
format!(
r#"
<div class="section">
<span class="label">Trigger path:</span> {}
</div>
<div class="section">
<span class="label">Trigger Type:</span> {}
</div>"#,
trigger_path.unwrap(),
trigger_kind_str
)
} else {
String::new()
};
let email_title = if trigger_kind.is_some() {
format!("Windmill Trigger Job {} Failed", &job_id)
} else {
format!("Windmill Job {} Failed", &job_id)
};
let content = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; color: #333; }}
h1 {{ color: #c0392b; }}
.section {{ margin-bottom: 20px; }}
.label {{ font-weight: bold; }}
pre {{ background-color: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }}
a.button {{
display: inline-block;
padding: 10px 15px;
margin-top: 10px;
color: #fff;
background-color: #2980b9;
text-decoration: none;
border-radius: 5px;
}}
</style>
</head>
<body>
<h1>{}</h1>
<div class="section">
<span class="label">Workspace:</span> {}
</div>
{}
<div class="section">
<span class="label">Script/Flow Path:</span> {}
</div>
<div class="section">
<span class="label">Job ID:</span> {}
</div>
<div class="section">
<span class="label">Error Details:</span>
<pre>{}</pre>
</div>
<a href="{}" class="button">View Job Details</a>
</body>
</html>
"#,
email_title, w_id, trigger_info, runnable_path, &job_id, error_details, job_url
);
if let Err(e) = send_email_html(
&subject,
&content,
email_recipients.to_owned(),
smtp_config,
None,
)
.await
{
let err_msg = format!(
"Failed to send workspace email notification for trigger failure (trigger kind: {}, job ID: {}): {}",
trigger_kind_str,
&job_id,
e
);
return Err(Error::internal_err(err_msg));
}
let success_msg = format!(
"Job ID '{}' failed. An email with error details has been sent to {:?}.",
&job_id, email_recipients
);
tracing::info!("{}", &success_msg);
Ok(success_msg)
}
#[derive(Debug, Serialize, Deserialize)]
struct SendEmail {
job_id: Uuid,
#[serde(default, deserialize_with = "empty_as_none")]
trigger_path: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
runnable_path: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
email_recipients: Option<Vec<String>>,
error: Value,
}
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
async fn send_email_with_instance_smtp(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(send_email): Json<SendEmail>,
) -> error::Result<Json<String>> {
use windmill_common::jobs::EMAIL_ERROR_HANDLER_USER_EMAIL;
use windmill_queue::{ERROR_HANDLER_USER_EMAIL, SCHEDULE_ERROR_HANDLER_USER_EMAIL};
if *CLOUD_HOSTED {
tracing::warn!(
"Workspace trigger failure email notification is not available for cloud hosted Windmill",
);
return Err(anyhow::anyhow!("Feature not supported in cloud hosted windmill").into());
}
// Workspace error handlers (admin-configured, custom ones included) and the preset hub
// handlers of a schedule run as one of these identities. A custom schedule handler runs as
// the schedule and is set by any schedule writer, so it must stay out of this list.
let is_handler_job = authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL
|| authed.email == ERROR_HANDLER_USER_EMAIL
|| authed.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL;
if !is_handler_job && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
return Err(Error::NotAuthorized(
"Only super admin, a workspace error handler or a preset schedule handler can send emails with the instance SMTP"
.to_string(),
));
}
// Missing/empty `email_recipients` is a schedule-level misconfiguration (e.g. the
// user picked the email handler in the UI but never entered an address). Log a warning
// and return a client error — do NOT escalate to `report_critical_error`, which would
// fan this out to the instance critical-error channels on every failed run.
let Some(recipients) = send_email.email_recipients.as_ref() else {
tracing::warn!(
workspace_id = %w_id,
trigger_path = ?send_email.trigger_path,
runnable_path = ?send_email.runnable_path,
"Email error handler invoked without any `email_recipients` in on_failure_extra_args; \
skipping send. Configure recipients on the schedule / workspace error handler."
);
return Err(Error::BadRequest(
"Email error handler invoked without any `email_recipients` configured".to_string(),
));
};
let resp = send_workspace_trigger_failure_email_notification(
&db,
&w_id,
&send_email.job_id,
send_email.trigger_path.as_deref(),
send_email.runnable_path.as_deref(),
recipients,
&send_email.error,
)
.await?;
Ok(Json(resp))
}
#[cfg(not(all(feature = "enterprise", feature = "instance_smtp")))]
async fn send_email_with_instance_smtp(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_send_email): Json<SendEmail>,
) -> error::Result<Json<String>> {
tracing::warn!("SMTP is not enabled, skipping workspace trigger failure email notification",);
return Err(anyhow::anyhow!("SMTP is not enabled").into());
}
#[cfg(all(feature = "enterprise", feature = "parquet"))]
async fn get_logs_from_store(
log_offset: i32,
logs: &str,
log_file_index: &Option<Vec<String>>,
) -> Option<error::Result<Body>> {
use futures::StreamExt;
let stream =
windmill_object_store::get_logs_from_store(log_offset, logs, log_file_index).await?;
let header = bytes::Bytes::from(
r#"to remove ansi colors, use: | sed 's/\x1B\[[0-9;]\{1,\}[A-Za-z]//g'
"#
.to_string(),
);
let prefixed_stream = futures::stream::once(async { Ok(header) }).chain(stream);
Some(Ok(Body::from_stream(prefixed_stream)))
}
async fn get_logs_from_disk(
log_offset: i32,
logs: &str,
log_file_index: &Option<Vec<String>>,
) -> Option<error::Result<Body>> {
if log_offset > 0 {
if let Some(file_index) = log_file_index.clone() {
for file_p in &file_index {
if !is_safe_log_file_path(file_p) {
return None;
}
let local_file = format!("{}/{file_p}", *WINDMILL_DIR);
// Defense in depth: refuse to read through a symlink so a planted
// symlink under the log directory cannot exfiltrate arbitrary files.
match tokio::fs::symlink_metadata(&local_file).await {
Ok(meta) if meta.file_type().is_symlink() => return None,
Ok(_) => {}
Err(_) => return None,
}
}
let logs = logs.to_string();
let stream = async_stream::stream! {
yield Ok(bytes::Bytes::from(
r#"to remove ansi colors, use: | sed 's/\x1B\[[0-9;]\{1,\}[A-Za-z]//g'
"#.to_string(),
));
for file_p in file_index.clone() {
let mut file = tokio::fs::File::open(format!("{}/{file_p}", *WINDMILL_DIR)).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
yield Ok(bytes::Bytes::from(buffer)) as anyhow::Result<bytes::Bytes>;
}
yield Ok(bytes::Bytes::from(logs))
};
return Some(Ok(Body::from_stream(stream)));
}
}
return None;
}
async fn get_completed_job_logs_tail(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<String> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
.flatten();
let record = sqlx::query!(
"SELECT created_by AS \"created_by!\", coalesce(job_logs.logs, '') as logs
FROM v2_job
LEFT JOIN job_logs ON job_logs.job_id = v2_job.id
WHERE v2_job.id = $1 AND v2_job.workspace_id = $2 AND ($3::text[] IS NULL OR v2_job.tag = ANY($3))
ORDER BY job_logs.log_offset DESC
LIMIT 100",
id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(&db)
.await?;
if let Some(record) = record {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
let logs = record.logs.unwrap_or_default();
Ok(Json(logs))
} else {
Err(Error::NotFound("Job not found".to_string()).into())
}
}
#[derive(Debug, Deserialize)]
struct QueryJobLogs {
remove_ansi_warnings: Option<bool>,
}
async fn get_job_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(query_job_logs): Query<QueryJobLogs>,
) -> error::Result<Response> {
// let audit_author: AuditAuthor = match opt_authed {
// Some(authed) => (&authed).into(),
// None => {
// return Err(Error::BadRequest(format!(
// "Cancelling script require to be logged in and member of {w_id}"
// )))
// }
// };
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
.flatten();
let record = sqlx::query!(
"SELECT j.created_by AS \"created_by\", coalesce(job_logs.logs, '') as logs, COALESCE(job_logs.log_offset, 0) as log_offset, job_logs.log_file_index
FROM v2_job j
LEFT JOIN job_logs ON job_logs.job_id = j.id
WHERE j.id = $1 AND j.workspace_id = $2 AND ($3::text[] IS NULL OR j.tag = ANY($3))",
id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(&db)
.await?;
if let Some(record) = record {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
let logs = record.logs.unwrap_or_default();
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(r) = get_logs_from_store(
record.log_offset.unwrap_or(0),
&logs,
&record.log_file_index,
)
.await
{
return r.map(content_plain);
}
if let Some(r) = get_logs_from_disk(
record.log_offset.unwrap_or(0),
&logs,
&record.log_file_index,
)
.await
{
return r.map(content_plain);
}
let logs = if query_job_logs.remove_ansi_warnings.unwrap_or(false) {
logs
} else {
format!(
"to remove ansi colors, use: | sed 's/\\x1B\\[[0-9;]\\{{1,\\}}[A-Za-z]//g'\n{}",
logs
)
};
Ok(content_plain(Body::from(logs)))
} else {
let text = sqlx::query!(
"SELECT j.created_by AS \"created_by!\", CONCAT(coalesce(job_logs.logs, '')) as logs, coalesce(job_logs.log_offset, 0) as log_offset, job_logs.log_file_index
FROM v2_job j
LEFT JOIN job_logs ON job_logs.job_id = j.id
WHERE j.id = $1 AND j.workspace_id = $2 AND ($3::text[] IS NULL OR j.tag = ANY($3))",
id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(&db)
.await?;
let text = not_found_if_none(text, "Job Logs", id.to_string())?;
if opt_authed.is_none() {
require_unauthed_job_read_access(
&db,
&w_id,
&id,
&text.created_by,
view_token.as_deref(),
)
.await?;
}
let logs = text.logs.unwrap_or_default();
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(r) =
get_logs_from_store(text.log_offset.unwrap_or(0), &logs, &text.log_file_index).await
{
return r.map(content_plain);
}
if let Some(r) =
get_logs_from_disk(text.log_offset.unwrap_or(0), &logs, &text.log_file_index).await
{
return r.map(content_plain);
}
let logs = format!(
"to remove ansi colors, use: | sed 's/\\x1B\\[[0-9;]\\{{1,\\}}[A-Za-z]//g'\n{}",
logs
);
Ok(content_plain(Body::from(logs)))
}
}
async fn resolve_logs_to_string(
log_offset: i32,
logs: &str,
log_file_index: &Option<Vec<String>>,
) -> String {
use futures::StreamExt;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(stream) =
windmill_object_store::get_logs_from_store(log_offset, logs, log_file_index).await
{
let mut result = String::new();
futures::pin_mut!(stream);
while let Some(Ok(bytes)) = stream.next().await {
result.push_str(&String::from_utf8_lossy(&bytes));
}
return result;
}
if let Some(stream) =
windmill_common::jobs::get_logs_from_disk(log_offset, logs, log_file_index).await
{
let mut result = String::new();
futures::pin_mut!(stream);
while let Some(Ok(bytes)) = stream.next().await {
result.push_str(&String::from_utf8_lossy(&bytes));
}
return result;
}
logs.to_string()
}
struct FlowTreeReadAuth {
/// Enclosing flow job id when the requested job is itself a step of a
/// larger flow run (flow_innermost_root_job is NULL on subflow jobs —
/// parent_job covers them).
enclosing_job: Option<Uuid>,
/// Scope tags of the caller's token, to re-apply on every descendant query
/// (child jobs of a `preserve_step_tags` flow can run on other tags).
scope_tags: Option<Vec<String>>,
}
/// Shared preamble of the flow-tree endpoints (`get_flow_all_logs*`,
/// `get_flow_all_results`): verifies the job exists (scope-tag filtered),
/// checks read access, and records the view.
async fn authorize_flow_tree_read(
view_token: Option<String>,
opt_authed: &Option<ApiAuthed>,
opt_tokened: &OptTokened,
db: &DB,
user_db: &UserDB,
w_id: &str,
id: Uuid,
) -> error::Result<FlowTreeReadAuth> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
.flatten();
let root_job = sqlx::query!(
"SELECT created_by, COALESCE(flow_innermost_root_job, parent_job) as enclosing_job FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(db)
.await?;
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
require_opt_authed_job_read_access(
db,
user_db,
&opt_authed,
w_id,
&id,
&root_job.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
w_id,
&id,
)
.await?;
Ok(FlowTreeReadAuth {
enclosing_job: root_job.enclosing_job.filter(|r| *r != id),
scope_tags: tags,
})
}
/// Human-readable label for a job's position in a flow's execution tree,
/// e.g. "Flow", "Step a (iteration 2/3)", "Step b (subflow)".
fn flow_tree_entry_label(
kind: &str,
path: &str,
parent_module_type: &str,
sibling_index: i32,
sibling_count: i32,
depth: i32,
) -> String {
let kind_label = match parent_module_type {
"branchall" => " branchall",
"branchone" => " branchone",
"forloopflow" => " forloop",
"whileloopflow" => " whileloop",
"aiagent" => " ai-agent",
"aidecision" => " ai-decision",
_ => "",
};
// Only a known non-fan-out parent module marks siblings as retry attempts;
// an unresolved type ("" — e.g. the flow was edited since the run) keeps
// the generic iteration wording, matching resolve_flow_step_job and the
// frontend's FAN_OUT_MODULE_TYPES check.
let attempt_like = !parent_module_type.is_empty() && !is_fan_out_module(parent_module_type);
if depth == 0 {
"Flow".to_string()
} else if matches!(
kind,
"flow" | "flowpreview" | "flownode" | "singlestepflow" | "aiagent"
) {
// Intermediate flow job (loop iteration or branch)
if parent_module_type == "branchone" {
// sibling_index is a row number among sibling jobs, not the chosen
// branch (branchone only enqueues the branch it selected) — don't
// pretend to know which branch ran.
format!("Step {}{} (selected branch)", path, kind_label)
} else if parent_module_type == "branchall" {
format!("Step {}{} (branch {})", path, kind_label, sibling_index)
} else if parent_module_type == "forloopflow" || parent_module_type == "whileloopflow" {
format!(
"Step {}{} (iteration {}/{})",
path, kind_label, sibling_index, sibling_count
)
} else if sibling_count > 1 {
if attempt_like {
format!(
"Step {} (attempt {}/{})",
path, sibling_index, sibling_count
)
} else {
format!(
"Step {}{} (iteration {}/{})",
path, kind_label, sibling_index, sibling_count
)
}
} else {
format!("Step {} (subflow)", path)
}
} else if sibling_count > 1 {
if attempt_like {
format!(
"Step {} (attempt {}/{})",
path, sibling_index, sibling_count
)
} else {
// Simple module optimization: forloop/whileloop with single step
// runs iterations as direct script jobs instead of subflows
format!(
"Step {}{} (iteration {}/{})",
path, kind_label, sibling_index, sibling_count
)
}
} else {
format!("Step {}", path)
}
}
/// A single job in a flow's execution tree, with its resolved logs and a
/// human-readable label describing its position (iteration, branch, subflow…).
#[derive(Serialize)]
struct FlowLogEntry {
job_id: String,
/// Human-readable label, e.g. "Step a (iteration 2/3)" or "Flow".
label: String,
/// Job kind (script, flow, forloopflow, …).
kind: String,
/// The flow step id this job corresponds to, if any.
flow_step_id: Option<String>,
/// Materialized step path (e.g. "a/b") used to locate the step in the flow.
step_path: Option<String>,
/// Depth in the flow tree (0 for the root flow job).
depth: i32,
/// The parent module type (forloopflow, branchall, …), if any.
parent_module_type: Option<String>,
/// 1-based index of this job among its siblings sharing the same step.
sibling_index: i32,
/// Total number of siblings sharing the same step.
sibling_count: i32,
/// Resolved logs for this job (pulled from disk/object store as needed).
logs: String,
}
async fn collect_flow_log_entries(
view_token: Option<String>,
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: &DB,
user_db: &UserDB,
w_id: &str,
id: Uuid,
) -> error::Result<Vec<FlowLogEntry>> {
let auth =
authorize_flow_tree_read(view_token, &opt_authed, &opt_tokened, db, user_db, w_id, id)
.await?;
// Fetch all jobs in the flow tree using recursive CTE.
// Uses a materialized id_path for depth-first ordering so children
// appear right after their parent (e.g. iteration 1 → its steps → iteration 2 → ...).
// Extracts parent_module_type (branchall, forloopflow, etc.) from parent's flow definition.
let records = sqlx::query!(
"WITH RECURSIVE job_tree AS (
SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,
'' as path_label, 0 as depth,
j.id::text as id_path,
''::text as parent_module_type
FROM v2_job j
WHERE j.id = $2 AND j.workspace_id = $1
UNION ALL
SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,
CASE
WHEN jt.path_label = '' THEN COALESCE(j.flow_step_id, '')
ELSE jt.path_label || '/' || COALESCE(j.flow_step_id, '')
END,
jt.depth + 1,
jt.id_path || '/' || j.id::text,
COALESCE((
SELECT m->'value'->>'type'
FROM v2_job parent_j
LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id
AND parent_j.kind::text = 'flow'
LEFT JOIN flow f ON f.path = parent_j.runnable_path
AND f.workspace_id = parent_j.workspace_id
LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'
) m
WHERE parent_j.id = jt.id
AND m->>'id' = j.flow_step_id
LIMIT 1
), '')::text
FROM v2_job j
JOIN job_tree jt ON j.parent_job = jt.id
WHERE j.workspace_id = $1
AND ($3::text[] IS NULL OR j.tag = ANY($3))
),
positions AS (
SELECT g.parent_job, g.flow_step_id, fj.jid, fj.ord
FROM (SELECT DISTINCT parent_job, flow_step_id FROM job_tree
WHERE parent_job IS NOT NULL AND flow_step_id IS NOT NULL) g
CROSS JOIN LATERAL (
SELECT COALESCE(
(SELECT flow_status FROM v2_job_completed WHERE id = g.parent_job),
(SELECT flow_status FROM v2_job_status WHERE id = g.parent_job)
) AS fs
) pf
CROSS JOIN LATERAL (
SELECT m FROM jsonb_array_elements(pf.fs->'modules') m
WHERE m->>'id' = g.flow_step_id
LIMIT 1
) md
CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')
WITH ORDINALITY fj(jid, ord)
),
with_sibling_index AS (
SELECT jt.*,
ROW_NUMBER() OVER (
PARTITION BY jt.parent_job, jt.flow_step_id
ORDER BY pos.ord NULLS LAST, jt.id
) as sibling_index,
COUNT(*) OVER (
PARTITION BY jt.parent_job, jt.flow_step_id
) as sibling_count
FROM job_tree jt
LEFT JOIN positions pos ON pos.parent_job = jt.parent_job
AND pos.flow_step_id = jt.flow_step_id
AND pos.jid = jt.id::text
)
SELECT w.id, w.kind, w.flow_step_id, w.path_label,
w.sibling_index::int as sibling_index,
w.sibling_count::int as sibling_count,
w.depth::int as depth,
w.parent_module_type,
coalesce(job_logs.logs, '') as logs,
COALESCE(job_logs.log_offset, 0) as log_offset,
job_logs.log_file_index
FROM with_sibling_index w
LEFT JOIN job_logs ON job_logs.job_id = w.id
ORDER BY w.id_path ASC",
w_id,
id,
auth.scope_tags.as_deref(),
)
.fetch_all(db)
.await?;
let mut entries = Vec::with_capacity(records.len());
for record in &records {
let kind = record.kind.as_deref().unwrap_or("");
let step_id = record.flow_step_id.as_deref().unwrap_or("");
let depth = record.depth.unwrap_or(0);
let sibling_count = record.sibling_count.unwrap_or(1);
let sibling_index = record.sibling_index.unwrap_or(0);
let parent_module_type = record.parent_module_type.as_deref().unwrap_or("");
let path = record.path_label.as_deref().unwrap_or(step_id);
let label = flow_tree_entry_label(
kind,
path,
parent_module_type,
sibling_index,
sibling_count,
depth,
);
let job_id = record.id.map(|u| u.to_string()).unwrap_or_default();
let logs = record.logs.as_deref().unwrap_or("");
let resolved =
resolve_logs_to_string(record.log_offset.unwrap_or(0), logs, &record.log_file_index)
.await;
entries.push(FlowLogEntry {
job_id,
label,
kind: kind.to_string(),
flow_step_id: record.flow_step_id.clone(),
step_path: record.path_label.clone(),
depth,
parent_module_type: if parent_module_type.is_empty() {
None
} else {
Some(parent_module_type.to_string())
},
sibling_index,
sibling_count,
logs: resolved,
});
}
Ok(entries)
}
async fn get_flow_all_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let entries = collect_flow_log_entries(
view_token,
opt_authed,
opt_tokened,
&db,
&user_db,
&w_id,
id,
)
.await?;
let mut all_logs = String::new();
for entry in &entries {
all_logs.push_str(&format!(
"\n=== {} (Job: {}) ===\n",
entry.label, entry.job_id
));
all_logs.push_str(&entry.logs);
all_logs.push('\n');
}
Ok(content_plain(Body::from(all_logs)))
}
/// Structured alternative to `get_flow_all_logs`: returns the same flow log
/// tree as a JSON array of entries (one per job) instead of a flat text blob,
/// so callers can render or process logs per-step without parsing delimiters.
async fn get_flow_all_logs_structured(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Vec<FlowLogEntry>> {
let entries = collect_flow_log_entries(
view_token,
opt_authed,
opt_tokened,
&db,
&user_db,
&w_id,
id,
)
.await?;
Ok(Json(entries))
}
/// A single job in a flow's execution tree with its status and (truncated)
/// result — the results twin of `FlowLogEntry`.
#[derive(Serialize)]
struct FlowResultEntry {
job_id: String,
/// Human-readable label, e.g. "Step a (iteration 2/3)" or "Flow".
label: String,
/// Job kind (script, flow, forloopflow, …).
kind: String,
/// The flow step id this job corresponds to, if any.
flow_step_id: Option<String>,
/// Materialized step path (e.g. "a/b") used to locate the step in the flow.
step_path: Option<String>,
/// Depth in the flow tree (0 for the root flow job).
depth: i32,
/// The parent module type (forloopflow, branchall, …), if any.
parent_module_type: Option<String>,
/// 1-based index of this job among its siblings sharing the same step.
sibling_index: i32,
/// Total number of siblings sharing the same step.
sibling_count: i32,
/// success | failure | canceled | skipped | suspended | running | queued
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
started_at: Option<chrono::DateTime<Utc>>,
/// Result JSON text truncated to the per-entry budget; absent until the job
/// has completed.
#[serde(skip_serializing_if = "Option::is_none")]
result_prefix: Option<String>,
/// Full length in characters of the result JSON text (greater than the
/// prefix length when truncated).
#[serde(skip_serializing_if = "Option::is_none")]
result_length: Option<i32>,
}
#[derive(Serialize)]
struct FlowAllResultsResponse {
/// Set when the requested job is itself a step of a larger flow run: the id
/// of the flow run directly enclosing it.
#[serde(skip_serializing_if = "Option::is_none")]
enclosing_job: Option<Uuid>,
entries: Vec<FlowResultEntry>,
/// True when the tree has more jobs than the entry cap; entries then hold
/// the depth-first prefix.
#[serde(skip_serializing_if = "std::ops::Not::not")]
truncated: bool,
/// True when the caller's token is tag-scoped: steps running on other tags
/// are omitted and indistinguishable from steps that never ran.
#[serde(skip_serializing_if = "std::ops::Not::not")]
scope_filtered: bool,
/// Set when `step` was provided but could not be resolved; a diagnostic the
/// caller can act on (available step ids, iteration statuses).
#[serde(skip_serializing_if = "Option::is_none")]
step_error: Option<String>,
}
#[derive(Deserialize)]
struct FlowAllResultsQuery {
/// Per-entry cap (in characters of JSON text) on `result_prefix`.
max_result_len: Option<i32>,
/// Step address to resolve instead of enumerating the tree: 'b', 'b/c',
/// 'b[12]' (1-based iteration/branch), composable as 'b[12]/c'.
step: Option<String>,
}
const FLOW_ALL_RESULTS_DEFAULT_MAX_LEN: i32 = 2000;
const FLOW_ALL_RESULTS_MAX_MAX_LEN: i32 = 30_000;
/// Hard cap on enumerated tree entries — bounds both the response size and the
/// per-row result::text materialization the query performs.
const FLOW_ALL_RESULTS_MAX_ENTRIES: i64 = 2000;
struct ResolvedStepJob {
job_id: Uuid,
flow_step_id: String,
path: String,
sibling_index: i32,
sibling_count: i32,
depth: i32,
parent_module_type: String,
}
/// Fan-out module types whose sibling jobs are iterations/branches; sibling
/// jobs of any other step are retry attempts.
fn is_fan_out_module(parent_module_type: &str) -> bool {
matches!(
parent_module_type,
"forloopflow" | "whileloopflow" | "branchall" | "branchone" | "aiagent"
)
}
/// 'b[12]' -> ("b", Some(12)); 'b' -> ("b", None).
fn parse_step_segment(segment: &str) -> (&str, Option<usize>) {
if let Some(rest) = segment.strip_suffix(']') {
if let Some((step_id, idx)) = rest.rsplit_once('[') {
if let Ok(idx) = idx.parse::<usize>() {
return (step_id, Some(idx));
}
}
}
(segment, None)
}
#[cfg(test)]
mod flow_tree_tests {
use super::{flow_tree_entry_label, parse_step_segment};
#[test]
fn label_classifies_iterations_branches_and_retry_attempts() {
assert_eq!(flow_tree_entry_label("flow", "", "", 1, 1, 0), "Flow");
assert_eq!(
flow_tree_entry_label("script", "l", "forloopflow", 2, 3, 1),
"Step l forloop (iteration 2/3)"
);
assert_eq!(
flow_tree_entry_label("flow", "b", "branchall", 2, 2, 1),
"Step b branchall (branch 2)"
);
// branchone only enqueues its chosen branch — the label must not claim
// which branch that was
assert_eq!(
flow_tree_entry_label("flow", "b", "branchone", 1, 1, 1),
"Step b branchone (selected branch)"
);
// siblings of a non-fan-out step are retry attempts, for both direct
// and subflow steps
assert_eq!(
flow_tree_entry_label("script", "a", "rawscript", 2, 3, 1),
"Step a (attempt 2/3)"
);
assert_eq!(
flow_tree_entry_label("flow", "b", "flow", 2, 2, 1),
"Step b (attempt 2/2)"
);
// unresolved parent module type ("", e.g. the flow was edited since the
// run) must NOT claim retries — keep the generic iteration wording
assert_eq!(
flow_tree_entry_label("script", "a", "", 2, 3, 1),
"Step a (iteration 2/3)"
);
assert_eq!(
flow_tree_entry_label("flow", "b", "flow", 1, 1, 1),
"Step b (subflow)"
);
assert_eq!(
flow_tree_entry_label("script", "a", "rawscript", 1, 1, 1),
"Step a"
);
}
#[test]
fn parses_step_segments() {
assert_eq!(parse_step_segment("b"), ("b", None));
assert_eq!(parse_step_segment("b[12]"), ("b", Some(12)));
assert_eq!(parse_step_segment("b[0]"), ("b", Some(0)));
assert_eq!(parse_step_segment("b[x]"), ("b[x]", None));
assert_eq!(parse_step_segment("b]"), ("b]", None));
}
}
/// Resolve a step address ('b/c', 'b[12]/c', '.' separators accepted) to a
/// single job of the flow tree rooted at `root`, walking one indexed
/// parent_job + flow_step_id lookup per segment — no tree enumeration.
/// Siblings are numbered by their position in the parent flow_status's
/// flow_jobs array — the authoritative iteration order — matching the tree
/// view's numbering; job-id order alone would shuffle parallel iterations
/// created in the same millisecond (ULID randomness). Retries have no
/// flow_jobs and fall back to id order. Err carries a diagnostic listing what
/// exists instead.
async fn resolve_flow_step_job(
db: &DB,
w_id: &str,
root: Uuid,
address: &str,
scope_tags: Option<&[String]>,
) -> error::Result<Result<ResolvedStepJob, String>> {
let segments = address
.replace('.', "/")
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect_vec();
if segments.is_empty() {
return Ok(Err("Empty step address.".to_string()));
}
let mut current = root;
let mut resolved: Option<ResolvedStepJob> = None;
let mut path_segments: Vec<&str> = vec![];
for (depth, segment) in segments.iter().enumerate() {
let (step_id, index) = parse_step_segment(segment);
path_segments.push(step_id);
// parent_module_type is resolved the same way as in the tree CTE (the
// module named `step_id` in the parent's flow definition); it is
// constant across the sibling rows.
let siblings = sqlx::query!(
"SELECT j.id,
COALESCE(c.status::text,
CASE WHEN q.running AND q.suspend > 0 THEN 'suspended'
WHEN q.running THEN 'running'
ELSE 'queued' END) as \"status!\",
COALESCE((
SELECT m->'value'->>'type'
FROM v2_job parent_j
LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id
AND parent_j.kind::text = 'flow'
LEFT JOIN flow f ON f.path = parent_j.runnable_path
AND f.workspace_id = parent_j.workspace_id
LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'
) m
WHERE parent_j.id = $1
AND m->>'id' = $3
LIMIT 1
), '')::text as \"parent_module_type!\"
FROM v2_job j
LEFT JOIN v2_job_completed c ON c.id = j.id
LEFT JOIN v2_job_queue q ON q.id = j.id
LEFT JOIN (
SELECT fj.jid, fj.ord
FROM (SELECT COALESCE(
(SELECT flow_status FROM v2_job_completed WHERE id = $1),
(SELECT flow_status FROM v2_job_status WHERE id = $1)
) AS fs) pf
CROSS JOIN LATERAL (
SELECT m FROM jsonb_array_elements(pf.fs->'modules') m
WHERE m->>'id' = $3
LIMIT 1
) md
CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')
WITH ORDINALITY fj(jid, ord)
) pos ON pos.jid = j.id::text
WHERE j.parent_job = $1 AND j.workspace_id = $2 AND j.flow_step_id = $3
AND ($4::text[] IS NULL OR j.tag = ANY($4))
ORDER BY pos.ord NULLS LAST, j.id",
current,
w_id,
step_id,
scope_tags,
)
.fetch_all(db)
.await?;
if siblings.is_empty() {
let available = sqlx::query_scalar!(
"SELECT DISTINCT flow_step_id as \"flow_step_id!\" FROM v2_job
WHERE parent_job = $1 AND workspace_id = $2 AND flow_step_id IS NOT NULL
AND ($3::text[] IS NULL OR tag = ANY($3))
ORDER BY flow_step_id",
current,
w_id,
scope_tags,
)
.fetch_all(db)
.await?;
return Ok(Err(format!(
"No step \"{}\" at this level. Available steps: {}.",
step_id,
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
)));
}
let parent_module_type = siblings[0].parent_module_type.clone();
let node = if let Some(index) = index {
match index.checked_sub(1).and_then(|i| siblings.get(i)) {
Some(node) => node,
None => {
return Ok(Err(format!(
"Step \"{}\" has no iteration [{}]. Existing: 1..{}.",
step_id,
index,
siblings.len()
)));
}
}
} else if siblings.len() > 1 {
// These diagnostics go verbatim into the model's context — cap the
// per-sibling listing so huge loops can't flood it.
const MAX_STATUSES_LISTED: usize = 20;
let mut statuses = siblings
.iter()
.take(MAX_STATUSES_LISTED)
.enumerate()
.map(|(i, s)| format!("[{}] {}", i + 1, s.status))
.join(", ");
if siblings.len() > MAX_STATUSES_LISTED {
statuses.push_str(&format!(
", … (+{} more)",
siblings.len() - MAX_STATUSES_LISTED
));
}
return Ok(Err(
if is_fan_out_module(&parent_module_type) || parent_module_type.is_empty() {
format!(
"Step \"{}\" ran {} times (loop/branches) — pick one with \"{}[i]\". Iterations: {}.",
step_id,
siblings.len(),
step_id,
statuses
)
} else {
format!(
"Step \"{}\" was retried — {} attempts, the last one is the final outcome. Pick one with \"{}[i]\". Attempts: {}.",
step_id,
siblings.len(),
step_id,
statuses
)
},
));
} else {
&siblings[0]
};
let index_in_siblings = siblings.iter().position(|s| s.id == node.id).unwrap_or(0);
current = node.id;
resolved = Some(ResolvedStepJob {
job_id: node.id,
flow_step_id: step_id.to_string(),
path: path_segments.join("/"),
sibling_index: (index_in_siblings + 1) as i32,
sibling_count: siblings.len() as i32,
depth: (depth + 1) as i32,
parent_module_type,
});
}
Ok(Ok(resolved.expect("segments is non-empty")))
}
/// Results twin of `get_flow_all_logs_structured`: one entry per job of the
/// flow's execution tree (same recursive enumeration), carrying per-job status
/// and truncated result instead of logs.
async fn get_flow_all_results(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(query): Query<FlowAllResultsQuery>,
) -> JsonResult<FlowAllResultsResponse> {
let auth = authorize_flow_tree_read(
view_token,
&opt_authed,
&opt_tokened,
&db,
&user_db,
&w_id,
id,
)
.await?;
let enclosing_job = auth.enclosing_job;
let scope_tags = auth.scope_tags;
let max_result_len = query
.max_result_len
.unwrap_or(FLOW_ALL_RESULTS_DEFAULT_MAX_LEN)
.clamp(1, FLOW_ALL_RESULTS_MAX_MAX_LEN);
// Step mode: resolve the address to one job directly (a few indexed
// lookups) instead of enumerating the whole tree.
if let Some(step) = query.step.as_deref().filter(|s| !s.trim().is_empty()) {
let resolved =
match resolve_flow_step_job(&db, &w_id, id, step, scope_tags.as_deref()).await? {
Ok(resolved) => resolved,
Err(step_error) => {
return Ok(Json(FlowAllResultsResponse {
enclosing_job,
entries: vec![],
truncated: false,
scope_filtered: scope_tags.is_some(),
step_error: Some(step_error),
}));
}
};
let record = sqlx::query!(
"SELECT j.kind::text as kind,
c.status::text as completed_status,
c.duration_ms as \"duration_ms?\",
COALESCE(c.started_at, q.started_at) as started_at,
LEFT(c.result::text, $3) as result_prefix,
length(c.result::text) as result_length,
q.running as \"q_running?\",
q.suspend as \"q_suspend?\"
FROM v2_job j
LEFT JOIN v2_job_completed c ON c.id = j.id
LEFT JOIN v2_job_queue q ON q.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
resolved.job_id,
w_id,
max_result_len,
)
.fetch_one(&db)
.await?;
let kind = record.kind.as_deref().unwrap_or("");
let status = match record.completed_status.as_deref() {
Some(s) => s.to_string(),
None => {
if record.q_running.unwrap_or(false) && record.q_suspend.unwrap_or(0) > 0 {
"suspended".to_string()
} else if record.q_running.unwrap_or(false) {
"running".to_string()
} else {
"queued".to_string()
}
}
};
let success = record.completed_status.as_deref().map(|s| s == "success");
let label = flow_tree_entry_label(
kind,
&resolved.path,
&resolved.parent_module_type,
resolved.sibling_index,
resolved.sibling_count,
resolved.depth,
);
return Ok(Json(FlowAllResultsResponse {
enclosing_job,
entries: vec![FlowResultEntry {
job_id: resolved.job_id.to_string(),
label,
kind: kind.to_string(),
flow_step_id: Some(resolved.flow_step_id),
step_path: Some(resolved.path),
depth: resolved.depth,
parent_module_type: if resolved.parent_module_type.is_empty() {
None
} else {
Some(resolved.parent_module_type)
},
sibling_index: resolved.sibling_index,
sibling_count: resolved.sibling_count,
status,
success,
duration_ms: record.duration_ms,
started_at: record.started_at,
result_prefix: record.result_prefix,
result_length: record.result_length,
}],
truncated: false,
scope_filtered: scope_tags.is_some(),
step_error: None,
}));
}
// Same recursive CTE as `collect_flow_log_entries` (kept textually in sync —
// sqlx macros cannot share the SQL string), joined against the completed and
// queue tables instead of `job_logs`.
let records = sqlx::query!(
"WITH RECURSIVE job_tree AS (
SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,
'' as path_label, 0 as depth,
j.id::text as id_path,
''::text as parent_module_type
FROM v2_job j
WHERE j.id = $2 AND j.workspace_id = $1
UNION ALL
SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,
CASE
WHEN jt.path_label = '' THEN COALESCE(j.flow_step_id, '')
ELSE jt.path_label || '/' || COALESCE(j.flow_step_id, '')
END,
jt.depth + 1,
jt.id_path || '/' || j.id::text,
COALESCE((
SELECT m->'value'->>'type'
FROM v2_job parent_j
LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id
AND parent_j.kind::text = 'flow'
LEFT JOIN flow f ON f.path = parent_j.runnable_path
AND f.workspace_id = parent_j.workspace_id
LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'
) m
WHERE parent_j.id = jt.id
AND m->>'id' = j.flow_step_id
LIMIT 1
), '')::text
FROM v2_job j
JOIN job_tree jt ON j.parent_job = jt.id
WHERE j.workspace_id = $1
AND ($4::text[] IS NULL OR j.tag = ANY($4))
),
positions AS (
SELECT g.parent_job, g.flow_step_id, fj.jid, fj.ord
FROM (SELECT DISTINCT parent_job, flow_step_id FROM job_tree
WHERE parent_job IS NOT NULL AND flow_step_id IS NOT NULL) g
CROSS JOIN LATERAL (
SELECT COALESCE(
(SELECT flow_status FROM v2_job_completed WHERE id = g.parent_job),
(SELECT flow_status FROM v2_job_status WHERE id = g.parent_job)
) AS fs
) pf
CROSS JOIN LATERAL (
SELECT m FROM jsonb_array_elements(pf.fs->'modules') m
WHERE m->>'id' = g.flow_step_id
LIMIT 1
) md
CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')
WITH ORDINALITY fj(jid, ord)
),
with_sibling_index AS (
SELECT jt.*,
ROW_NUMBER() OVER (
PARTITION BY jt.parent_job, jt.flow_step_id
ORDER BY pos.ord NULLS LAST, jt.id
) as sibling_index,
COUNT(*) OVER (
PARTITION BY jt.parent_job, jt.flow_step_id
) as sibling_count
FROM job_tree jt
LEFT JOIN positions pos ON pos.parent_job = jt.parent_job
AND pos.flow_step_id = jt.flow_step_id
AND pos.jid = jt.id::text
),
limited AS (
SELECT * FROM with_sibling_index ORDER BY id_path ASC LIMIT $5
)
SELECT w.id, w.kind, w.flow_step_id, w.path_label,
w.sibling_index::int as sibling_index,
w.sibling_count::int as sibling_count,
w.depth::int as depth,
w.parent_module_type,
c.status::text as completed_status,
c.duration_ms as \"duration_ms?\",
COALESCE(c.started_at, q.started_at) as started_at,
LEFT(c.result::text, $3) as result_prefix,
length(c.result::text) as result_length,
q.running as \"q_running?\",
q.suspend as \"q_suspend?\"
FROM limited w
LEFT JOIN v2_job_completed c ON c.id = w.id
LEFT JOIN v2_job_queue q ON q.id = w.id
ORDER BY w.id_path ASC",
w_id,
id,
max_result_len,
scope_tags.as_deref(),
FLOW_ALL_RESULTS_MAX_ENTRIES + 1,
)
.fetch_all(&db)
.await?;
// One row past the cap fetched only to detect truncation.
let mut records = records;
let truncated = records.len() as i64 > FLOW_ALL_RESULTS_MAX_ENTRIES;
if truncated {
records.truncate(FLOW_ALL_RESULTS_MAX_ENTRIES as usize);
}
let mut entries = Vec::with_capacity(records.len());
for record in records {
let kind = record.kind.as_deref().unwrap_or("");
let step_id = record.flow_step_id.as_deref().unwrap_or("");
let depth = record.depth.unwrap_or(0);
let sibling_count = record.sibling_count.unwrap_or(1) as i32;
let sibling_index = record.sibling_index.unwrap_or(0) as i32;
let parent_module_type = record.parent_module_type.as_deref().unwrap_or("");
let path = record.path_label.as_deref().unwrap_or(step_id);
let label = flow_tree_entry_label(
kind,
path,
parent_module_type,
sibling_index,
sibling_count,
depth,
);
let status = match record.completed_status.as_deref() {
Some(s) => s.to_string(),
None => {
if record.q_running.unwrap_or(false) && record.q_suspend.unwrap_or(0) > 0 {
"suspended".to_string()
} else if record.q_running.unwrap_or(false) {
"running".to_string()
} else {
"queued".to_string()
}
}
};
let success = record.completed_status.as_deref().map(|s| s == "success");
entries.push(FlowResultEntry {
job_id: record.id.map(|u| u.to_string()).unwrap_or_default(),
label,
kind: kind.to_string(),
flow_step_id: record.flow_step_id.clone(),
step_path: record.path_label.clone(),
depth,
parent_module_type: if parent_module_type.is_empty() {
None
} else {
Some(parent_module_type.to_string())
},
sibling_index,
sibling_count,
status,
success,
duration_ms: record.duration_ms,
started_at: record.started_at,
result_prefix: record.result_prefix,
result_length: record.result_length,
});
}
Ok(Json(FlowAllResultsResponse {
enclosing_job,
entries,
truncated,
scope_filtered: scope_tags.is_some(),
step_error: None,
}))
}
async fn get_args(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Box<RawValue>> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
let record = sqlx::query!(
"SELECT j.created_by AS \"created_by!\", j.args as \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_job j
WHERE j.id = $1 AND j.workspace_id = $2 AND ($3::text[] IS NULL OR j.tag = ANY($3))",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?;
if let Some(record) = record {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(record.args.map(|x| x.0).unwrap_or_default()))
} else {
let record = sqlx::query!(
"SELECT created_by AS \"created_by!\", args as \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_job
WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?;
let record = not_found_if_none(record, "Job Args", id.to_string())?;
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(record.args.map(|x| x.0).unwrap_or_default()))
}
}
async fn get_started_at_by_ids(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(mut ids): Json<Vec<Uuid>>,
) -> JsonResult<Vec<Option<chrono::DateTime<chrono::Utc>>>> {
ids.truncate(100);
let started_at = sqlx::query!(
"SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2",
ids.as_slice(),
&w_id
)
.fetch_all(&db)
.await?;
let as_map = started_at
.iter()
.map(|x| (x.id, x.started_at))
.collect::<HashMap<_, _>>();
let mut r = Vec::new();
for id in ids {
r.push(as_map.get(&id).map(|x| x.clone()).unwrap_or_default());
}
Ok(Json(r))
}
#[derive(Serialize, FromRow)]
struct ListableQueuedJob {
pub id: Uuid,
pub running: bool,
pub canceled: bool,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_for: chrono::DateTime<chrono::Utc>,
pub script_hash: Option<ScriptHash>,
pub script_path: Option<String>,
pub args: Option<serde_json::Value>,
pub job_kind: JobKind,
pub schedule_path: Option<String>,
pub is_flow_step: bool,
pub language: Option<ScriptLang>,
pub email: String,
pub suspend: Option<i32>,
pub tag: String,
pub priority: Option<i16>,
pub workspace_id: String,
}
/// Narrows a job list to what a path-scoped `jobs:read:<paths>` token may read: the
/// top-level runs of the runnables it names. A step is read by id, under its flow. No-op
/// for every caller whose lists are not path-filtered (see `job_list_path_filter`).
fn confine_list_to_read_scope(sqlb: &mut SqlBuilder, authed: &ApiAuthed) {
if let windmill_api_auth::ScopePathFilter::Restricted { exact, prefix } =
windmill_api_auth::scopes::job_list_path_filter(authed.scopes.as_deref())
{
and_where_root_run_at_paths(sqlb, &exact, &prefix);
}
}
async fn list_queue_jobs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListQueueQuery>,
) -> error::JsonResult<Vec<ListableQueuedJob>> {
let include_args = lq.include_args.unwrap_or(false);
if include_args && *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
"include_args is not supported on cloud hosted Windmill".to_string(),
));
}
let args_field = if include_args {
"v2_job.args"
} else {
"null as args"
};
let mut sqlb = list_queue_jobs_query(
&w_id,
&lq,
&[
"v2_job.id",
"v2_job_queue.running",
// A canceled row stays in the queue until a worker picks it up and completes it, and
// the `QueuedJob` schema this answers with declares the field either way.
"v2_job_queue.canceled_by IS NOT NULL as canceled",
"v2_job.created_by",
"v2_job.created_at",
"v2_job_queue.started_at",
"v2_job_queue.scheduled_for",
"v2_job.runnable_id as script_hash",
"v2_job.runnable_path as script_path",
args_field,
"v2_job.kind as job_kind",
"CASE WHEN v2_job.trigger_kind = 'schedule' THEN v2_job.trigger END as schedule_path",
"v2_job.permissioned_as",
"v2_job.flow_step_id IS NOT NULL as is_flow_step",
"v2_job.script_lang as language",
"v2_job.permissioned_as_email as email",
"v2_job_queue.suspend",
"v2_job.tag",
"v2_job.priority",
"v2_job.workspace_id",
],
pagination,
false,
get_scope_tags(&authed),
);
confine_list_to_read_scope(&mut sqlb, &authed);
let sql = sqlb.sql()?;
let mut tx = user_db.begin(&authed).await?;
let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(jobs))
}
#[derive(Deserialize)]
pub struct CancelSelectionQuery {
force_cancel: Option<bool>,
all_workspaces: Option<bool>,
}
async fn cancel_selection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(query): Query<CancelSelectionQuery>,
Json(jobs): Json<Vec<Uuid>>,
) -> error::JsonResult<Vec<Uuid>> {
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec());
// Only honor all_workspaces when the path workspace is "admins", matching
// the convention used by count_queue_jobs / count_completed_jobs_detail.
// Outside the admins workspace, always scope to the path w_id so a client
// cannot drop workspace scoping by passing all_workspaces=true.
let all_workspaces = w_id == "admins" && query.all_workspaces.unwrap_or(false);
let path_w_id = if all_workspaces {
None
} else {
Some(w_id.as_str())
};
let rows = sqlx::query!(
"SELECT j.id AS \"id!\", j.workspace_id AS \"workspace_id!\" FROM v2_job j LEFT JOIN v2_job_queue q USING (id) WHERE j.id = ANY($1) AND j.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind AND ($2::text[] IS NULL OR j.tag = ANY($2)) AND ($3::text IS NULL OR j.workspace_id = $3)",
&jobs,
tags.as_ref().map(|v| v.as_slice()),
path_w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
// cancel_jobs enforces workspace_id per row, so dispatch one call per
// workspace so cross-workspace selections (all_workspaces=true) aren't
// silently dropped. In single-workspace mode the SQL filter above ensures
// this collapses to a single call.
let mut jobs_by_workspace: HashMap<String, Vec<Uuid>> = HashMap::new();
for row in rows {
jobs_by_workspace
.entry(row.workspace_id)
.or_default()
.push(row.id);
}
let force_cancel = query.force_cancel.unwrap_or(false);
let mut cancelled = Vec::new();
for (workspace_id, ids) in jobs_by_workspace {
let ids =
if windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref())
.is_some()
{
// Checked on the job the cancel actually kills: a force cancel reaches the
// highest queued ancestor.
let mut targets = Vec::with_capacity(ids.len());
for id in ids {
let target = if force_cancel {
force_cancel_target(&db, &workspace_id, id).await?
} else {
id
};
targets.push((id, target));
}
let admitted: HashSet<Uuid> = filter_jobs_within_cancel_scope(
&db,
&authed,
&workspace_id,
targets.iter().map(|(_, t)| *t).collect(),
)
.await?
.into_iter()
.collect();
targets
.into_iter()
.filter(|(_, t)| admitted.contains(t))
.map(|(id, _)| id)
.collect()
} else {
ids
};
let Json(mut w_cancelled) =
cancel_jobs(ids, &db, &authed, workspace_id.as_str(), force_cancel).await?;
cancelled.append(&mut w_cancelled);
}
Ok(Json(cancelled))
}
async fn list_filtered_job_uuids(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
let mut sqlb = list_completed_jobs_query(
w_id.as_str(),
None,
0,
&lq,
&["v2_job.id"],
false,
get_scope_tags(&authed),
);
// Same reasoning as the runs-list union gate: "resolved only" is a completed-jobs
// concept, so unioning the queue would feed queued jobs into bulk actions taken
// under that filter. "hide resolved" must still keep them.
let query = if lq.status.is_some() || lq.resolved == Some(true) {
sqlb.subquery()?
} else {
let sqlb2 = list_queue_jobs_query(
w_id.as_str(),
&lq.into(),
&["v2_job.id"],
Pagination { page: None, per_page: None },
false,
get_scope_tags(&authed),
);
sqlb.union_all(sqlb2.subquery()?).subquery()?
};
let ids = sqlx::query_scalar(query.as_str()).fetch_all(&db).await?;
Ok(Json(ids))
}
async fn list_filtered_uuids(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(lq): Query<ListQueueQuery>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
let mut sqlb = SqlBuilder::select_from("v2_job_queue")
.fields(&["v2_job_queue.id"])
.clone();
sqlb = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb);
sqlb.and_where_ne("v2_job.trigger_kind", "'schedule'")
.or_where_is_null("v2_job.trigger_kind");
if let Some(tags) = get_scope_tags(&authed) {
sqlb.and_where_in(
"v2_job.tag",
&tags.iter().map(|x| quote(x)).collect::<Vec<_>>(),
);
}
sqlb = filter_list_queue_query(sqlb, &lq, w_id.as_str(), false);
let sql = sqlb.query()?;
let jobs = sqlx::query_scalar(sql.as_str()).fetch_all(&db).await?;
Ok(Json(jobs))
}
#[derive(Serialize)]
struct QueueStats {
database_length: i64,
suspended: Option<i64>,
}
#[derive(Deserialize)]
pub struct CountQueueJobsQuery {
all_workspaces: Option<bool>,
tags: Option<String>,
}
async fn count_queue_jobs(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(cq): Query<CountQueueJobsQuery>,
) -> error::JsonResult<QueueStats> {
let tags = cq
.tags
.map(|t| t.split(',').map(|s| s.to_string()).collect::<Vec<_>>());
Ok(Json(
sqlx::query_as!(
QueueStats,
"SELECT coalesce(COUNT(*) FILTER(WHERE q.suspend = 0 AND q.running = false), 0) as \"database_length!\", coalesce(COUNT(*) FILTER(WHERE q.suspend > 0), 0) as \"suspended!\" FROM v2_job_queue q JOIN v2_job j USING (id) WHERE (j.workspace_id = $1 OR $2) AND q.scheduled_for <= now() AND ($3::text[] IS NULL OR j.tag = ANY($3))",
w_id,
w_id == "admins" && cq.all_workspaces.unwrap_or(false),
tags.as_ref().map(|v| v.as_slice())
)
.fetch_one(&db)
.await?,
))
}
#[derive(Deserialize)]
pub struct CountCompletedJobsQuery {
completed_after_s_ago: Option<i64>,
success: Option<bool>,
tags: Option<String>,
all_workspaces: Option<bool>,
}
async fn count_completed_jobs_detail(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<CountCompletedJobsQuery>,
) -> error::JsonResult<i64> {
let mut sqlb = SqlBuilder::select_from("v2_job_completed");
sqlb.field("COUNT(*) as count");
// Filtering on v2_job.workspace_id instead would keep the planner off
// ix_job_workspace_id_completed_at_all and scan the whole retention window.
if !(w_id == "admins" && query.all_workspaces.unwrap_or(false)) {
sqlb.and_where_eq("v2_job_completed.workspace_id", "?".bind(&w_id));
}
if let Some(after_s_ago) = query.completed_after_s_ago {
let after = Utc::now() - chrono::Duration::seconds(after_s_ago);
sqlb.and_where_gt("completed_at", "?".bind(&after.to_rfc3339()));
}
if let Some(success) = query.success {
if success {
sqlb.and_where_eq("status", "'success'")
.or_where_eq("status", "'skipped'");
} else {
sqlb.and_where_ne("status", "'success'")
.and_where_ne("status", "'skipped'");
}
}
if let Some(tags) = query.tags {
sqlb.join("v2_job USING (id)");
sqlb.and_where_in(
"v2_job.tag",
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
);
}
let sql = sqlb.sql()?;
let mut tx = db.begin().await?;
set_list_jobs_statement_timeout(&mut tx).await?;
let stats = sqlx::query_scalar::<_, i64>(&sql)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
list_jobs_timeout_error(
e,
"Counting completed jobs",
"Lower completed_after_s_ago or narrow the filters.",
)
})?;
tx.commit().await?;
Ok(Json(stats))
}
async fn count_completed_jobs(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> error::JsonResult<QueueStats> {
Ok(Json(
sqlx::query_as!(
QueueStats,
"SELECT coalesce(COUNT(*), 0) as \"database_length!\", null::bigint as suspended FROM v2_job_completed WHERE workspace_id = $1",
w_id
)
.fetch_one(&db)
.await?,
))
}
#[derive(Deserialize)]
struct OldestJobQuery {
all_workspaces: Option<bool>,
}
#[derive(Serialize)]
struct OldestJob {
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Where the job history starts, whatever the caller may see of it: a client scanning the
/// runs backwards in time windows needs a bound that row-level visibility cannot make slow.
async fn get_oldest_job(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(q): Query<OldestJobQuery>,
) -> error::JsonResult<OldestJob> {
let created_at = if w_id == "admins" && q.all_workspaces.unwrap_or(false) {
// One index probe per workspace: a plain min() over the table has no index to walk.
sqlx::query_scalar(
"SELECT min(o.created_at) FROM workspace w CROSS JOIN LATERAL \
(SELECT min(created_at) AS created_at FROM v2_job WHERE workspace_id = w.id) o",
)
.fetch_one(&db)
.await?
} else {
sqlx::query_scalar("SELECT min(created_at) FROM v2_job WHERE workspace_id = $1")
.bind(&w_id)
.fetch_one(&db)
.await?
};
Ok(Json(OldestJob { created_at }))
}
lazy_static::lazy_static! {
/// 0 keeps the connection-wide statement_timeout.
static ref LIST_JOBS_STATEMENT_TIMEOUT_SECS: u64 =
std::env::var("LIST_JOBS_STATEMENT_TIMEOUT_SECS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(30);
}
/// A client that gives up does not cancel its query, so without this bound every retry of a
/// slow filter stacks another scan running until the connection-wide 5min timeout.
async fn set_list_jobs_statement_timeout(tx: &mut Transaction<'_, Postgres>) -> error::Result<()> {
let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS;
if timeout_secs > 0 {
sqlx::query(&format!("SET LOCAL statement_timeout = '{timeout_secs}s'"))
.execute(&mut **tx)
.await?;
}
Ok(())
}
fn list_jobs_timeout_error(e: sqlx::Error, action: &str, hint: &str) -> Error {
let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS;
match e {
sqlx::Error::Database(ref db_err)
if timeout_secs > 0 && db_err.code().as_deref() == Some("57014") =>
{
Error::Generic(
StatusCode::BAD_REQUEST,
format!("{action} took more than {timeout_secs}s and was stopped. {hint}"),
)
}
e => e.into(),
}
}
async fn list_jobs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<Job>> {
let include_args = lq.include_args.unwrap_or(false);
if include_args && *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
"include_args is not supported on cloud hosted Windmill".to_string(),
));
}
let (per_page, offset) = paginate(pagination);
let lqc = lq.clone();
if offset > 0 {
tracing::warn!("offset is not 0, but is ignored for list_jobs. Use created_before or completed_before instead.");
}
if (lq.success.is_some() || lq.status.is_some()) && lq.running.is_some_and(|x| x) {
return Err(error::Error::BadRequest(
"cannot specify success/status with running".to_string(),
));
}
// Create dynamic field arrays when include_args is true
let cj_fields: Vec<&str>;
let qj_fields: Vec<&str>;
let cj_fields_ref: &[&str];
let qj_fields_ref: &[&str];
if include_args {
cj_fields = UnifiedJob::completed_job_fields()
.iter()
.map(|f| {
if *f == "null as args" {
"v2_job.args"
} else {
*f
}
})
.collect();
qj_fields = UnifiedJob::queued_job_fields()
.iter()
.map(|f| {
if *f == "null as args" {
"v2_job.args"
} else {
*f
}
})
.collect();
cj_fields_ref = &cj_fields;
qj_fields_ref = &qj_fields;
} else {
cj_fields_ref = UnifiedJob::completed_job_fields();
qj_fields_ref = UnifiedJob::queued_job_fields();
}
let sqlc = if lq.running.is_none() {
let mut sqlc = list_completed_jobs_query(
&w_id,
Some(per_page),
0,
&ListCompletedQuery { order_desc: Some(true), ..lqc },
cj_fields_ref,
true,
get_scope_tags(&authed),
);
confine_list_to_read_scope(&mut sqlc, &authed);
Some(sqlc)
} else {
None
};
let sql = if lq.success.is_none()
&& lq.status.is_none()
&& lq.label.is_none()
&& lq.result.is_none()
&& !lq.is_skipped.unwrap_or(false)
// Only "resolved = true" forces completed-only: queued jobs would otherwise leak
// into a resolved-only view. "resolved = false" (hide resolved) must keep them,
// since a running job has no resolution to hide.
&& lq.resolved != Some(true)
&& lq.created_before.is_none()
&& lq.started_before.is_none()
&& lq.created_or_started_before.is_none()
&& lq.completed_before.is_none()
{
let mut sqlq = list_queue_jobs_query(
&w_id,
&ListQueueQuery { order_desc: Some(true), ..lq.into() },
qj_fields_ref,
Pagination { per_page: None, page: None },
true,
get_scope_tags(&authed),
);
confine_list_to_read_scope(&mut sqlq, &authed);
if let Some(sqlc) = sqlc {
format!("{} UNION ALL {}", &sqlq.subquery()?, &sqlc.subquery()?,)
} else {
sqlq.limit(per_page).offset(offset).query()?
}
} else {
if sqlc.is_none() {
return Err(error::Error::BadRequest(
"cannot specify success, status, label, created_or_started_before, or starte
d_before with running"
.to_string(),
));
}
sqlc.unwrap().limit(per_page).offset(offset).query()?
};
// tracing::info!("sql: {}", &sql);
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
set_list_jobs_statement_timeout(&mut tx).await?;
let jobs: Vec<UnifiedJob> = sqlx::query_as(&sql)
.fetch_all(&mut *tx)
.warn_after_seconds_with_sql(5, format!("list_jobs: {}", sql))
.await
.map_err(|e| {
list_jobs_timeout_error(e, "Listing jobs", "Set a start date or narrow the filters.")
})?;
tx.commit().await?;
Ok(Json(jobs.into_iter().map(From::from).collect()))
}
pub async fn resume_suspended_flow_as_owner(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, flow_id)): Path<(String, Uuid)>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
let mut tx = db.begin().await?;
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
require_owner_of_path(&authed, flow_path)?;
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
let trigger_email = flow.email.as_deref().unwrap_or("");
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
.ok()
.and_then(|fs| fs.approval_conditions)
.or_else(|| {
// WAC flows store approval_conditions directly in flow_status JSONB
flow_status_value
.get("approval_conditions")
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
});
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
}
let value = value.unwrap_or(serde_json::Value::Null);
insert_resume_job(
0,
job_id,
&flow,
value,
Some(authed.username),
true,
&mut tx,
)
.await?;
if is_wac {
decrement_suspend(flow.id, &mut tx).await?;
} else {
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
}
tx.commit().await?;
Ok(StatusCode::CREATED)
}
// --- New approval system endpoints ---
use windmill_common::variables::{
generate_approval_token, generate_view_token, PUBLIC_VIEW_TOKEN_DOMAIN, VIEW_TOKEN_DOMAIN,
};
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
db: &DB,
token: &str,
job_id: Uuid,
workspace_id: &str,
) -> error::Result<()> {
let expected = generate_approval_token(workspace_id, job_id, db).await?;
if token != expected {
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
struct ResumeSuspendedBody {
payload: Option<serde_json::Value>,
approval_token: Option<String>,
approved: Option<bool>,
}
async fn resume_suspended(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(body): Json<ResumeSuspendedBody>,
) -> error::Result<StatusCode> {
let approved = body.approved.unwrap_or(true);
let value = body.payload.unwrap_or(serde_json::Value::Null);
// Determine if we have a valid authed user or token
let has_token = if let Some(ref token) = body.approval_token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
let mut tx = db.begin().await?;
// Resolve the suspended flow (works for both WAC and classic flows)
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
// Check approval conditions
let approval_conditions = extract_approval_conditions(flow.flow_status.as_ref(), is_wac);
let trigger_email = flow.email.as_deref().unwrap_or("");
if let Some(ref ac) = approval_conditions {
if ac.user_auth_required && opt_authed.is_none() {
return Err(Error::NotAuthorized(
"This approval requires a logged-in user. Please sign in.".to_string(),
));
}
}
// If logged in, check authorization rules
if let Some(ref authed) = opt_authed {
// self_approval_disabled applies to owners too (only admins are exempt), so it is
// enforced before the owner shortcut below. A token-only (anonymous) resume is treated as
// capability-based and intentionally not gated here; see resume_suspended_job.
if let Some(ref ac) = approval_conditions {
require_not_self_approval(authed, ac, trigger_email)?;
}
let is_admin = authed.is_admin;
let is_owner = flow
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if !is_admin && !is_owner {
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)?;
}
} else if !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Generate a unique resume_id
let resume_id: u32 = rand::random();
// Check for duplicate
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest("Resume request already sent".to_string()));
}
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
insert_resume_job(
resume_id,
resume_job_id,
&flow,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
} else if is_wac {
decrement_suspend(flow.id, &mut tx).await?;
} else {
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = if let Some(ref authed) = opt_authed {
AuditAuthor::from(authed)
} else {
AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: None,
}
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct ApprovalInfoQuery {
token: Option<String>,
}
#[derive(Serialize)]
struct ApprovalInfo {
flow_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
form_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
default_args: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
enums: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_conditions: Option<ApprovalConditions>,
can_approve: bool,
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
skin: ApprovalSkin,
#[serde(skip_serializing_if = "Option::is_none")]
step_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
flow_summary: Option<String>,
approvers: Vec<Approval>,
/// Share-read-link token for the flow, minted only for callers allowed to view this
/// approval. Lets an authenticated workspace-member approver open the run details of
/// a flow they don't otherwise have read access to (the run page reads it as a
/// `view_token` query param).
#[serde(skip_serializing_if = "Option::is_none")]
view_token: Option<String>,
}
/// Whether `opt_authed` is allowed to approve — and therefore view — this approval step.
/// Mirrors the authorization performed at the resume boundary: workspace admins always qualify;
/// self_approval_disabled then bars the triggerer even when they own the runnable; otherwise
/// owners qualify and the remaining approval conditions (user_auth_required /
/// user_groups_required) decide. When the step does not require auth, an anonymous (token-only)
/// caller qualifies.
fn can_approve_step(
opt_authed: &Option<ApiAuthed>,
approval_conditions: &Option<ApprovalConditions>,
script_path: Option<&str>,
trigger_email: &str,
) -> bool {
match opt_authed {
Some(authed) => {
if authed.is_admin {
return true;
}
// self_approval_disabled applies to owners too, so it gates the owner shortcut.
if let Some(ref ac) = approval_conditions {
if require_not_self_approval(authed, ac, trigger_email).is_err() {
return false;
}
}
let is_owner = script_path
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if is_owner {
return true;
}
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)
.is_ok()
}
// Not logged in — only acceptable when the step does not require auth.
None => !approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false),
}
}
/// The latest approval step the run has passed: a step before the current `step` that ran
/// rather than being skipped. Steps from `step` on don't count, because while an approval is
/// pending the step after it already holds the `WaitingForEvents` status.
fn last_reached_approval_step<'a>(
flow: &'a FlowValue,
status: &FlowStatus,
) -> Option<&'a FlowModule> {
flow.modules
.iter()
.zip(status.modules.iter())
.take(usize::try_from(status.step).unwrap_or(0))
.rev()
.filter(|(_, m)| matches!(m, FlowStatusModule::Success { skipped: false, .. }))
.map(|(module, _)| module)
.find(|module| module.suspend.is_some())
}
/// The approval conditions a step's own settings give, as the worker records them when the step
/// suspends. The worker drops them from the run once the step is approved, so a run that has
/// moved on is gated by these. Groups computed by an expression can't be re-evaluated outside
/// the run, so such a step falls back to any signed-in user.
fn approval_conditions_from_settings(suspend: &Suspend) -> Option<ApprovalConditions> {
let user_auth_required = suspend.user_auth_required.unwrap_or(false);
let self_approval_disabled = suspend.self_approval_disabled.unwrap_or(false);
if !user_auth_required && !self_approval_disabled {
return None;
}
let user_groups_required = match &suspend.user_groups_required {
Some(InputTransform::Static { value }) if user_auth_required => {
serde_json::from_str(value.get()).unwrap_or_default()
}
_ => vec![],
};
Some(ApprovalConditions { user_auth_required, user_groups_required, self_approval_disabled })
}
/// How the approval step presents itself on the approval page.
struct ApprovalStepView {
skin: ApprovalSkin,
summary: Option<String>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(query): Query<ApprovalInfoQuery>,
) -> error::Result<Json<ApprovalInfo>> {
// Validate access: either logged in or valid token
let has_token = if let Some(ref token) = query.token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Nothing below goes through `require_job_read_access`, and an approval token is no
// way around a scoped token's confinement.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_read_scope(&db, authed, &w_id, &job_id).await?;
}
// Fetch job info
#[derive(sqlx::FromRow)]
struct ApprovalJobRow {
id: Uuid,
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
// `v2_job_status` only holds a run that hasn't finished, so the fields below also read
// the completed run's status: a finished run's page keeps its skin and, for workflows as
// code, its description, still gated by the approval conditions the run had.
completed_flow_status: Option<serde_json::Value>,
is_wac: bool,
wac_approval: Option<serde_json::Value>,
approval_conditions: Option<serde_json::Value>,
flow_summary: Option<String>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status,
c.flow_status AS completed_flow_status,
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status) IS NOT NULL
AS is_wac,
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status)->'_approval'
AS wac_approval,
COALESCE(s.flow_status, c.flow_status)->'approval_conditions'
AS approval_conditions,
NULLIF(COALESCE(f.summary, sc.summary), '') AS flow_summary
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
LEFT JOIN v2_job_completed c ON c.id = j.id
LEFT JOIN flow f
ON j.kind = 'flow' AND f.workspace_id = j.workspace_id AND f.path = j.runnable_path
LEFT JOIN script sc
ON j.kind = 'script' AND sc.workspace_id = j.workspace_id AND sc.hash = j.runnable_id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.is_wac;
let run_ac = row
.approval_conditions
.as_ref()
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
// Extract approval info based on WAC vs classic flow
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel, step) =
if is_wac {
let approval_meta = row.wac_approval.as_ref();
let form = approval_meta.and_then(|m| m.get("form").cloned());
let default_args = approval_meta.and_then(|m| m.get("default_args").cloned());
let enums = approval_meta.and_then(|m| m.get("enums").cloned());
let description = approval_meta.and_then(|m| m.get("description").cloned());
let skin = approval_meta
.and_then(|m| m.get("skin"))
.and_then(|v| serde_json::from_value::<ApprovalSkin>(v.clone()).ok())
.unwrap_or_default();
let step = Some(ApprovalStepView { skin, summary: None });
(form, description, default_args, enums, run_ac, None, step)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
// Try raw_flow on the job first, fall back to flow_version for deployed flows,
// then flow_node for graph-based branch/loop sub-flows.
let raw_flow: Option<FlowValue> = {
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_job {
serde_json::from_value(v).ok()
} else {
// Deployed flow: fetch from flow_version using runnable_id
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_version {
serde_json::from_value(v).ok()
} else {
// FlowNode sub-flow (graph-based branch/loop): raw_flow is not stored
// in v2_job for newer versions, fetch from flow_node table
let from_node: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fn.flow FROM v2_job j \
JOIN flow_node fn ON fn.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
from_node.and_then(|v| serde_json::from_value(v).ok())
}
}
};
let suspend_module = raw_flow
.as_ref()
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
let form = suspend_settings
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
let completed_fs = row
.completed_flow_status
.as_ref()
.filter(|_| fs.is_none())
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let approval_module = raw_flow
.as_ref()
.zip(fs.as_ref().or(completed_fs.as_ref()))
.and_then(|(flow, status)| last_reached_approval_step(flow, status));
let ac = run_ac.or_else(|| {
approval_module
.and_then(|module| module.suspend.as_ref())
.and_then(approval_conditions_from_settings)
});
let step = approval_module.map(|module| ApprovalStepView {
skin: module
.suspend
.as_ref()
.and_then(|s| s.skin)
.unwrap_or_default(),
summary: module.summary.clone().filter(|s| !s.trim().is_empty()),
});
// Fetch description, default_args, and enums from the step's completed job result
let step_job_id = fs
.as_ref()
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
.and_then(|m| m.job());
let (desc, default_args, enums) = if let Some(sjid) = step_job_id {
let result: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
)
.bind(sjid)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
let enums = result.as_ref().and_then(|r| r.get("enums").cloned());
(desc, da, enums)
} else {
(None, None, None)
};
(form, desc, default_args, enums, ac, hc, step)
};
let skin = step.as_ref().map(|s| s.skin).unwrap_or_default();
let step_summary = step.and_then(|s| s.summary);
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
// Determine if current user can approve this step.
let can_approve = can_approve_step(
&opt_authed,
&approval_conditions,
row.script_path.as_deref(),
row.email.as_str(),
);
// When the step requires auth, the approval details must be revealed only to authorized
// approvers — a valid token alone is not sufficient. Return a stripped response that lets
// the frontend render the sign-in / not-authorized state without leaking the form,
// description, prefilled args, or other approvers' identities.
let can_view = !user_auth_required || can_approve;
if !can_view {
return Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema: None,
description: None,
default_args: None,
enums: None,
approval_conditions,
can_approve: false,
user_auth_required,
hide_cancel: None,
skin,
step_summary: None,
flow_summary: None,
approvers: vec![],
view_token: None,
}));
}
// Get existing approvers
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
)
.bind(&job_id)
.fetch_all(&db)
.await?
.into_iter()
.map(|(rid, approver)| Approval {
resume_id: rid as u16,
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect();
// Possession of view rights over this approval is sufficient to mint a
// share-read-link token for the flow: it only grants read (no resume), and only to
// an authenticated workspace member, so it never widens what the approver can do.
// Not for a token whose reads are confined: the link would outlive it and serve any
// member, which is why `job_view_token` is refused to it too.
let confined = opt_authed.as_ref().is_some_and(|authed| {
windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref()).is_some()
});
let view_token = if confined {
None
} else {
let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?;
Some(format!("{}.{hmac}", row.id))
};
Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema,
description,
default_args,
enums,
approval_conditions,
can_approve,
user_auth_required,
hide_cancel,
skin,
step_summary,
flow_summary: row.flow_summary,
approvers,
view_token,
}))
}
// --- End new approval system endpoints ---
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
resume_suspended_job_internal(
value,
db,
w_id,
job_id,
resume_id,
approver,
secret,
authed,
opt_tokened,
true,
)
.await
}
async fn resume_suspended_job_internal(
value: Option<serde_json::Value>,
db: sqlx::Pool<Postgres>,
w_id: String,
job_id: Uuid,
resume_id: u32,
approver: QueryApprover,
secret: String,
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
approved: bool,
) -> Result<StatusCode, Error> {
let value = value.unwrap_or(serde_json::Value::Null);
verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// Get flow info - works for step-level, flow-level, and WAC approval. This locks the
// queue row until commit, so resumes serialize with each other and with the worker
// entering the suspend.
let (flow_info, is_flow_level, is_wac) =
get_flow_info_for_resume(job_id, &w_id, &mut tx).await?;
// HMAC secret = full capability. Skip approval_conditions checks: possession of the full
// resume URL is the authorization (it is only disclosed to intended approvers, e.g. when a
// step returns it). Identity-based rules, including self_approval_disabled, are enforced by
// the resume_suspended endpoint instead.
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)
"#,
Uuid::from_u128(job_id.as_u128() ^ resume_id as u128),
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(anyhow::anyhow!("resume request already sent").into());
}
let approver_value = if authed.as_ref().is_none()
|| (approver
.approver
.clone()
.is_some_and(|x| x != "".to_string()))
{
approver.approver
} else {
authed.as_ref().map(|x| x.username.clone())
};
// Inside the transaction that inserts the row and moves the suspend counter:
// validating earlier would let the workflow resolve this step and suspend on the
// next one in between, so a stale request would wake that later step instead.
if is_wac {
reject_mismatched_wac_approval(&mut tx, flow_info.id, resume_id).await?;
}
insert_resume_job(
resume_id,
job_id,
&flow_info,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = 0 WHERE id = $1",
flow_info.id
)
.execute(&mut *tx)
.await?;
} else if is_wac {
// WAC approval: decrement suspend counter directly on the WAC parent job.
decrement_suspend(flow_info.id, &mut tx).await?;
} else if is_flow_level {
// Flow-level resumes don't match a step: the approval is picked up when the worker
// checks for resumes (both step-level and flow-level).
decrement_suspend(flow_info.id, &mut tx).await?;
} else {
// For step-level resumes, try to resume immediately if the step is waiting
resume_immediately_if_relevant(flow_info, job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = match authed {
Some(authed) => (&authed).into(),
None => AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: opt_tokened
.token
.map(|s| s[0..TOKEN_PREFIX_LEN].to_string()),
},
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
async fn verify_suspended_secret(
w_id: &String,
db: &DB,
job_id: Uuid,
resume_id: u32,
approver: &QueryApprover,
secret: String,
) -> Result<(), Error> {
let key = get_workspace_key(w_id, db).await?;
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(job_id.as_bytes());
mac.update(resume_id.to_be_bytes().as_ref());
if let Some(approver) = approver.approver.clone() {
mac.update(approver.as_bytes());
}
mac.verify_slice(hex::decode(secret)?.as_ref())
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
Ok(())
}
/* If the flow is currently waiting to be resumed (`FlowStatusModule::WaitingForEvents`)
* the suspend column must be set to the number of resume messages waited on.
*
* The flow's queue row is locked in this transaction because to avoid race conditions around
* the suspend column.
* That is, a job needs one event but it hasn't arrived, a worker counts zero events before
* entering WaitingForEvents. Then this message arrives but the job isn't in WaitingForEvents
* yet so the suspend counter isn't updated. Then the job enters WaitingForEvents expecting
* one event to arrive based on the count that is no longer correct. */
async fn resume_immediately_if_relevant<'c>(
flow: FlowInfo,
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<()> {
Ok(if 0 < flow.suspend {
let status =
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
.context("deserialize flow status")?;
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
{
decrement_suspend(flow.id, tx).await?;
}
})
}
/// Relative rather than `suspend = <read value> - 1`, so concurrent approvals each remove one
/// event from the count instead of overwriting each other's decrement.
async fn decrement_suspend(flow_id: Uuid, tx: &mut Transaction<'_, Postgres>) -> error::Result<()> {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \
WHERE id = $1 AND suspend > 0",
flow_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_resume_job<'c>(
resume_id: u32,
job_id: Uuid,
flow: &FlowInfo,
value: serde_json::Value,
approver: Option<String>,
approved: bool,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<()> {
sqlx::query!(
r#"
INSERT INTO resume_job
(id, resume_id, job, flow, value, approver, approved)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
Uuid::from_u128(job_id.as_u128() ^ resume_id as u128),
resume_id as i32,
job_id,
flow.id,
value,
approver,
approved
)
.execute(&mut **tx)
.await?;
Ok(())
}
#[derive(sqlx::FromRow)]
struct FlowInfo {
id: Uuid,
flow_status: Option<serde_json::Value>,
suspend: i32,
script_path: Option<String>,
email: Option<String>,
}
/// Get flow info from either a step job (by looking up its parent), a flow job directly,
/// or a WAC workflow job (self-suspended for approval).
/// Returns (FlowInfo, is_flow_level, is_wac) where:
/// - is_flow_level: job_id was a flow job (pre-approval)
/// - is_wac: job_id is a WAC workflow suspended for approval (target is itself)
async fn get_flow_info_for_resume(
job_id: Uuid,
w_id: &str,
tx: &mut Transaction<'_, Postgres>,
) -> error::Result<(FlowInfo, bool, bool)> {
// Single query that determines if job_id is a flow, step, or WAC job,
// and locks the appropriate suspended job's queue row.
// For WAC jobs (no parent, not a flow), the job itself is the suspended target.
let result = sqlx::query!(
r#"
WITH job_info AS (
SELECT id, kind::text AS kind, parent_job
FROM v2_job
WHERE id = $1 AND workspace_id = $2
)
SELECT
q.id AS "id!",
q.suspend AS "suspend!",
j.runnable_path AS script_path,
j.permissioned_as_email AS email,
(ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS "is_flow_level!",
(ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS "is_wac!"
FROM job_info ji
JOIN v2_job_queue q ON q.id = CASE
WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id
ELSE COALESCE(ji.parent_job, ji.id)
END
JOIN v2_job j ON j.id = q.id
FOR UPDATE OF q
"#,
job_id,
w_id,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"job not found or parent flow not in queue: {job_id}"
))
})?;
// Read in its own statement: a statement that waited on the queue-row lock above re-reads
// only the locked row, so a status joined into it could predate the worker's commit.
let flow_status: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(result.id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow_info = FlowInfo {
id: result.id,
flow_status,
suspend: result.suspend,
script_path: result.script_path,
email: Some(result.email),
};
Ok((flow_info, result.is_flow_level, result.is_wac))
}
async fn get_suspended_flow_info<'c>(
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<(FlowInfo, Uuid, bool)> {
// Lock before reading: a single statement that waited on this lock would re-read only
// the queue row, pairing a fresh suspend counter with a stale flow status.
sqlx::query("SELECT 1 FROM v2_job_queue WHERE id = $1 FOR UPDATE")
.bind(job_id)
.execute(&mut **tx)
.await?;
let flow = sqlx::query_as!(
FlowInfo,
r#"
SELECT j.id AS "id!", COALESCE(s.flow_status, s.workflow_as_code_status) as flow_status, q.suspend AS "suspend!", j.runnable_path as script_path, j.permissioned_as_email as email
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_status s USING (id)
WHERE j.id = $1
"#,
job_id,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
// Try to extract step job_id from FlowStatus modules (classic flow path)
let step_job_id = flow
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|s| match s.modules.get(s.step as usize) {
Some(FlowStatusModule::WaitingForEvents { job, .. }) => Some(job.to_owned()),
_ => None,
});
if let Some(step_job_id) = step_job_id {
// Classic flow
Ok((flow, step_job_id, false))
} else if flow.suspend > 0 {
// WAC approval: no FlowStatus modules, but the job is suspended
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
// so for WAC it may contain approval_conditions from flow_status column
// or the WAC checkpoint from workflow_as_code_status column.
// We need the approval_conditions which are in flow_status column.
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
let flow_status_only: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(&job_id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow = FlowInfo {
id: flow.id,
flow_status: flow_status_only,
suspend: flow.suspend,
script_path: flow.script_path,
email: flow.email,
};
Ok((flow, job_id, true))
} else {
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
}
}
pub async fn cancel_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
resume_suspended_job_internal(
value,
db,
w_id,
job_id,
resume_id,
approver,
secret,
authed,
opt_tokened,
false,
)
.await
}
#[derive(Serialize)]
pub struct SuspendedJobFlow {
pub job: Job,
pub approvers: Vec<Approval>,
/// Share-read-link token for the parent flow, minted because the caller proved
/// possession of the approval secret. Lets an authenticated workspace-member
/// approver open the run details of a flow they don't otherwise have read access
/// to (the run page reads it as a `view_token` query param).
#[serde(skip_serializing_if = "Option::is_none")]
pub view_token: Option<String>,
}
pub async fn get_suspended_job_flow(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
) -> error::Result<Response> {
verify_suspended_secret(&w_id, &db, job, resume_id, &approver, secret).await?;
let flow_id = sqlx::query_scalar!(
r#"
SELECT parent_job
FROM v2_job
WHERE id = $1 AND workspace_id = $2
"#,
job,
w_id
)
.fetch_optional(&db)
.await?
.flatten()
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
// The resume secret is this route's gate, so it never reaches
// `require_job_read_access` and the run-scope confinement that gate carries. Re-apply
// it against the flow whose args and status are about to be returned: holding a
// resume secret must not let a scoped token read a flow it may not run. Anonymous
// approvers are unaffected.
if let Some(authed) = authed.as_ref() {
require_job_within_read_scope(&db, authed, &w_id, &flow_id).await?;
}
let flow = GetQuery::new()
.without_logs()
.without_code()
.fetch(&db, &flow_id, &w_id)
.await?;
let flow_status = flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let flow_module_status = flow_status
.modules
.iter()
.find(|p| p.job() == Some(job))
.ok_or_else(|| anyhow::anyhow!("unable to find the module"))?;
let trigger_email = match &flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(
authed.clone(),
flow_status.approval_conditions.clone(),
trigger_email,
)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
_ => vec![],
};
let approvers = if approvers_from_status.is_empty() {
sqlx::query!(
r#"
SELECT resume_id, approver
FROM resume_job
WHERE job = $1
"#,
job,
)
.fetch_all(&db)
.await?
.into_iter()
.map(|x| Approval {
resume_id: x.resume_id as u16,
approver: x.approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect()
} else {
approvers_from_status
};
log_job_view(
&db,
authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&job,
)
.await?;
// Possession of a valid approval secret is sufficient to mint a share-read-link
// token for the parent flow: it only grants read (no resume), and only to an
// authenticated workspace member, so it never widens what the approver can do.
let hmac = generate_view_token(&w_id, flow_id, VIEW_TOKEN_DOMAIN, &db).await?;
let view_token = Some(format!("{flow_id}.{hmac}"));
Ok(Json(SuspendedJobFlow { job: flow, approvers, view_token }).into_response())
}
/// Read the step's approval_conditions from the suspended flow status. For classic flows they
/// live inside the deserialized `FlowStatus`; for workflow-as-code they are a top-level
/// `approval_conditions` key in the status JSON.
fn extract_approval_conditions(
flow_status: Option<&serde_json::Value>,
is_wac: bool,
) -> Option<ApprovalConditions> {
if is_wac {
flow_status
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
} else {
flow_status
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|fs| fs.approval_conditions)
}
}
/// The flow's triggerer may not approve their own suspended step when the step sets
/// `self_approval_disabled`. Only admins are exempt: owning the runnable does not grant
/// the right to approve your own run, so this must be enforced at every resume boundary
/// independently of the owner shortcut (which only waives user_auth_required /
/// user_groups_required).
fn require_not_self_approval(
authed: &ApiAuthed,
approval_conditions: &ApprovalConditions,
trigger_email: &str,
) -> error::Result<()> {
if approval_conditions.self_approval_disabled
&& !authed.is_admin
&& authed.email.eq(trigger_email)
{
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
Ok(())
}
fn conditionally_require_authed_user(
_authed: Option<ApiAuthed>,
approval_conditions_opt: Option<ApprovalConditions>,
_trigger_email: &str,
) -> error::Result<()> {
if approval_conditions_opt.is_none() {
return Ok(());
}
let approval_conditions = approval_conditions_opt.unwrap();
// Check self-approval independently of user_auth_required
if let Some(ref authed) = _authed {
require_not_self_approval(authed, &approval_conditions, _trigger_email)?;
}
if approval_conditions.user_auth_required {
{
#[cfg(not(feature = "enterprise"))]
return Err(Error::BadRequest(
"Approvals for logged in users is an enterprise only feature".to_string(),
));
#[cfg(feature = "enterprise")]
{
if _authed.is_none() {
return Err(Error::NotAuthorized(
"Only logged in users can approve this flow step".to_string(),
));
}
let authed = _authed.unwrap();
if !authed.is_admin {
if !approval_conditions.user_groups_required.is_empty() {
#[cfg(feature = "enterprise")]
{
for required_group in approval_conditions.user_groups_required.iter() {
if authed.groups.contains(&required_group) {
return Ok(());
}
}
let error_msg = format!(
"Only users from one of the following groups are allowed to approve this workflow: {}",
approval_conditions.user_groups_required.join(", ")
);
return Err(Error::PermissionDenied(error_msg));
}
}
}
}
}
}
Ok(())
}
pub async fn create_job_signature(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
) -> error::Result<String> {
require_resume_mint_authority(&db, &w_id, &authed, job_id).await?;
let key = get_workspace_key(&w_id, &db).await?;
create_signature(key, job_id, resume_id, approver.approver)
}
pub async fn get_flow_user_state(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id, key)): Path<(String, Uuid, String)>,
) -> error::JsonResult<Option<serde_json::Value>> {
// Reachable by a `jobs:run` token (it is one of the by-id routes a run needs), so
// apply the same run-scope confinement as the other single-job reads. RLS below
// still governs which jobs the owner's identity can see at all.
require_job_within_read_scope(&db, &authed, &w_id, &job_id).await?;
let mut tx = user_db.begin(&authed).await?;
let r = sqlx::query_scalar!(
r#"
SELECT COALESCE(s.flow_status, s.workflow_as_code_status)->'user_states'->$1
FROM v2_job_queue q LEFT JOIN v2_job_status s USING (id)
WHERE q.id = $2 AND q.workspace_id = $3
"#,
key,
job_id,
w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
Ok(Json(r))
}
pub async fn set_flow_user_state(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id, key)): Path<(String, Uuid, String)>,
Json(value): Json<serde_json::Value>,
) -> error::Result<String> {
let mut tx = user_db.begin(&authed).await?;
let r = sqlx::query_scalar!(
r#"
UPDATE v2_job_status f SET flow_status = JSONB_SET(flow_status, ARRAY['user_states'], JSONB_SET(COALESCE(flow_status->'user_states', '{}'::jsonb), ARRAY[$1], $2))
FROM v2_job j
WHERE f.id = $3 AND f.id = j.id AND j.workspace_id = $4 AND kind IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') RETURNING 1
"#,
key,
value,
job_id,
w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
if r.is_none() {
return Err(Error::NotFound("Flow job not found".to_string()));
}
tx.commit().await?;
Ok("Flow job state updated".to_string())
}
fn create_signature(
key: String,
job_id: Uuid,
resume_id: u32,
approver: Option<String>,
) -> Result<String, Error> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(job_id.as_bytes());
mac.update(resume_id.to_be_bytes().as_ref());
if let Some(approver) = approver {
mac.update(approver.as_bytes());
}
Ok(hex::encode(mac.finalize().into_bytes()))
}
fn build_resume_url(
op: &str,
w_id: &str,
job_id: &Uuid,
resume_id: &u32,
signature: &str,
approver: &str,
base_url: &str,
) -> String {
format!("{base_url}/api/w/{w_id}/jobs_u/{op}/{job_id}/{resume_id}/{signature}{approver}")
}
pub async fn get_resume_urls(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
) -> error::JsonResult<ResumeUrls> {
require_resume_mint_authority(&db, &w_id, &authed, job_id).await?;
get_resume_urls_internal(
Extension(db),
Path((w_id, job_id, resume_id)),
Query(approver),
)
.await
}
/// Resume URLs bound to one `wait_for_approval(key=...)` step of a running
/// Workflow-as-Code job, so the workflow can route the request through its own
/// channel instead of the built-in ones. Same authority as `get_resume_urls`:
/// only the `resume_id` derivation differs, and it is the one the worker will
/// use when that step suspends.
pub async fn get_wac_approval_urls(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, step_key)): Path<(String, Uuid, String)>,
Query(approver): Query<QueryApprover>,
) -> error::JsonResult<ResumeUrls> {
if step_key.trim().is_empty() {
return Err(Error::BadRequest(
"step_key must be the key of a wait_for_approval step".to_string(),
));
}
// Also the workspace check for the `v2_job_status` write below, which is keyed by job id
// alone and would otherwise take a write aimed at another workspace's job.
require_resume_mint_authority(&db, &w_id, &authed, job_id).await?;
// The approval belongs to the WAC parent, but WM_JOB_ID is the child job when
// this is called from inside a task() rather than a step(). Resolve up so the
// URL still targets the workflow that will suspend.
let job_id = get_flow_id_for_job(&db, job_id).await.unwrap_or(job_id);
// The write below is what the run page keys its WAC timeline off, so it must not
// land on a job that has no WAC status. Rules out flows and step/child jobs; a
// WAC parent is itself a script job, so a plain script is indistinguishable here
// and still passes — it simply never mints, since only the SDK calls this.
let is_wac = sqlx::query_scalar!(
r#"SELECT (kind::text NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
AND parent_job IS NULL) AS "is_wac!"
FROM v2_job WHERE id = $1"#,
job_id
)
.fetch_optional(&db)
.await?
.unwrap_or(false);
if !is_wac {
return Err(Error::BadRequest(format!(
"job {job_id} is not a workflow-as-code job"
)));
}
let resume_id = windmill_common::wac::approval_resume_id(&step_key);
// Remember which steps have a minted URL in circulation. A workflow may mint
// several up front, and the resume path uses this to tell "URL for the step
// awaiting approval" apart from "URL for some other step of this workflow",
// which it otherwise cannot: the interactive channels sign random resume_ids
// and must keep resuming whatever step is pending.
//
// Record first, then look for a collision, both in one transaction: the upsert
// takes the row lock, so a concurrent mint of a colliding key is serialized
// behind it and sees this key rather than racing past an earlier read. Upsert
// because a workflow can mint before any step has checkpointed, and a bare
// UPDATE would silently match nothing and leave the link unbound.
let mut tx = db.begin().await?;
sqlx::query(
"INSERT INTO v2_job_status (id, workflow_as_code_status)
VALUES ($1, jsonb_build_object('_minted_approval_keys',
jsonb_build_object($2::text, true)))
ON CONFLICT (id) DO UPDATE SET workflow_as_code_status = jsonb_set(
COALESCE(v2_job_status.workflow_as_code_status, '{}'::jsonb),
ARRAY['_minted_approval_keys'],
COALESCE(v2_job_status.workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb)
|| jsonb_build_object($2::text, true)
)",
)
.bind(job_id)
.bind(&step_key)
.execute(&mut *tx)
.await?;
// Two keys sharing a resume_id share one resume_job row and one capability, and
// the binding check could not tell which of them a link was minted for.
if let Some(other) = sqlx::query_scalar::<_, String>(
"SELECT jsonb_object_keys(
COALESCE(workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb))
FROM v2_job_status WHERE id = $1",
)
.bind(job_id)
.fetch_all(&mut *tx)
.await?
.into_iter()
.find(|k| *k != step_key && windmill_common::wac::approval_resume_id(k) == resume_id)
{
tx.rollback().await?;
return Err(Error::BadRequest(format!(
"step key `{step_key}` collides with `{other}` on the same resume id; rename one"
)));
}
tx.commit().await?;
get_resume_urls_internal(
Extension(db),
Path((w_id, job_id, resume_id)),
Query(approver),
)
.await
}
/// A WAC resume URL minted for a named `wait_for_approval` step is accepted only
/// while that step is the one awaiting approval. Approval rows are consumed
/// oldest-first regardless of resume_id (WIN-2241 — required so Slack/Teams/the
/// approval page, which sign random ids, keep working), so a row banked at any
/// other moment is picked up by whichever approval is reached first, silently
/// answering it with this approver's response. Unbound resume_ids are untouched.
async fn reject_mismatched_wac_approval(
tx: &mut Transaction<'_, Postgres>,
job_id: Uuid,
resume_id: u32,
) -> Result<(), Error> {
// Lock the queue row the worker also writes when it suspends on the next step,
// so the pending step read below cannot change before this transaction commits.
sqlx::query("SELECT 1 FROM v2_job_queue WHERE id = $1 FOR UPDATE")
.bind(job_id)
.fetch_optional(&mut **tx)
.await?;
let status: Option<sqlx::types::Json<WacApprovalBinding>> = sqlx::query_scalar(
"SELECT jsonb_build_object(
'minted', COALESCE(workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb),
'pending', workflow_as_code_status->'_checkpoint'->'pending_steps'
) FROM v2_job_status WHERE id = $1",
)
.bind(job_id)
.fetch_optional(&mut **tx)
.await?;
let Some(sqlx::types::Json(binding)) = status else {
return Ok(());
};
let awaiting = binding.pending.as_ref().filter(|p| p.mode == "approval");
let bound_to = binding
.minted
.keys()
.find(|k| windmill_common::wac::approval_resume_id(k) == resume_id);
// A bound link is only ever valid while its own step is the one awaiting
// approval. Accepting it at any other time — including while the workflow is
// still running toward that step — leaves a row that the next approval to be
// reached consumes, whichever step that is.
match (bound_to, awaiting) {
(Some(step), pending) if !pending.is_some_and(|p| p.keys.iter().any(|k| k == step)) => {
Err(Error::BadRequest(format!(
"this approval link is bound to step `{step}`, which is not currently awaiting \
approval"
)))
}
_ => Ok(()),
}
}
#[derive(Deserialize)]
struct WacApprovalBinding {
minted: std::collections::HashMap<String, serde_json::Value>,
pending: Option<windmill_common::wac::WacPendingSteps>,
}
pub async fn get_resume_urls_internal(
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
) -> error::JsonResult<ResumeUrls> {
let key = get_workspace_key(&w_id, &db).await?;
// If flow_level is true, use the parent flow ID for the signature and URLs
// This allows pre-approvals that can be consumed by any later suspend step
let target_job_id = if approver.flow_level.unwrap_or(false) {
get_flow_id_for_job(&db, job_id).await?
} else {
job_id
};
let signature = create_signature(key, target_job_id, resume_id, approver.approver.clone())?;
let approver_query = approver
.approver
.as_ref()
.map(|x| format!("?approver={}", encode(x)))
.unwrap_or_else(String::new);
// Generate approval token for the new approval page URL.
// The token targets the parent flow/WAC job for proper resolution.
let approval_target_id = get_flow_id_for_job(&db, job_id)
.await
.unwrap_or(target_job_id);
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
let base_url_str = (**BASE_URL.load()).clone();
let base_url = base_url_str.as_str();
let res = ResumeUrls {
approvalPage: format!(
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
),
cancel: build_resume_url(
"cancel",
&w_id,
&target_job_id,
&resume_id,
&signature,
&approver_query,
&base_url,
),
resume: build_resume_url(
"resume",
&w_id,
&target_job_id,
&resume_id,
&signature,
&approver_query,
&base_url,
),
};
Ok(Json(res))
}
/// A resume signature is full approval authority: `jobs_u/resume|cancel` skip the step's
/// approval_conditions and accept any resume_id. So only a workspace admin, or a job token
/// whose run lineage claims the suspended job ([`unclaimable_run_lineage`]), may mint one, and
/// a scoped token also needs run scope on the flow. The job must be in `w_id`, whose key signs
/// the result.
async fn require_resume_mint_authority(
db: &DB,
w_id: &str,
authed: &ApiAuthed,
job_id: Uuid,
) -> error::Result<()> {
let flow_path = resume_target_flow_path(db, w_id, job_id).await?;
check_scopes(authed, || format!("jobs:run:flows:{}", flow_path))?;
if !unclaimable_run_lineage(db, w_id, vec![job_id], authed)
.await?
.is_empty()
{
return Err(Error::PermissionDenied(format!(
"only job {job_id}'s own run or a workspace admin can sign resume or approval urls for it"
)));
}
Ok(())
}
/// Resolve the runnable path of the flow a (possibly step) job of `w_id` belongs to, used
/// to scope-check resume-signature minting against `jobs:run:flows:<path>`.
/// Returns an empty string when the path can't be resolved (e.g. previews); an empty
/// path only matters for path-restricted tokens, which would not be running such a flow.
async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
let job = sqlx::query!(
r#"SELECT kind::text as "kind!", parent_job, runnable_path
FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
job_id,
w_id
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("job {job_id} not found")))?;
// All flow kinds: the job itself is the flow whose path scopes the resume.
if matches!(
job.kind.as_str(),
"flow" | "flowpreview" | "flownode" | "singlestepflow"
) {
return Ok(job.runnable_path.unwrap_or_default());
}
// Otherwise it's a step; its parent is the flow.
if let Some(parent) = job.parent_job {
return Ok(sqlx::query_scalar!(
"SELECT runnable_path FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent,
w_id
)
.fetch_optional(db)
.await?
.flatten()
.unwrap_or_default());
}
Ok(job.runnable_path.unwrap_or_default())
}
/// Get the flow ID for a job. If the job is a flow, returns the job_id.
/// If the job is a step in a flow, returns the parent flow ID.
async fn get_flow_id_for_job(db: &DB, job_id: Uuid) -> error::Result<Uuid> {
// First check if the job is a flow itself (kind = 'flow' or 'flowpreview')
let job_info = sqlx::query!(
r#"
SELECT kind::text as "kind!", parent_job
FROM v2_job
WHERE id = $1
"#,
job_id
)
.fetch_optional(db)
.await?
.ok_or_else(|| anyhow::anyhow!("job not found: {}", job_id))?;
// If it's a flow job, return the job_id itself
if job_info.kind == "flow" || job_info.kind == "flowpreview" {
return Ok(job_id);
}
// Otherwise, return the parent flow ID
job_info
.parent_job
.ok_or_else(|| anyhow::anyhow!("job {} has no parent flow", job_id).into())
}
#[derive(Deserialize)]
struct CancelJob {
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum PreviewKind {
Code,
Identity,
Noop,
Bundle,
Tarbundle,
ScriptHash,
}
#[derive(Debug, Deserialize)]
struct Preview {
content: Option<String>,
kind: Option<PreviewKind>,
script_hash: Option<String>,
path: Option<String>,
args: Option<HashMap<String, Box<JsonRawValue>>>,
language: Option<ScriptLang>,
tag: Option<String>,
dedicated_worker: Option<bool>,
lock: Option<String>,
format: Option<String>,
flow_path: Option<String>,
modules: Option<HashMap<String, ScriptModule>>,
/// Map of relative-import script path -> temp storage hash. When set, the
/// preview job resolves those imports from not-yet-deployed local content
/// (uploaded to raw_script_temp) instead of the deployed script.
temp_script_refs: Option<HashMap<String, String>>,
/// Run the preview as this identity (an email or a `u/`/`g/` principal) instead of the
/// caller. See `preview_on_behalf_of`.
on_behalf_of: Option<String>,
/// Data table and object storage access as the caller rather than as the job's identity,
/// as a full-code app's inline runnable flagged `viewer_data_access` does for its viewer.
viewer_data_access: Option<bool>,
}
#[cfg(feature = "run_inline")]
#[derive(Debug, Deserialize)]
struct PreviewInline {
content: String,
args: Option<HashMap<String, Box<JsonRawValue>>>,
language: ScriptLang,
}
#[cfg(feature = "run_inline")]
#[derive(Debug, Deserialize)]
struct InlineScriptArgs {
args: Option<HashMap<String, Box<JsonRawValue>>>,
}
#[derive(Deserialize)]
pub struct WorkflowTask {
pub args: Option<HashMap<String, Box<JsonRawValue>>>,
}
#[derive(Deserialize)]
struct PreviewFlow {
value: FlowValue,
path: Option<String>,
args: Option<HashMap<String, Box<JsonRawValue>>>,
tag: Option<String>,
restarted_from: Option<RestartedFrom>,
/// Map of relative-import script path -> temp storage hash. Propagated to
/// each flow step so inline-script relative imports resolve from
/// not-yet-deployed local content instead of the deployed script.
temp_script_refs: Option<HashMap<String, String>>,
/// See `Preview::on_behalf_of`.
on_behalf_of: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DynamicSelectRequest {
pub entrypoint_function: String,
pub args: Option<HashMap<String, Box<JsonRawValue>>>,
pub runnable_ref: DynamicSelectRunnableRef,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source")]
pub enum DynamicSelectRunnableRef {
#[serde(rename = "deployed")]
Deployed { path: String, runnable_kind: RunnableKind },
#[serde(rename = "inline")]
Inline { code: String, lang: Option<ScriptLang> },
}
use windmill_common::flows::InputTransform;
#[derive(Deserialize)]
struct BatchReRunJobsBodyArgs {
job_ids: Vec<Uuid>,
script_options_by_path: HashMap<String, BatchReRunOptions>,
flow_options_by_path: HashMap<String, BatchReRunOptions>,
}
#[derive(Deserialize)]
struct BatchReRunOptions {
input_transforms: Option<HashMap<String, InputTransform>>,
use_latest_version: Option<bool>,
}
#[derive(sqlx::FromRow, Serialize, Clone)]
struct BatchReRunQueryReturnType {
id: Uuid,
kind: JobKind,
script_path: String,
script_hash: ScriptHash,
input: serde_json::Value,
scheduled_for: chrono::DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
#[serde(skip)]
created_by: String,
}
async fn batch_rerun_compute_js_expression(
expr: String,
job: BatchReRunQueryReturnType,
) -> error::Result<Box<RawValue>> {
let job_no_schema = BatchReRunQueryReturnType { schema: None, ..job };
let job_value =
serde_json::to_value(&job_no_schema).map_err(|e| Error::ExecutionErr(e.to_string()))?;
let mut globals = std::collections::HashMap::new();
globals.insert("job".to_string(), job_value);
windmill_jseval::eval_simple_js(expr, globals)
.await
.map_err(|e| Error::ExecutionErr(e.to_string()))
}
async fn batch_rerun_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(body): Json<BatchReRunJobsBodyArgs>,
) -> error::Result<Response> {
check_scopes(&authed, || format!("jobs:run"))?;
let stream = batch_rerun_jobs_inner(authed, db, user_db, w_id, body);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Ok(Response::builder()
.status(201)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.body(body)
.unwrap())
}
fn batch_rerun_jobs_inner(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
body: BatchReRunJobsBodyArgs,
) -> impl futures::Stream<Item = String> {
let (tx, rx) = tokio::sync::mpsc::channel(10);
tokio::spawn(async move {
let mut job_stream = sqlx::query_as!(
BatchReRunQueryReturnType,
r#"WITH norm AS (
SELECT
j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by,
-- Project effective kind for dispatch: pass script/flow through;
-- for singlestepflow, read the wrapped runnable's type from
-- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').
CASE
WHEN j.kind IN ('script', 'flow') THEN j.kind::text
WHEN j.kind = 'singlestepflow' THEN
COALESCE(
(SELECT m->'value'->>'type'
FROM jsonb_array_elements(j.raw_flow->'modules') m
WHERE m->>'id' = 'a'
LIMIT 1),
'script'
)
END AS norm_kind,
-- Pinned script hash for script-wrapped singlestepflow lives in
-- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a
-- version, so this is NULL there (Flow rerun pushes by path).
(CASE WHEN j.kind = 'singlestepflow' THEN
(SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint
FROM jsonb_array_elements(j.raw_flow->'modules') m
WHERE m->>'id' = 'a'
AND m->'value'->>'hash' IS NOT NULL
LIMIT 1)
END) AS ssf_hash
FROM v2_job j
WHERE j.id = ANY($1)
AND j.workspace_id = $2
AND j.kind IN ('script', 'flow', 'singlestepflow')
)
SELECT
n.id,
n.norm_kind::JOB_KIND AS "kind!: _",
COALESCE(s.path, f.path, n.runnable_path) AS "script_path!",
-- script_hash is unused on the Flow rerun path (path-based push), so
-- 0 is a safe placeholder when no version is pinned.
COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS "script_hash!: _",
COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS "scheduled_for!: _",
n.args AS input,
n.created_by,
-- Pinned schema for script/flow; latest-by-path fallback for
-- singlestepflow so input_transforms still resolve at rerun time.
COALESCE(
s.schema,
f.schema,
(CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN
(SELECT s2.schema FROM script s2
WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path
ORDER BY s2.created_at DESC LIMIT 1)
END),
(CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN
(SELECT fv.schema FROM flow
LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)
END)
) AS "schema: _"
FROM norm n
LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'
LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'
LEFT JOIN v2_job_completed jc ON jc.id = n.id
LEFT JOIN v2_job_queue jq ON jq.id = n.id
WHERE n.norm_kind IS NOT NULL
AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL
AND (
n.kind = 'singlestepflow'
OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)
)"#,
&body.job_ids,
w_id
).fetch(&db);
while let Some(Ok(job)) = job_stream.next().await {
let job_result =
batch_rerun_handle_job(&job, &authed, &db, &user_db, &w_id, &body).await;
let send_to_stream_result = tx
.send(match job_result {
Ok(uuid) => format!("{}\n", uuid),
Err(err) => format!("Error: {}\n", err.to_string()),
})
.await;
match send_to_stream_result {
Ok(_) => {}
Err(e) => tracing::error!("Couldn't re-run job {}: {}", job.id, e.to_string()),
}
}
});
tokio_stream::wrappers::ReceiverStream::new(rx)
}
async fn batch_rerun_handle_job(
job: &BatchReRunQueryReturnType,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &String,
body: &BatchReRunJobsBodyArgs,
) -> error::Result<String> {
// The source jobs are selected on the root pool, and a rerun copies their args into a
// job the caller owns, so each one must pass the same read gate as `jobs_u/get`.
require_job_read_access(db, user_db, authed, w_id, &job.id, &job.created_by, None).await?;
let options = if matches!(job.kind, JobKind::Script) {
&body.script_options_by_path
} else {
&body.flow_options_by_path
}
.get(&job.script_path);
let mut args: HashMap<String, Box<RawValue>> = serde_json::from_value(job.input.clone())?;
let use_latest_version = options.and_then(|o| o.use_latest_version).unwrap_or(false);
let input_transforms = options
.and_then(|o| o.input_transforms.as_ref())
.map(|t| t.iter())
.into_iter()
.flatten();
let latest_schema;
let schema = if use_latest_version {
// Project singlestepflow's wrapped runnable type so the path-based schema
// lookup resolves it to the underlying script/flow's latest schema —
// without this, transforms silently no-op for singlestepflow reruns.
latest_schema = sqlx::query_scalar!(
r#"SELECT COALESCE(
(SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = norm.path AND s.workspace_id = $2 AND norm.kind = 'script' ORDER BY s.path, s.created_at DESC),
(SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = norm.path AND flow.workspace_id = $2 AND norm.kind = 'flow')
) FROM (
SELECT
jb.runnable_path AS path,
CASE
WHEN jb.kind IN ('script', 'flow') THEN jb.kind::text
WHEN jb.kind = 'singlestepflow' THEN COALESCE(
(SELECT m->'value'->>'type' FROM jsonb_array_elements(jb.raw_flow->'modules') m WHERE m->>'id' IN ('a', 'main') LIMIT 1),
'script'
)
END AS kind
FROM v2_job jb
WHERE jb.id = $1 AND jb.workspace_id = $2
) norm
GROUP BY norm.kind, norm.path"#,
&job.id,
&w_id
).fetch_optional(db).await?.flatten();
latest_schema.as_ref()
} else {
job.schema.as_ref()
};
let schema = schema
.and_then(serde_json::Value::as_object)
.and_then(|s| s.get("properties"))
.and_then(serde_json::Value::as_object);
for (property_name, transform) in input_transforms {
let schema_has_key = schema
.map(|s| s.contains_key(property_name))
.unwrap_or(false);
if !schema_has_key {
continue;
}
match transform {
InputTransform::Static { value } => {
args.insert(property_name.clone(), value.clone());
}
InputTransform::Javascript { expr } => {
args.insert(
property_name.clone(),
batch_rerun_compute_js_expression(expr.clone(), job.clone()).await?,
);
}
InputTransform::Ai => {}
}
}
// Call appropriate function to push job to queue
match job.kind {
JobKind::Flow => {
let result = push_flow_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
user_db.clone(),
w_id.clone(),
StripPath(job.script_path.clone()),
RunJobQuery { skip_preprocessor: Some(true), ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await;
if let Ok((uuid, _, _, _)) = result {
resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await;
return Ok(uuid.to_string());
}
}
JobKind::Script => {
let result = if use_latest_version {
push_script_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
user_db.clone(),
w_id.clone(),
StripPath(job.script_path.clone()),
RunJobQuery { skip_preprocessor: Some(true), ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await
.map(|r| r.0)
} else {
run_job_by_hash_inner(
authed.clone(),
db.clone(),
user_db.clone(),
w_id.clone(),
job.script_hash,
RunJobQuery { skip_preprocessor: Some(true), ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await
.map(|r| r.0)
};
if let Ok(uuid) = result {
resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await;
return Ok(uuid.to_string());
}
}
_ => {}
}
Err(error::Error::ExecutionErr(
format!("Couldn't re-run job {}", job.id).to_string(),
))
}
/// Marks a failed or canceled source of a batch re-run as handled, with the re-run as its note.
/// This is the only moment the link exists (the re-run is pushed with no back-pointer), and it
/// is what lets "hide resolved" exclude already re-run jobs from the next batch. Never fails the
/// re-run, which is already queued: a source that cannot be resolved is logged and left as is.
async fn resolve_batch_rerun_source(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
source: Uuid,
rerun: Uuid,
) {
if !role_can_code(db, w_id, authed).await.unwrap_or(false) {
return;
}
let result: error::Result<()> = async {
let mut tx = user_db.clone().begin(authed).await?;
let tags = get_scope_tags(authed);
let (resolved_by, _) = resolution_attribution(authed, None);
// Same authorization and eligibility as `resolve_completed_jobs`. DO NOTHING keeps a
// resolution someone already explained.
let resolved = sqlx::query_scalar!(
"INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)
SELECT c.id, c.workspace_id, $4, $5, false
FROM v2_job_completed c
JOIN v2_job j ON j.id = c.id
WHERE c.id = $1
AND c.workspace_id = $2
AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))
AND c.status IN ('failure', 'canceled')
AND j.flow_step_id IS NULL
ON CONFLICT (job_id) DO NOTHING
RETURNING job_id",
source,
w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
resolved_by,
format!("Re-run as {rerun}"),
)
.fetch_optional(&mut *tx)
.await?;
if resolved.is_some() {
audit_log(
&mut *tx,
authed,
"jobs.resolve",
ActionKind::Update,
w_id,
Some(&source.to_string()),
None,
)
.await?;
}
tx.commit().await?;
Ok(())
}
.await;
if let Err(e) = result {
tracing::error!("Could not resolve job {source} after re-running it as {rerun}: {e}");
}
}
pub async fn run_flow_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let (args, trigger_metadata) = match get_args_and_trigger_metadata(
&db,
&authed,
RunnableId::from_flow_path(flow_path.to_path()),
&run_query,
&w_id,
args,
)
.await?
{
WebhookRun::Run(args, trigger_metadata) => (args, trigger_metadata),
// 200 rather than an error: services drop a webhook that keeps failing, and disabling a
// trigger in Windmill must not cost it its registration.
WebhookRun::TriggerDisabled => {
return Ok((StatusCode::OK, NATIVE_TRIGGER_DISABLED_MSG.to_string()))
}
};
let (uuid, _, _, _) = push_flow_job_by_path_into_queue(
authed,
db,
None,
user_db,
w_id,
flow_path,
run_query,
args,
trigger_metadata,
)
.await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
pub async fn run_flow_by_version(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, version)): Path<(String, i64)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_flow_version(version),
run_query.skip_preprocessor,
)
.await?;
let (uuid, _, _) =
run_flow_by_version_inner(authed, db, user_db, w_id, version, run_query, args, None)
.await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
pub async fn run_flow_by_version_inner(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
version: i64,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(Uuid, Option<String>, bool)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
let flow_version_info =
get_flow_version_info_from_version(&db, version, &w_id, &flow_path).await?;
let (uuid, early_return, has_failure_module, _) = run_flow(
&authed,
&db,
None,
user_db,
&w_id,
&flow_path,
flow_version_info,
run_query,
args,
trigger,
)
.await?;
Ok((uuid, early_return, has_failure_module))
}
/// Path parameters of `POST /w/{workspace}/jobs/restart/f/{job_id}`, shared by the CE and EE
/// handlers. Axum only checks the tuple against the route at request time and rejects a
/// mismatch with an opaque 500 before the handler runs, so both must be declared from here:
/// an arity that drifts from the route hides the handler behind what reads as a broken route.
type RestartFlowPath = Path<(String, Uuid)>;
#[cfg(not(feature = "enterprise"))]
pub async fn restart_flow(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path((_w_id, _job_id)): RestartFlowPath,
Query(_run_query): Query<RunJobQuery>,
) -> error::Result<(StatusCode, String)> {
return Err(Error::BadRequest(
"Restarting a flow is a feature only available in enterprise version".to_string(),
));
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
pub struct RestartFlowRequestBody {
step_id: String,
branch_or_iteration_n: Option<usize>,
flow_version: Option<i64>,
/// Optional path of additional steps to descend into AFTER `step_id`. Each entry
/// represents one level of nesting inside the spawned child of the previous level's
/// container (BranchOne / ForLoop iteration / Subflow). When non-empty, the actual
/// restart point is the LAST entry's `step_id`; intermediate entries identify the
/// containers along the way.
#[serde(default)]
nested_path: Vec<NestedRestartStep>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
pub struct NestedRestartStep {
step_id: String,
/// For ForLoop / sequential BranchAll containers: the 0-based iteration
/// (or branch) index to restart at. Iterations `0..n-1` are preserved,
/// iteration `n` is the restart target. Defaults to `0` when omitted.
/// Unused for BranchOne / Subflow containers.
branch_or_iteration_n: Option<usize>,
}
/// Walks the `nested_path` against the original execution to:
/// - validate each step exists and was executed
/// - resolve the child job UUID at each level
/// - capture `branch_chosen` for BranchOne containers (so the worker can lock branch
/// evaluation and reuse the originally-taken path)
///
/// Returns `(top_branch_chosen, nested_chain)`:
/// - `top_branch_chosen`: locked branch for the TOP-level container if it is a
/// BranchOne, else None
/// - `nested_chain`: the `RestartedFrom` to embed under the top-level run's
/// `restarted_from.nested` (None when `nested_path` is empty)
#[cfg(feature = "enterprise")]
async fn resolve_nested_restart(
db: &DB,
workspace_id: &str,
parent_job_id: Uuid,
parent_step_id: &str,
parent_branch_or_iteration_n: Option<usize>,
nested_path: Vec<NestedRestartStep>,
// (flow path of the restarted job, version to restart on)
parent_flow_version: Option<(&str, i64)>,
) -> error::Result<(
Option<windmill_common::flow_status::BranchChosen>,
Option<Box<RestartedFrom>>,
)> {
use windmill_common::flow_status::BranchChosen;
use windmill_common::flows::FlowModuleValue;
let row = sqlx::query!(
"SELECT
j.runnable_id AS \"runnable_id: ScriptHash\",
j.kind AS \"job_kind!: JobKind\",
COALESCE(c.flow_status, c.workflow_as_code_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",
j.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\"
FROM v2_job_completed c JOIN v2_job j USING (id)
WHERE j.id = $1 AND j.workspace_id = $2",
parent_job_id,
workspace_id,
)
.fetch_optional(db)
.await?
.with_context(|| {
format!(
"completed job {} not found while resolving nested restart path",
parent_job_id
)
})?;
let flow_data = if let Some((flow_path, version)) = parent_flow_version {
windmill_queue::fetch_restart_flow_version(db, workspace_id, flow_path, version).await?
} else {
cache::job::fetch_flow(db, &row.job_kind, row.runnable_id)
.or_else(|_| cache::job::fetch_preview_flow(db, &parent_job_id, row.raw_flow))
.await?
};
let flow_value = flow_data.value();
let flow_status: FlowStatus = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_str::<FlowStatus>(v.get()).ok())
.ok_or_else(|| {
Error::internal_err(format!(
"Unable to parse flow status for job {}",
parent_job_id
))
})?;
let parent_status_module = flow_status
.modules
.iter()
.find(|m| m.id() == parent_step_id)
.ok_or_else(|| {
Error::BadRequest(format!(
"step '{}' not found in original run for job {}",
parent_step_id, parent_job_id
))
})?;
let parent_module_def = flow_value
.modules
.iter()
.find(|m| m.id == parent_step_id)
.ok_or_else(|| {
Error::BadRequest(format!(
"step '{}' not found in flow definition for job {}",
parent_step_id, parent_job_id
))
})?;
// Leaf level: validation is done (`parent_step_id` exists), no chain to
// build below. Return early so the caller's `nested_path.last()` step is
// confirmed to actually exist before we queue the restart job.
if nested_path.is_empty() {
return Ok((None, None));
}
let parent_module_value = parent_module_def.get_value()?;
let (child_job_id, parent_branch_chosen): (Uuid, Option<BranchChosen>) =
match parent_module_value {
FlowModuleValue::BranchOne { .. } => {
let job = parent_status_module.job().ok_or_else(|| {
Error::BadRequest(format!(
"BranchOne step '{}' has no child job in original run",
parent_step_id
))
})?;
(job, parent_status_module.branch_chosen())
}
FlowModuleValue::Flow { .. } => {
let job = parent_status_module.job().ok_or_else(|| {
Error::BadRequest(format!(
"Subflow step '{}' has no child job in original run",
parent_step_id
))
})?;
(job, None)
}
FlowModuleValue::ForloopFlow { parallel, .. } => {
if parallel {
return Err(Error::BadRequest(format!(
"Restart inside a parallel ForLoop is not supported (step '{}')",
parent_step_id
)));
}
// 0-based iteration to restart at; absent ⟹ iteration 0.
// Iterations 0..n-1 are preserved (matching existing top-level semantics).
let n = parent_branch_or_iteration_n.unwrap_or(0);
let flow_jobs = parent_status_module.flow_jobs().ok_or_else(|| {
Error::BadRequest(format!(
"ForLoop step '{}' has no recorded iterations in original run",
parent_step_id
))
})?;
let job = flow_jobs.get(n).copied().ok_or_else(|| {
Error::BadRequest(format!(
"ForLoop step '{}' has only {} iterations; cannot restart at iteration {}",
parent_step_id,
flow_jobs.len(),
n
))
})?;
(job, None)
}
FlowModuleValue::BranchAll { .. } => {
return Err(Error::BadRequest(format!(
"Restart inside a BranchAll step ('{}') is not supported",
parent_step_id
)));
}
FlowModuleValue::WhileloopFlow { .. } => {
return Err(Error::BadRequest(format!(
"Restart inside a WhileLoop step ('{}') is not supported",
parent_step_id
)));
}
_ => {
return Err(Error::BadRequest(format!(
"step '{}' is not a container (BranchOne / sequential ForLoop / Subflow); cannot have a nested restart",
parent_step_id
)));
}
};
let mut iter = nested_path.into_iter();
let head = iter.next().unwrap();
let rest: Vec<NestedRestartStep> = iter.collect();
let head_step_id = head.step_id;
let head_branch_or_iteration_n = head.branch_or_iteration_n;
let (child_branch_chosen, deeper_nested) = Box::pin(resolve_nested_restart(
db,
workspace_id,
child_job_id,
&head_step_id,
head_branch_or_iteration_n,
rest,
None,
))
.await?;
let nested_chain = RestartedFrom {
flow_job_id: child_job_id,
step_id: head_step_id,
branch_or_iteration_n: head_branch_or_iteration_n,
flow_version: None,
branch_chosen: child_branch_chosen,
nested: deeper_nested,
};
Ok((parent_branch_chosen, Some(Box::new(nested_chain))))
}
#[cfg(feature = "enterprise")]
pub async fn restart_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): RestartFlowPath,
Query(run_query): Query<RunJobQuery>,
Json(RestartFlowRequestBody {
step_id,
branch_or_iteration_n,
flow_version,
nested_path,
}): Json<RestartFlowRequestBody>,
) -> error::Result<(StatusCode, String)> {
check_license_key_valid().await?;
let mut tx = user_db.clone().begin(&authed).await?;
let completed_job = sqlx::query!(
"SELECT
j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",
j.tag AS \"tag!\", j.priority, j.kind AS \"kind!: JobKind\"
FROM v2_job j
WHERE j.id = $1 and j.workspace_id = $2",
job_id,
&w_id,
)
.fetch_optional(&mut *tx)
.await?
.with_context(|| "Unable to find completed job with the given job UUID")?;
drop(tx);
let flow_path = completed_job
.script_path
.with_context(|| "No flow path set for completed flow job")?;
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
// A restarted flow preview reruns the value its request supplied, while a flow preview
// under a parent is read (by job provenance) as that parent's own definition.
let (parent_job, root_job) = if completed_job.kind == JobKind::FlowPreview {
(None, None)
} else {
(run_query.parent_job, run_query.root_job)
};
let ehm = HashMap::new();
let push_args = completed_job
.args
.as_ref()
.map(|json| PushArgs { args: &json.0, extra: None })
.unwrap_or_else(|| PushArgs::from(&ehm));
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let (top_branch_chosen, nested_chain) = resolve_nested_restart(
&db,
&w_id,
job_id,
&step_id,
branch_or_iteration_n,
nested_path,
flow_version.map(|v| (flow_path.as_str(), v)),
)
.await?;
let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into());
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
JobPayload::RestartedFlow {
completed_job_id: job_id,
step_id,
branch_or_iteration_n,
flow_version,
branch_chosen: top_branch_chosen,
nested: nested_chain,
},
push_args,
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
parent_job,
None,
root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
Some(completed_job.tag),
None,
None,
completed_job.priority,
Some(&authed.clone().into()),
false,
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
pub async fn run_script_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let (args, trigger_metadata) = match get_args_and_trigger_metadata(
&db,
&authed,
RunnableId::from_script_path(script_path.to_path()),
&run_query,
&w_id,
args,
)
.await?
{
WebhookRun::Run(args, trigger_metadata) => (args, trigger_metadata),
WebhookRun::TriggerDisabled => {
return Ok((StatusCode::OK, NATIVE_TRIGGER_DISABLED_MSG.to_string()))
}
};
let (uuid, _, _) = push_script_job_by_path_into_queue(
authed,
db,
None,
user_db,
w_id,
script_path,
run_query,
args,
trigger_metadata,
)
.await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
/// What a webhook delivery resolved to: the arguments to run with, or nothing to run.
pub enum WebhookRun {
Run(PushArgsOwned, Option<TriggerMetadata>),
/// The native trigger this delivery belongs to is disabled.
TriggerDisabled,
}
const NATIVE_TRIGGER_DISABLED_MSG: &str =
"This trigger is disabled in Windmill, so no job was created";
#[allow(unused)]
pub async fn get_args_and_trigger_metadata(
db: &DB,
authed: &ApiAuthed,
runnable_id: RunnableId,
run_query: &RunJobQuery,
w_id: &str,
args: RawWebhookArgs,
) -> error::Result<WebhookRun> {
use windmill_common::triggers::TriggerMetadata;
// Build trigger metadata if this is a native trigger request
#[cfg(feature = "native_trigger")]
let (trigger_metadata, native_args) = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::{
native_trigger_is_enabled, prepare_native_trigger_args, ServiceName,
};
let service_name = ServiceName::try_from(service_name_str.to_owned())?;
if let Some(external_id) = run_query.trigger_external_id.as_deref() {
if !native_trigger_is_enabled(db, w_id, service_name, external_id).await? {
return Ok(WebhookRun::TriggerDisabled);
}
}
let metadata = Some(TriggerMetadata::new(
run_query.trigger_external_id.clone(),
service_name.as_job_trigger_kind(),
));
let body = match &args.body {
crate::args::RawBody::Json(s) => s.clone(),
crate::args::RawBody::Text(s) => s.clone(),
_ => String::new(),
};
let native =
prepare_native_trigger_args(service_name, db, w_id, &args.metadata.headers, body)
.await?;
(metadata, native)
} else {
(None, None)
};
#[cfg(not(feature = "native_trigger"))]
let trigger_metadata: Option<TriggerMetadata> = None;
#[cfg(not(feature = "native_trigger"))]
let native_args: Option<windmill_queue::PushArgsOwned> = None;
let args = if let Some(prepared) = native_args {
prepared
} else {
args.to_args_from_runnable(
&authed,
&db,
&w_id,
runnable_id,
run_query.skip_preprocessor,
)
.await?
};
Ok(WebhookRun::Run(args, trigger_metadata))
}
#[derive(Deserialize)]
pub struct WorkflowAsCodeQuery {
pub skip_update: Option<bool>,
}
pub async fn run_workflow_as_code(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id, entrypoint)): Path<(String, Uuid, String)>,
Query(run_query): Query<RunJobQuery>,
Query(wkflow_query): Query<WorkflowAsCodeQuery>,
Json(task): Json<WorkflowTask>,
) -> error::Result<(StatusCode, String)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let mut extra = HashMap::new();
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?;
// A job running its own task is its runtime, not a new run: a restricted job token keeps
// it (`is_own_job_runtime_route`), and the task inherits that job's restriction at push.
if authed.job_id != Some(job_id) {
check_scopes(&authed, || format!("jobs:run"))?;
}
// The task becomes a child of `job_id`, runs its code and writes into its flow status, so
// only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it.
if authed.job_id != Some(job_id) && !authed.is_admin {
return Err(error::Error::PermissionDenied(format!(
"only job {job_id}'s own WM_TOKEN can run its workflow tasks"
)));
}
if !is_valid_entrypoint_name(&entrypoint) {
return Err(error::Error::BadRequest(format!(
"Invalid entrypoint {entrypoint:?}: must match ^[A-Za-z_][A-Za-z0-9_]*$ \
(letters, digits and underscores, not starting with a digit)"
)));
}
let mut i = 1;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let job = GetQuery::new()
.without_logs()
.fetch_queued(&db, &job_id, &w_id)
.await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let job = not_found_if_none(job, "Queued Job", &job_id.to_string())?;
let JobExtended { inner: job, raw_code, raw_lock, .. } = job;
let (_debouncing_settings, concurrency_settings) =
windmill_common::runnable_settings::prefetch_cached_from_handle(
job.runnable_settings_handle,
&db,
)
.await?;
// A task re-runs a preview with the preview's modules: the ones `push` stored for it, never
// the task's args. Read on their own, as `fetch_queued` swaps oversized args for a placeholder.
let preview_modules = if job.job_kind == JobKind::Preview {
sqlx::query_scalar::<_, Option<sqlx::types::Json<HashMap<String, ScriptModule>>>>(
"SELECT args->'_MODULES' FROM v2_job WHERE id = $1",
)
.bind(job.id)
.fetch_one(&db)
.await?
.map(|modules| modules.0)
} else {
None
};
let (job_payload, tag, _delete_after_use, _delete_after_secs, timeout, on_behalf_of) =
match job.job_kind {
JobKind::Preview => (
JobPayload::Code(RawCode {
hash: None,
content: raw_code.unwrap_or_default(),
path: job.script_path,
language: job.language.unwrap_or_else(|| ScriptLang::Deno),
lock: raw_lock,
concurrency_settings: concurrency_settings
.maybe_fallback(
windmill_queue::custom_concurrency_key(&db, &job.id)
.await
.map_err(to_anyhow)?,
job.concurrent_limit,
job.concurrency_time_window_s,
)
.into(),
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
dedicated_worker: None,
// TODO(debouncing): enable for this mode
debouncing_settings: DebouncingSettings::default(),
modules: preview_modules,
tag: None,
}),
Some(job.tag.clone()),
None,
None,
run_query.timeout,
None,
),
JobKind::Script => {
let userdb_authed =
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
script_path_to_payload(
job.script_path(),
Some(userdb_authed),
db.clone(),
&w_id,
run_query.skip_preprocessor,
)
.await?
}
_ => return Err(anyhow::anyhow!("Not supported").into()),
};
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag).or(Some(job.tag));
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let (email, permissioned_as, push_authed, tx) =
if let Some(on_behalf_of) = on_behalf_of.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
job_payload,
args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
Some(job_id),
None,
job.root_job.or(Some(job_id)),
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
if !wkflow_query.skip_update.unwrap_or(false) {
sqlx::query!(
"INSERT INTO v2_job_status (id, workflow_as_code_status)
VALUES ($1, JSONB_SET('{}'::JSONB, array[$2], $3))
ON CONFLICT (id) DO UPDATE SET
workflow_as_code_status = JSONB_SET(
COALESCE(v2_job_status.workflow_as_code_status, '{}'::JSONB),
array[$2],
$3
)",
job_id,
uuid.to_string(),
serde_json::json!({ "scheduled_for": Utc::now(), "name": entrypoint }),
)
.execute(&mut *tx)
.await?;
} else {
tracing::info!("Skipping update of flow status for job {job_id} in workspace {w_id}");
sqlx::query!(
"INSERT INTO v2_job_status (id, workflow_as_code_status) VALUES ($1, '{}'::JSONB)
ON CONFLICT (id) DO NOTHING",
job_id,
)
.execute(&mut *tx)
.await?;
}
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
tx.commit().await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
}
Ok((StatusCode::CREATED, uuid.to_string()))
}
#[derive(Deserialize)]
pub struct WacInlineCheckpointPayload {
pub key: String,
pub result: serde_json::Value,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub duration_ms: Option<u64>,
}
#[derive(Serialize)]
pub struct WacInlineCheckpointResponse {
/// The normalized failure record, when the posted step failed. The SDK
/// raises from this rather than from its own copy, so the round that ran
/// the failing body and every replay of it read the same object.
#[serde(skip_serializing_if = "Option::is_none")]
pub failure: Option<serde_json::Value>,
}
/// Fast-path endpoint called by the WAC v2 SDKs to persist a single `step()`
/// checkpoint delta without unwinding the parent workflow subprocess.
///
/// Mirrors the worker-side `WacOutput::InlineCheckpoint` arm in
/// `bun_executor::handle_wac_v2_output` exactly — same `completed_steps`
/// entry, same `_step/<key>` timeline entry, same source-hash validation —
/// but does **not** touch `v2_job_queue`, because the parent subprocess is
/// still live and about to return the next chunk of script output.
///
/// Auth: requires the job's ephemeral token (the one the worker sets into
/// `WM_TOKEN` when spawning the subprocess), not just any workspace-scoped
/// `ApiAuthed`. Because WAC v2 replays steps from `completed_steps`, a forged
/// entry directly changes the workflow's observed return values — this is
/// execution state, not user-facing metadata. `OptJobAuthed.job_id` is set
/// only when the caller presents a JWT whose `job_id` claim matches the URL
/// path, so rejecting mismatches closes the workspace-wide privilege gap.
///
/// Any error here causes the SDK to fall back to raising `_StepSuspend`,
/// which then goes through the untouched worker-side path. Old SDKs that
/// never call this endpoint continue to work unchanged.
pub async fn wac_inline_checkpoint(
OptJobAuthed { authed: _, job_id: token_job_id }: OptJobAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(payload): Json<WacInlineCheckpointPayload>,
) -> error::Result<Json<WacInlineCheckpointResponse>> {
// Enforce ephemeral-job-token binding: the presented token must be the
// one issued to *this* specific job. Regular workspace API tokens don't
// have `job_id` populated in their JWT claims, so `token_job_id` is None
// for them — reject unconditionally.
if token_job_id != Some(job_id) {
return Err(error::Error::PermissionDenied(
"wac_inline_checkpoint requires the job's ephemeral token".to_string(),
));
}
// Look up the job's script hash for source-hash validation. We deliberately
// use a minimal query here rather than `fetch_queued(...)` — the latter
// pulls in many extra columns we don't need. Restrict to the job kinds
// that actually run user WAC v2 code (`script` and `preview`) so a
// forged/buggy caller can't write a bogus checkpoint onto a flow,
// dependency, or other job kind that shares the workspace.
let row: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT runnable_id FROM v2_job
WHERE id = $1 AND workspace_id = $2
AND kind IN ('script'::job_kind, 'preview'::job_kind)",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?;
let (runnable_id,) = row.ok_or_else(|| {
error::Error::NotFound(format!("WAC v2 job {job_id} not found in workspace {w_id}"))
})?;
let source_hash = runnable_id.map(|h| h.to_string());
let mut tx = db.begin().await?;
let failure = windmill_common::wac::persist_inline_checkpoint_delta(
&mut tx,
&job_id,
source_hash.as_deref(),
&payload.key,
payload.result,
payload.started_at.as_deref(),
payload.duration_ms,
)
.await?;
tx.commit().await?;
// Only failures come back: a successful step's result can be large, and the
// SDK already holds it.
Ok(Json(WacInlineCheckpointResponse { failure }))
}
lazy_static::lazy_static! {
static ref JOB_VIEW_AUDIT_LOGS: bool = std::env::var("JOB_VIEW_AUDIT_LOGS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(false);
static ref JOB_VIEW_CACHE: JobViewCache = JobViewCache::new(50000);
}
struct JobViewCache {
cache: Cache<String, std::time::Instant>,
}
impl JobViewCache {
fn new(items_capacity: usize) -> Self {
Self { cache: Cache::new(items_capacity) }
}
fn get_or_insert(&self, key: &str) -> Option<std::time::Instant> {
match self.cache.get(key) {
Some(t) if t < std::time::Instant::now() => {
self.cache.insert(
key.to_string(),
std::time::Instant::now() + std::time::Duration::from_secs(60),
);
None
}
v => {
self.cache.insert(
key.to_string(),
std::time::Instant::now() + std::time::Duration::from_secs(60),
);
v
}
}
}
}
async fn log_job_view(
db: &DB,
opt_authed: Option<&ApiAuthed>,
opt_token: Option<&str>,
w_id: &str,
job_id: &Uuid,
) -> error::Result<()> {
if *JOB_VIEW_AUDIT_LOGS {
let audit_author = match opt_authed {
Some(authed) => AuditAuthor::from(authed),
None => AuditAuthor {
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: opt_token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string()),
},
};
if JOB_VIEW_CACHE
.get_or_insert(&format!("{}_{}", job_id, audit_author.email))
.is_none()
{
audit_log(
db,
&audit_author,
"jobs.view",
ActionKind::Execute,
w_id,
Some(&job_id.to_string()),
None,
)
.await?;
};
}
Ok(())
}
pub async fn run_wait_result_job_by_path_get(
cross_site: CrossSiteGetGuard,
method: hyper::http::Method,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let script_path = script_path.to_path();
let runnable_id = cross_site.script_runnable(script_path)?;
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
if method == http::Method::HEAD {
return Ok(Json(serde_json::json!("")).into_response());
}
let payload_as_args = run_query.payload_as_args()?;
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
args.body = args::Body::HashMap(payload_as_args);
let args = args
.to_args_from_runnable(&db, &w_id, runnable_id, run_query.skip_preprocessor)
.await?;
check_queue_too_long(&db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?;
let user_db_with_authed =
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_authed) =
script_path_to_payload(
script_path,
Some(user_db_with_authed),
db.clone(),
&w_id,
run_query.skip_preprocessor,
)
.await?;
let tag = run_query.tag.clone().or(tag);
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
let (email, permissioned_as, push_authed, tx) =
if let Some(on_behalf_of) = on_behalf_authed.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
job_payload,
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await;
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
return wait_result;
}
pub async fn run_wait_result_flow_by_path_get(
method: hyper::http::Method,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let path = flow_path.to_path();
check_scopes(&authed, || format!("jobs:run:flows:{path}"))?;
if method == http::Method::HEAD {
return Ok(Json(serde_json::json!("")).into_response());
}
let payload_as_args = run_query.payload_as_args()?;
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
args.body = args::Body::HashMap(payload_as_args);
let args = args
.to_args_from_runnable(
&db,
&w_id,
RunnableId::from_flow_path(path),
run_query.skip_preprocessor,
)
.await?;
run_wait_result_flow_by_path_internal(db, run_query, flow_path, authed, user_db, args, w_id)
.await
}
pub async fn run_wait_result_script_by_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let path = script_path.to_path();
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_script_path(path),
run_query.skip_preprocessor,
)
.await?;
run_wait_result_script_by_path_internal(db, run_query, script_path, authed, user_db, w_id, args)
.await
}
pub async fn run_wait_result_script_by_path_internal(
db: sqlx::Pool<Postgres>,
run_query: RunJobQuery,
script_path: StripPath,
authed: ApiAuthed,
user_db: UserDB,
w_id: String,
args: PushArgsOwned,
) -> error::Result<Response> {
check_queue_too_long(&db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?;
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) =
script_path_to_payload(
script_path.to_path(),
Some(db_authed),
db.clone(),
&w_id,
run_query.skip_preprocessor,
)
.await?;
let tag = run_query.tag.clone().or(tag);
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
let (email, permissioned_as, push_authed, tx) =
if let Some(on_behalf_of) = on_behalf_of.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
job_payload,
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await;
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
return wait_result;
}
pub async fn run_wait_result_script_by_hash(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_script_hash(script_hash),
run_query.skip_preprocessor,
)
.await?;
check_queue_too_long(&db, run_query.queue_limit).await?;
let hash = script_hash.0;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let script_info = get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash)
.await?
.prefetch_cached(&db)
.await?;
let on_behalf_of = script_info.on_behalf_of(&w_id, &db).await?;
let ScriptHashInfo {
path,
tag,
mut cache_ttl,
mut cache_ignore_s3_path,
language,
dedicated_worker,
priority,
delete_after_use,
delete_after_secs,
timeout,
has_preprocessor,
labels,
job_token_scopes,
runnable_settings:
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
..
} = script_info;
if let Some(run_query_cache_ttl) = run_query.cache_ttl {
cache_ttl = Some(run_query_cache_ttl);
cache_ignore_s3_path = run_query.cache_ignore_s3_path;
}
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
let tag = run_query.tag.clone().or(tag);
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
(
&obo.email,
obo.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
&authed.email,
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
JobPayload::ScriptHash {
hash: ScriptHash(hash),
path: path,
concurrency_settings,
debouncing_settings,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
&& has_preprocessor.unwrap_or(false),
labels,
job_token_scopes,
},
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await;
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
return wait_result;
}
pub async fn run_wait_result_flow_by_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let path = flow_path.to_path();
check_scopes(&authed, || format!("jobs:run:flows:{path}"))?;
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_flow_path(path),
run_query.skip_preprocessor,
)
.await?;
run_wait_result_flow_by_path_internal(db, run_query, flow_path, authed, user_db, args, w_id)
.await
}
pub async fn stream_flow_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
method: hyper::http::Method,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_flow_path(flow_path.to_path()),
args,
run_query,
method == http::Method::GET,
)
.await
}
pub async fn stream_flow_by_version(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, version)): Path<(String, i64)>,
Query(run_query): Query<RunJobQuery>,
method: hyper::http::Method,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_flow_version(version),
args,
run_query,
method == http::Method::GET,
)
.await
}
pub async fn stream_script_by_path(
cross_site: CrossSiteGetGuard,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
method: hyper::http::Method,
args: RawWebhookArgs,
) -> error::Result<Response> {
let runnable_id = cross_site.script_runnable(script_path.to_path())?;
stream_job(
authed,
db,
user_db,
w_id,
runnable_id,
args,
run_query,
method == http::Method::GET,
)
.await
}
pub async fn stream_script_by_hash(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
Query(run_query): Query<RunJobQuery>,
method: hyper::http::Method,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_script_hash(script_hash),
args,
run_query,
method == http::Method::GET,
)
.await
}
pub async fn stream_job(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
runnable_id: RunnableId,
args: RawWebhookArgs,
run_query: RunJobQuery,
is_get: bool,
) -> error::Result<Response> {
if run_query.retry.is_some() {
return Err(error::Error::BadRequest(
"retry is only supported when running a script asynchronously".to_string(),
));
}
let args = if is_get {
let payload_as_args = run_query.payload_as_args()?;
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
args.body = args::Body::HashMap(payload_as_args);
let args = args
.to_args_from_runnable(&db, &w_id, runnable_id.clone(), run_query.skip_preprocessor)
.await?;
args
} else {
args.to_args_from_runnable(
&authed,
&db,
&w_id,
runnable_id.clone(),
run_query.skip_preprocessor,
)
.await?
};
let poll_delay_ms = run_query.poll_delay_ms;
let (uuid, early_return, has_failure_module) = match runnable_id {
RunnableId::ScriptId(ScriptId::ScriptPath(script_path))
| RunnableId::HubScript(script_path) => {
let (uuid, _, _) = push_script_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
user_db,
w_id.clone(),
StripPath(script_path),
run_query,
args,
None,
)
.await?;
(uuid, None, false)
}
RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => {
let (uuid, _, _) = run_job_by_hash_inner(
authed.clone(),
db.clone(),
user_db,
w_id.clone(),
script_hash,
run_query,
args,
None,
)
.await?;
(uuid, None, false)
}
RunnableId::FlowId(FlowId::FlowPath(flow_path)) => {
let (uuid, early_return, has_failure_module, _) = push_flow_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
user_db,
w_id.clone(),
StripPath(flow_path),
run_query,
args,
None,
)
.await?;
(uuid, early_return, has_failure_module)
}
RunnableId::FlowId(FlowId::FlowVersion(version)) => {
let (uuid, early_return, has_failure_module) = run_flow_by_version_inner(
authed.clone(),
db.clone(),
user_db,
w_id.clone(),
version,
run_query,
args,
None,
)
.await?;
(uuid, early_return, has_failure_module)
}
};
let opt_authed = Some(authed.clone());
let opt_tokened = OptTokened { token: None }; // ignored when authed is some
let (tx, rx) = tokio::sync::mpsc::channel(32);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
format!(
"data: {}\n\n",
serde_json::to_string(&x).unwrap_or_default()
)
});
start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
w_id,
uuid,
None,
None,
None,
None,
Some(true),
Some(true),
None,
None,
tx,
poll_delay_ms,
early_return,
has_failure_module,
false,
);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Ok(Response::builder()
.status(200)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(body)
.unwrap())
}
pub async fn run_wait_result_flow_by_path_internal(
db: sqlx::Pool<Postgres>,
run_query: RunJobQuery,
flow_path: StripPath,
authed: ApiAuthed,
user_db: UserDB,
args: PushArgsOwned,
w_id: String,
) -> error::Result<Response> {
check_queue_too_long(&db, run_query.queue_limit).await?;
let flow_path = flow_path.to_path();
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let flow_version_info =
get_latest_flow_version_info_for_path(Some(userdb_authed), &db, &w_id, &flow_path, true)
.await?;
run_flow_and_wait_result(
&authed,
&db,
user_db,
&w_id,
flow_path,
flow_version_info,
run_query,
args,
None,
)
.await
}
pub async fn run_wait_result_flow_by_version_get(
method: hyper::http::Method,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, version)): Path<(String, i64)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
if method == http::Method::HEAD {
return Ok(Json(serde_json::json!("")).into_response());
}
let payload_as_args = run_query.payload_as_args()?;
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
args.body = args::Body::HashMap(payload_as_args);
let args = args
.to_args_from_runnable(
&db,
&w_id,
RunnableId::from_flow_version(version),
run_query.skip_preprocessor,
)
.await?;
let flow_version_info =
get_flow_version_info_from_version(&db, version, &w_id, &flow_path).await?;
run_flow_and_wait_result(
&authed,
&db,
user_db,
&w_id,
&flow_path,
flow_version_info,
run_query,
args,
None,
)
.await
}
pub async fn run_wait_result_flow_by_version(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, version)): Path<(String, i64)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_flow_version(version),
run_query.skip_preprocessor,
)
.await?;
let flow_version_info =
get_flow_version_info_from_version(&db, version, &w_id, &flow_path).await?;
run_flow_and_wait_result(
&authed,
&db,
user_db,
&w_id,
&flow_path,
flow_version_info,
run_query,
args,
None,
)
.await
}
/// Whether request-supplied SQL from an operator may run. Operators can only run deployed
/// code, so a request their job token (`WM_TOKEN`) authenticates comes from code a
/// non-operator authored. The job must still be running, and the request must have the
/// shape `wmill.datatable()` sends (PostgreSQL against a `datatable://` database), so a
/// WM_TOKEN that leaked into job logs cannot be replayed to reach another target while the
/// job lives, in particular DuckDB, which runs in-process in the worker.
///
/// What it does permit is any statement against the workspace's data tables, writes and DDL
/// included: the helper's body is an unrestricted SQL template. What that reaches is the
/// operator's own data table role — the preview job is permissioned as them, so the executor
/// resolves it under their tenancy like any other job.
///
/// The database argument is only half the target: the executor honors a `-- database`
/// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused.
/// Check them against the code the executor runs rather than the request's `content`, which
/// is not the same string once a `WM_INTERNAL_DB` marker expands.
async fn operator_may_run_datatable_query(
db: &DB,
w_id: &str,
job_id: Option<Uuid>,
language: Option<&ScriptLang>,
content: &str,
args: Option<&HashMap<String, Box<JsonRawValue>>>,
) -> error::Result<bool> {
let Some(job_id) = job_id else {
return Ok(false);
};
if language != Some(&ScriptLang::Postgresql) {
return Ok(false);
}
// Parse the directives out of the code the executor actually runs: it expands a
// `WM_INTERNAL_DB` marker first, and a directive can be embedded in the expansion.
// An expansion that overrides the language would run something other than the SQL the
// language check above cleared, so it is refused along with a malformed marker.
let executed =
match query_builders::try_expand_internal_db_query(content, &ScriptLang::Postgresql) {
Some(Ok(expanded)) if expanded.language_override.is_none() => Cow::Owned(expanded.code),
Some(_) => return Ok(false),
None => Cow::Borrowed(content),
};
if windmill_parser_sql::parse_db_resource(&executed).is_some()
|| !matches!(windmill_parser_sql::parse_s3_mode(&executed), Ok(None))
{
return Ok(false);
}
let targets_datatable = args
.and_then(|args| args.get("database"))
.and_then(|database| serde_json::from_str::<String>(database.get()).ok())
.is_some_and(|database| database.starts_with("datatable://"));
if !targets_datatable {
return Ok(false);
}
Ok(sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
job_id,
w_id
)
.fetch_optional(db)
.await?
.unwrap_or(false))
}
/// Whether the caller's role may run code of its own: previews, dependency jobs, inline dynamic
/// selects, resolving jobs.
async fn role_can_code(db: &DB, w_id: &str, authed: &ApiAuthed) -> error::Result<bool> {
Ok(
windmill_common::roles::caller_capabilities(db, w_id, authed)
.await?
.code,
)
}
/// The refusal a member whose role cannot code gets from a preview route. Inside a job the caller
/// never ran a preview themselves, so name the one thing the job's token may do.
fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
let reason = if job_id.is_some() {
"Your role cannot run preview jobs in this workspace: from inside a job, a member \
whose role cannot code may only run a wmill.datatable() query while that job is running"
} else {
"Your role cannot run preview jobs in this workspace"
};
// 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the
// flow editor builders open reaches this route.
error::Error::PermissionDenied(reason.to_string())
}
/// The identity a preview asks to run as, or `None` when it runs as the caller.
///
/// A preview is request-supplied code, so running it as someone else is impersonation, not
/// "running their runnable": only those who may set a runnable's `on_behalf_of` on deploy
/// (admins and `wm_deployers`) may ask for it. Naming oneself is always allowed. Operators are
/// refused even in `wm_deployers`: the datatable-query exemption lets them reach this route
/// without being able to deploy anything.
pub(crate) async fn preview_on_behalf_of(
authed: &ApiAuthed,
requested: Option<&str>,
w_id: &str,
db: &DB,
) -> error::Result<Option<OnBehalfOf>> {
let Some(requested) = requested.filter(|s| !s.is_empty()) else {
return Ok(None);
};
let own = username_to_permissioned_as(&authed.username);
if requested == own || requested == authed.email {
return Ok(None);
}
if !role_can_code(db, w_id, authed).await?
|| !windmill_common::can_preserve_on_behalf_of(authed)
{
return Err(Error::PermissionDenied(
"Only admins and members of wm_deployers can run a preview on behalf of another user"
.to_string(),
));
}
let permissioned_as =
windmill_common::resolve_on_behalf_of(None, Some(requested), true, authed, w_id, db)
.await?
.unwrap_or(own.clone());
if permissioned_as == own {
return Ok(None);
}
let email =
windmill_common::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db)
.await?;
Ok(Some(OnBehalfOf { email, permissioned_as }))
}
/// Audits the impersonation and opens the job's own logs with it, so whoever reads the run sees
/// who it ran as and who asked, not only the identity columns.
pub(crate) async fn audit_preview_on_behalf_of(
tx: &mut Transaction<'_, Postgres>,
authed: &ApiAuthed,
w_id: &str,
job_id: Uuid,
path: Option<&str>,
obo: &OnBehalfOf,
) -> error::Result<()> {
let mut params = HashMap::from([("on_behalf_of", obo.permissioned_as.as_str())]);
if let Some(path) = path {
params.insert("path", path);
}
audit_log(
&mut **tx,
authed,
"jobs.run.preview_on_behalf_of",
ActionKind::Execute,
w_id,
Some(&job_id.to_string()),
Some(params),
)
.await?;
sqlx::query!(
"INSERT INTO job_logs (logs, job_id, workspace_id) VALUES ($1, $2, $3) ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)",
format!(
"Preview run on behalf of {} ({}), requested by {}\n",
obo.permissioned_as, obo.email, authed.email
),
job_id,
w_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
/// The push identity and isolation level for a preview: the caller's own, or the requested
/// `on_behalf_of`'s, pushed through the root pool like a deployed `on_behalf_of` run.
fn preview_push_identity(
authed: &ApiAuthed,
user_db: &UserDB,
db: &DB,
obo: Option<&OnBehalfOf>,
) -> (
String,
String,
Option<windmill_common::db::Authed>,
PushIsolationLevel<'static>,
) {
match obo {
Some(obo) => (
obo.email.clone(),
obo.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
),
None => (
authed.email.clone(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
),
}
}
async fn run_preview_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
Json(preview): Json<Preview>,
) -> error::Result<(StatusCode, String)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
if !role_can_code(&db, &w_id, &authed).await? {
// A deferred run would outlive the running job the exemption keys off.
if run_query.get_scheduled_for(&db).await?.is_some()
|| !operator_may_run_datatable_query(
&db,
&w_id,
authed.job_id,
preview.language.as_ref(),
preview.content.as_deref().unwrap_or_default(),
preview.args.as_ref(),
)
.await?
{
return Err(operator_preview_refusal(authed.job_id));
}
}
// Preview runs arbitrary, request-supplied code. require_path_read_access_for_preview
// only checks folder/namespace *read* access (and is a no-op when path is null), so a
// token scoped to a specific script/flow could otherwise escape its scope and run any
// code. Require the broad jobs:run scope, like other arbitrary-execution endpoints.
check_scopes(&authed, || format!("jobs:run"))?;
require_path_read_access_for_preview(&authed, &preview.path)?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
let obo = preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, tx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let audited_path = preview.path.clone();
// A job token's end user is whoever ran the calling job, who never consented to that job's
// code reading data as them: only a direct caller, reading as themselves, may ask for it.
let viewer_data_access = authed.job_id.is_none() && preview.viewer_data_access.unwrap_or(false);
let preview_args = preview.args.unwrap_or_default();
let mut extra = HashMap::new();
if let Some(fp) = &preview.flow_path {
extra.insert("_FLOW_PATH".to_string(), to_raw_value(fp));
}
if let Some(ref temp_script_refs) = preview.temp_script_refs {
extra.insert(
"_TEMP_SCRIPT_REFS".to_string(),
to_raw_value(temp_script_refs),
);
}
let extra = if extra.is_empty() { None } else { Some(extra) };
let push_args = PushArgs { extra, args: &preview_args };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
match preview.kind {
Some(PreviewKind::Identity) => JobPayload::Identity,
Some(PreviewKind::Noop) => JobPayload::Noop,
_ => JobPayload::Code(RawCode {
hash: preview
.script_hash
.as_ref()
.and_then(|s| windmill_common::scripts::to_i64(s).ok()),
content: preview.content.unwrap_or_default(),
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
concurrency_settings: ConcurrencySettingsWithCustom::default(), // TODO(gbouv): once I find out how to store limits in the content of a script, should be easy to plug limits here
debouncing_settings: DebouncingSettings::default(), // TODO(pyra): same as for concurrency limits.
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
modules: preview.modules,
tag: None,
}),
},
push_args,
authed.display_username(),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
None,
None,
None,
run_query.job_id,
false,
false,
None,
true,
tag,
run_query.timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
let mut tx = tx;
if let Some(obo) = obo.as_ref() {
audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path.as_deref(), obo)
.await?;
}
if viewer_data_access {
sqlx::query!(
"UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1",
uuid
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
#[cfg(feature = "run_inline")]
async fn run_inline_preview_script(
OptJobAuthed { authed, job_id }: OptJobAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(preview): Json<PreviewInline>,
) -> error::Result<Response> {
// Same arbitrary-code class as run_preview_script, and every worker and standalone
// server exposes this route, so an operator is refused on the same terms. A
// narrowly-scoped token must not escape its scope through inline preview either.
if !role_can_code(&db, &w_id, &authed).await?
&& !operator_may_run_datatable_query(
&db,
&w_id,
job_id,
Some(&preview.language),
&preview.content,
preview.args.as_ref(),
)
.await?
{
return Err(operator_preview_refusal(job_id));
}
check_scopes(&authed, || format!("jobs:run"))?;
windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?;
if let Some(job_id) = job_id {
register_potential_assets_on_inline_execution(job_id, &w_id, &preview);
}
let (end_user_email, viewer_data_access) =
run_inline_preview_end_user(&db, &w_id, &authed).await?;
let utils = get_worker_internal_server_inline_utils()?;
let result = utils.run_inline_preview_script.as_ref()(RunInlinePreviewScriptFnParams {
content: preview.content,
args: preview.args,
workspace_id: w_id.clone(),
base_internal_url: utils.base_internal_url.clone(),
killpill_rx: utils.killpill_rx.resubscribe(),
created_by: authed.display_username().to_string(),
permissioned_as: username_to_permissioned_as(&authed.username),
permissioned_as_email: authed.email.clone(),
end_user_email,
viewer_data_access,
lang: preview.language,
worker_name: "".to_string(),
worker_dir: "".to_string(),
client: AuthedClient {
base_internal_url: utils.base_internal_url.clone(),
force_client: None,
token,
workspace: w_id,
},
conn: windmill_common::worker::Connection::Sql(db),
})
.await?;
Ok(Json(to_raw_value(&result)).into_response())
}
#[cfg(not(feature = "run_inline"))]
async fn run_inline_preview_script() -> error::Result<Response> {
Err(error::Error::InternalErr(
"inline preview requires the run_inline feature on the worker".to_string(),
))
}
#[cfg(feature = "run_inline")]
async fn run_inline_script_by_path(
OptJobAuthed { authed, .. }: OptJobAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Json(body): Json<InlineScriptArgs>,
) -> error::Result<Response> {
let script_path_str = script_path.to_path();
check_scopes(&authed, || format!("jobs:run:scripts:{script_path_str}"))?;
run_inline_script_inner(
authed,
token,
db,
w_id,
// Resolved to a version by `run_inline_script_inner`.
InlineScriptTarget::Path { path: script_path.to_path().to_string(), hash: 0 },
body.args,
Some(user_db),
)
.await
}
#[cfg(not(feature = "run_inline"))]
async fn run_inline_script_by_path() -> error::Result<Response> {
Err(error::Error::InternalErr(
"inline script by path requires the worker feature".to_string(),
))
}
#[cfg(feature = "run_inline")]
async fn run_inline_script_by_hash(
OptJobAuthed { authed, .. }: OptJobAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
Json(body): Json<InlineScriptArgs>,
) -> error::Result<Response> {
// Resolve the script path from the hash and check scopes properly
let hash = script_hash.0;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let ScriptHashInfo { path, .. } =
get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash)
.await?
.prefetch_cached(&db)
.await?;
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
run_inline_script_inner(
authed,
token,
db,
w_id,
InlineScriptTarget::Hash(hash),
body.args,
Some(user_db),
)
.await
}
#[cfg(not(feature = "run_inline"))]
async fn run_inline_script_by_hash() -> error::Result<Response> {
Err(error::Error::InternalErr(
"inline script by hash requires the worker feature".to_string(),
))
}
#[cfg(feature = "run_inline")]
async fn run_inline_script_inner(
authed: ApiAuthed,
token: String,
db: DB,
w_id: String,
target: InlineScriptTarget,
args: Option<HashMap<String, Box<JsonRawValue>>>,
user_db: Option<UserDB>,
) -> error::Result<Response> {
windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?;
// An inline run executes with the caller's own token, so it cannot honour a script's
// `job_token_scopes`: such a script only runs as a job. A path is resolved here, once, and
// the version checked is the version run.
let (target, restricted) = match target {
InlineScriptTarget::Path { path, .. } => {
let authed_ref = authed.to_authed_ref();
let info = get_latest_deployed_hash_for_path(
user_db
.as_ref()
.map(|db| UserDbWithAuthed { db: db.clone(), authed: &authed_ref }),
db.clone(),
&w_id,
&path,
)
.await?;
let restricted = info.job_token_scopes.is_some();
(
InlineScriptTarget::Path { path, hash: info.hash },
restricted,
)
}
InlineScriptTarget::Hash(hash) => {
let restricted = windmill_common::get_script_info_for_hash(None, &db, &w_id, hash)
.await?
.job_token_scopes
.is_some();
(InlineScriptTarget::Hash(hash), restricted)
}
};
if restricted {
return Err(Error::BadRequest(
"This script restricts its job token (job_token_scopes) and cannot be run inline; \
run it as a job instead"
.to_string(),
));
}
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let utils = get_worker_internal_server_inline_utils()?;
let authed_owned: windmill_common::db::Authed = authed.clone().into();
let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams {
target,
args,
workspace_id: w_id.clone(),
base_internal_url: utils.base_internal_url.clone(),
killpill_rx: utils.killpill_rx.resubscribe(),
created_by: authed.display_username().to_string(),
permissioned_as: username_to_permissioned_as(&authed.username),
permissioned_as_email: authed.email.clone(),
end_user_email,
worker_name: "".to_string(),
worker_dir: "".to_string(),
client: AuthedClient {
base_internal_url: utils.base_internal_url.clone(),
force_client: None,
token,
workspace: w_id,
},
conn: windmill_common::worker::Connection::Sql(db),
user_db: user_db.map(|udb| (udb, authed_owned)),
})
.await?;
Ok(Json(to_raw_value(&result)).into_response())
}
#[cfg(feature = "run_inline")]
fn register_potential_assets_on_inline_execution(
job_id: Uuid,
w_id: &str,
preview: &PreviewInline,
) {
let assets = if preview.language == ScriptLang::DuckDb {
Some(windmill_parser_sql_asset::parse_assets(&preview.content).map(|a| a.assets))
} else if preview.language == ScriptLang::Postgresql {
let datatable = preview
.args
.as_ref()
.and_then(|args| args.get("database"))
.map(|v| v.get().trim_matches('"'))
.and_then(|dt| dt.strip_prefix("datatable://"))
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
// part of a name stored before names were restricted, so it stays.
.map(|dt| {
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
});
if let Some(datatable) = datatable {
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {
let schema = captures.get(1).map(|m| m.as_str().to_string());
let content = Some(re.replace(&preview.content, "").to_string());
(schema, content)
} else {
(None, None)
};
let content = content.as_deref().unwrap_or(&preview.content);
windmill_parser_sql_asset::parse_wmill_sdk_sql_assets(
AssetKind::DataTable,
datatable,
schema.as_deref(),
content,
)
.transpose()
} else {
None
}
} else {
None
};
match assets {
Some(Ok(assets)) => {
for asset in assets {
let columns = asset.columns.as_ref().map(|cols| {
cols.iter()
.map(|(col_name, col_access_type)| {
(
col_name.clone(),
windmill_common::assets::asset_access_type_from_parser(
*col_access_type,
),
)
})
.collect()
});
register_runtime_asset(InsertRuntimeAssetParams {
access_type: asset
.access_type
.map(windmill_common::assets::asset_access_type_from_parser),
asset_kind: windmill_common::assets::asset_kind_from_parser(asset.kind),
asset_path: asset.path,
columns,
job_id,
workspace_id: w_id.to_string(),
created_at: None,
});
}
}
_ => {}
}
}
async fn run_wait_result_preview_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
Json(preview): Json<Preview>,
) -> error::Result<Response> {
let (_status_code, uuid) = run_preview_script(
authed.clone(),
Extension(db.clone()),
Extension(user_db.clone()),
Path(w_id.clone()),
Query(run_query.clone()),
Json(preview),
)
.await?;
let uuid = uuid
.parse::<Uuid>()
.map_err(|_| Error::BadRequest("Invalid UUID".to_string()))?;
let result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await;
return result;
}
async fn run_bundle_preview_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
mut multipart: axum::extract::Multipart,
) -> error::Result<(StatusCode, String)> {
if !role_can_code(&db, &w_id, &authed).await? {
return Err(operator_preview_refusal(None));
}
// Bundle preview runs arbitrary, request-supplied code; require the broad jobs:run
// scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
check_scopes(&authed, || format!("jobs:run"))?;
let mut job_id = None;
let mut tx = None;
let mut uploaded = false;
let mut is_tar = false;
let mut format = BundleFormat::Cjs;
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
let data = field.bytes().await;
let data = data.map_err(to_anyhow)?;
if name == "preview" {
let preview: Preview = serde_json::from_slice(&data).map_err(to_anyhow)?;
require_path_read_access_for_preview(&authed, &preview.path)?;
format = preview
.format
.and_then(|s| BundleFormat::from_string(&s))
.unwrap_or(BundleFormat::Cjs);
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
let obo =
preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, ltx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let audited_path = preview.path.clone();
let args = preview.args.unwrap_or_default();
// The bundle's runtime still resolves workspace-path imports
// (`/f/...`) via loader.bun.js, so pass through temp_script_refs the
// same way `run_preview_script` does — otherwise codebase previews
// silently fall back to deployed content for those imports.
let extra = preview.temp_script_refs.as_ref().map(|refs| {
let mut m = HashMap::new();
m.insert("_TEMP_SCRIPT_REFS".to_string(), to_raw_value(refs));
m
});
let push_args = PushArgs { extra, args: &args };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
is_tar = match preview.kind {
Some(PreviewKind::Tarbundle) => true,
_ => false,
};
// tracing::info!("is_tar 1: {is_tar}");
// hmap.insert("")
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, ntx) = push(
&db,
ltx,
&w_id,
JobPayload::Code(RawCode {
hash: Some(windmill_common::scripts::codebase_to_hash(
is_tar,
format == BundleFormat::Esm,
)),
content: preview.content.unwrap_or_default(),
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
modules: None,
tag: None,
}),
push_args,
authed.display_username(),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
None,
None,
None,
run_query.job_id,
false,
false,
None,
true,
tag,
run_query.timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
let mut ntx = ntx;
if let Some(obo) = obo.as_ref() {
audit_preview_on_behalf_of(
&mut ntx,
&authed,
&w_id,
uuid,
audited_path.as_deref(),
obo,
)
.await?;
}
job_id = Some(uuid);
tx = Some(ntx);
}
if name == "file" {
let mut id = job_id
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"script need to be passed first in the multipart upload".to_string(),
)
})?
.to_string();
// tracing::info!("is_tar 2: {is_tar}");
if format == BundleFormat::Esm {
id = format!("{}.esm", id);
}
if is_tar {
id = format!("{}.tar", id);
}
uploaded = true;
let path = windmill_object_store::bundle(&w_id, &id);
upload_artifact_to_store(
&path,
data,
&windmill_common::worker::ROOT_STANDALONE_BUNDLE_DIR,
)
.await?;
}
// println!("Length of `{}` is {} bytes", name, data.len());
}
if !uploaded {
return Err(Error::BadRequest("No file uploaded".to_string()));
}
if job_id.is_none() {
return Err(Error::BadRequest(
"No script found in the uploaded file".to_string(),
));
}
tx.unwrap().commit().await?;
Ok((StatusCode::CREATED, job_id.unwrap().to_string()))
}
#[derive(Deserialize, Debug)]
pub struct RunDependenciesRequest {
pub raw_scripts: Vec<RawScriptForDependencies>,
pub entrypoint: String,
#[serde(default)]
pub raw_workspace_dependencies: Option<RawWorkspaceDependencies>,
#[serde(default)]
pub raw_deps: Option<String>,
/// Map of script path -> content hash for resolving imports from temp storage.
/// Used by CLI to provide local script content during lock generation.
#[serde(default)]
pub temp_script_refs: Option<HashMap<String, String>>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct RawScriptForDependencies {
pub script_path: String,
pub raw_code: Option<String>,
pub language: ScriptLang,
}
#[derive(Serialize)]
pub struct RunDependenciesResponse {
pub dependencies: String,
}
/// The path comes from the request body: one carrying scope syntax (`,`, `:`, `*`) could
/// put a granted path beside an ungranted one, so it needs a grant covering every path.
fn check_dependencies_scope(authed: &ApiAuthed, path: &str) -> error::Result<()> {
if windmill_common::auth::is_scope_literal_path(path) {
check_scopes(authed, || format!("jobs:run:dependencies:{path}"))
} else {
check_scopes(authed, || "jobs:run:dependencies".to_string())
}
}
async fn push_dependencies_job(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
req: RunDependenciesRequest,
) -> error::Result<Uuid> {
check_dependencies_scope(authed, &req.entrypoint)?;
if !role_can_code(db, w_id, authed).await? {
return Err(error::Error::PermissionDenied(
"Your role cannot run dependencies jobs in this workspace".to_string(),
));
}
if req.raw_deps.is_some() {
return Err(error::Error::MigrationNeeded {
feature: "cli is outdated".into(),
version: MIN_VERSION_WORKSPACE_DEPENDENCIES.to_owned(),
guide_url: Url::from_str(
"https://www.windmill.dev/docs/core_concepts/workspace_dependencies/migration",
)?,
});
}
// Check if workers support workspace dependencies feature
if req.raw_workspace_dependencies.is_some() {
windmill_common::workspace_dependencies::min_version_supports_v0_workspace_dependencies()
.await?;
}
if req.raw_scripts.len() != 1 || req.raw_scripts[0].script_path != req.entrypoint {
return Err(error::Error::internal_err(
"For now only a single raw script can be passed to this endpoint, and the entrypoint should be set to the script path".to_string(),
));
}
let RawScriptForDependencies { script_path, raw_code, language } = req.raw_scripts[0].clone();
let mut hm = HashMap::new();
req.raw_workspace_dependencies
.map(|v| hm.insert("raw_workspace_dependencies".to_owned(), to_raw_value(&v)));
req.temp_script_refs
.map(|v| hm.insert("temp_script_refs".to_owned(), to_raw_value(&v)));
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
let (uuid, tx) = push(
db,
PushIsolationLevel::IsolatedRoot(db.clone()),
w_id,
JobPayload::RawScriptDependencies {
script_path,
content: raw_code.unwrap_or_default(),
language,
},
PushArgs { extra: Some(hm), args: &HashMap::new() },
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&authed.clone().into()),
false,
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
Ok(uuid)
}
async fn run_dependencies_job(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<RunDependenciesRequest>,
) -> error::Result<Response> {
let uuid = push_dependencies_job(&authed, &db, &w_id, req).await?;
run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await
}
async fn run_dependencies_job_async(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<RunDependenciesRequest>,
) -> error::Result<(StatusCode, String)> {
let uuid = push_dependencies_job(&authed, &db, &w_id, req).await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
#[derive(Deserialize)]
pub struct RunFlowDependenciesRequest {
pub path: String,
pub flow_value: FlowValue,
#[serde(default)]
pub raw_workspace_dependencies: Option<RawWorkspaceDependencies>,
#[serde(default)]
pub raw_deps: Option<HashMap<String, String>>,
#[serde(default)]
pub temp_script_refs: Option<HashMap<String, String>>,
}
#[derive(Serialize)]
pub struct RunFlowDependenciesResponse {
pub dependencies: String,
}
async fn push_flow_dependencies_job(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
req: RunFlowDependenciesRequest,
) -> error::Result<Uuid> {
check_dependencies_scope(authed, &req.path)?;
let flows_level = check_operator_can_build(
db,
w_id,
authed,
BuilderKind::Flows,
"run dependencies jobs",
)
.await?;
// The dependency job locks whatever inline code this request carries, on a worker. A
// composition-only flow has none, so validating here costs a builder nothing and keeps the
// lock step from becoming the way to run code the write path refuses.
if flows_level == AuthorLevel::Compose {
validate_operator_composed_flow(&req.flow_value, &None, authed, db, user_db, w_id).await?;
// The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages,
// lock error) even with `skip_flow_update`, so a builder must be able to write there.
crate::drafts::require_can_write_path(
authed,
db,
user_db,
w_id,
windmill_common::user_drafts::UserDraftItemKind::Flow,
&req.path,
)
.await
.map_err(|e| match e {
error::Error::NotAuthorized(msg) => error::Error::PermissionDenied(msg),
e => e,
})?;
}
if req.raw_deps.is_some() {
return Err(error::Error::MigrationNeeded {
feature: "cli is outdated".into(),
version: MIN_VERSION_WORKSPACE_DEPENDENCIES.to_owned(),
guide_url: Url::from_str(
"https://www.windmill.dev/docs/core_concepts/workspace_dependencies/migration",
)?,
});
}
// Check if workers support workspace dependencies feature
if req.raw_workspace_dependencies.is_some() {
windmill_common::workspace_dependencies::min_version_supports_v0_workspace_dependencies()
.await?;
}
let mut args_map = HashMap::from([("skip_flow_update".to_string(), to_raw_value(&true))]);
req.raw_workspace_dependencies
.map(|v| args_map.insert("raw_workspace_dependencies".to_string(), to_raw_value(&v)));
req.temp_script_refs
.map(|v| args_map.insert("temp_script_refs".to_string(), to_raw_value(&v)));
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
let (uuid, tx) = push(
db,
PushIsolationLevel::IsolatedRoot(db.clone()),
w_id,
JobPayload::RawFlowDependencies { path: req.path, flow_value: req.flow_value },
PushArgs::from(&args_map),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&authed.clone().into()),
false,
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
Ok(uuid)
}
async fn run_flow_dependencies_job(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<RunFlowDependenciesRequest>,
) -> error::Result<Response> {
let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?;
run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await
}
async fn run_flow_dependencies_job_async(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<RunFlowDependenciesRequest>,
) -> error::Result<(StatusCode, String)> {
let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
#[derive(Deserialize)]
struct BatchRawScript {
content: String,
language: Option<ScriptLang>,
lock: Option<String>,
}
#[derive(Deserialize)]
struct BatchInfo {
kind: String,
flow_value: Option<FlowValue>,
path: Option<String>,
rawscript: Option<BatchRawScript>,
tag: Option<String>,
}
#[tracing::instrument(level = "trace", skip_all)]
async fn add_batch_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, n)): Path<(String, i32)>,
Json(batch_info): Json<BatchInfo>,
) -> error::JsonResult<Vec<Uuid>> {
require_super_admin(&db, &authed).await?;
let mut job_token_scopes: Option<Vec<String>> = None;
let (
hash,
path,
job_kind,
language,
dedicated_worker,
ScriptRunnableSettingsInline {
concurrency_settings:
ConcurrencySettings {
concurrent_limit,
concurrency_time_window_s,
concurrency_key: custom_concurrency_key,
},
..
},
timeout,
raw_code,
raw_lock,
raw_flow,
flow_status,
) = match batch_info.kind.as_str() {
"script" => {
if let Some(path) = batch_info.path {
let db_authed =
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let ScriptHashInfo {
hash: script_hash,
language,
dedicated_worker,
timeout,
runnable_settings,
job_token_scopes: script_job_token_scopes,
.. // TODO: consider on_behalf_of_email and created_by for batch jobs
} = get_latest_deployed_hash_for_path(Some(db_authed), db.clone(), &w_id, &path)
.await?
.prefetch_cached(&db)
.await?;
job_token_scopes = script_job_token_scopes;
(
Some(script_hash),
Some(path),
JobKind::Script,
Some(language),
dedicated_worker,
runnable_settings,
timeout,
None,
None,
None,
None,
)
} else {
Err(anyhow::anyhow!(
"Path is required if no value is not provided"
))?
}
}
"rawscript" => {
if let Some(rawscript) = batch_info.rawscript {
(
None,
None,
JobKind::Preview,
rawscript.language,
None,
Default::default(),
None,
Some(rawscript.content),
rawscript.lock,
None,
None,
)
} else {
Err(anyhow::anyhow!(
"rawscript is required for `rawscript` kind"
))?
}
}
"flow" => {
let (mut value, job_kind, path) = if let Some(value) = batch_info.flow_value {
(value, JobKind::FlowPreview, None)
} else if let Some(path) = batch_info.path {
let mut tx = user_db.clone().begin(&authed).await?;
let value_json = sqlx::query!(
"SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json<Box<RawValue>>\", flow.job_token_scopes FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
LEFT JOIN flow_version_lite
ON flow_version_lite.id = flow_version.id
WHERE flow.path = $1 AND flow.workspace_id = $2 LIMIT 1",
&path, &w_id
)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::internal_err(format!("not found flow at path {:?}", path)))?;
job_token_scopes = value_json.job_token_scopes;
let value =
serde_json::from_str::<FlowValue>(value_json.value.get()).map_err(|err| {
Error::internal_err(format!(
"could not convert json to flow for {path}: {err:?}"
))
})?;
(value, JobKind::Flow, Some(path))
} else {
Err(anyhow::anyhow!(
"Path is required if no value is not provided"
))?
};
if windmill_common::scopes::validate_flow_step_job_token_scopes(&value)? {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
add_virtual_items_if_necessary(&mut value.modules);
let flow_status = FlowStatus::new(&value);
(
None, // script_hash
path, // script_path
job_kind, // job_kind
None, // language
None, // dedicated_worker
ScriptRunnableSettingsInline {
concurrency_settings: value.concurrency_settings.clone(),
..Default::default()
},
None, // timeout
None, // raw_code
None, // raw_lock
Some(value), // raw_flow
Some(flow_status), // flow_status
)
}
"noop" => (
None,
None,
JobKind::Noop,
None,
None,
Default::default(),
None,
None,
None,
None,
None,
),
_ => {
return Err(error::Error::BadRequest(format!(
"Invalid batch kind: {}",
batch_info.kind
)))
}
};
let language = language.unwrap_or(ScriptLang::Deno);
let tag = match (dedicated_worker, batch_info.tag) {
// Same rule as `push`: a dedicated worker would run it with its own unscoped token.
(Some(true), _) if path.is_some() && job_token_scopes.is_none() => {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
}
(None, Some(tag)) => {
windmill_queue::tags::apply_dev_workspace_suffix(tag, &w_id, &db).await?
}
_ => {
windmill_queue::tags::apply_dev_workspace_suffix(
language.as_str().to_string(),
&w_id,
&db,
)
.await?
}
};
let mut tx = user_db.begin(&authed).await?;
let uuids = sqlx::query_scalar!(
r#"WITH uuid_table as (
select gen_random_uuid() as uuid from generate_series(1, $16)
)
INSERT INTO v2_job
(id, workspace_id, raw_code, raw_lock, raw_flow, tag, runnable_id, runnable_path, kind,
script_lang, created_by, permissioned_as, permissioned_as_email, concurrent_limit,
concurrency_time_window_s, timeout, args)
(SELECT uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
('{ "uuid": "' || uuid || '" }')::jsonb FROM uuid_table)
RETURNING id AS "id!""#,
w_id,
raw_code,
raw_lock,
raw_flow.map(sqlx::types::Json) as Option<sqlx::types::Json<FlowValue>>,
tag,
hash,
path,
job_kind.clone() as JobKind,
language as ScriptLang,
authed.username,
username_to_permissioned_as(&authed.username),
authed.email,
concurrent_limit,
concurrency_time_window_s,
timeout,
n,
)
.fetch_all(&mut *tx)
.await?;
let uuids = sqlx::query_scalar!(
r#"WITH uuid_table as (
select unnest($4::uuid[]) as uuid
)
INSERT INTO v2_job_queue
(id, workspace_id, scheduled_for, tag)
(SELECT uuid, $1, $2, $3 FROM uuid_table)
RETURNING id"#,
w_id,
Utc::now(),
tag,
&uuids
)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO v2_job_runtime (id, ping) SELECT unnest($1::uuid[]), null",
&uuids,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes)
SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9",
&uuids,
authed.email,
authed.username,
authed.is_admin,
authed.is_operator,
&[],
&[],
w_id,
job_token_scopes.as_deref() as Option<&[String]>,
)
.execute(&mut *tx)
.await?;
if let Some(flow_status) = flow_status {
sqlx::query!(
"INSERT INTO v2_job_status (id, flow_status)
SELECT unnest($1::uuid[]), $2",
&uuids,
sqlx::types::Json(flow_status) as sqlx::types::Json<FlowStatus>
)
.execute(&mut *tx)
.await?;
}
if let Some(custom_concurrency_key) = custom_concurrency_key {
sqlx::query!(
"INSERT INTO concurrency_counter(concurrency_id, job_uuids)
VALUES ($1, '{}'::jsonb)",
&custom_concurrency_key
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO concurrency_key (job_id, key) SELECT id, $1 FROM unnest($2::uuid[]) as id",
custom_concurrency_key,
&uuids
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(uuids))
}
async fn run_preview_flow_job(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
Json(raw_flow): Json<PreviewFlow>,
) -> error::Result<(StatusCode, String)> {
let flows_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "run preview jobs")
.await?;
// Flow preview runs an arbitrary, request-supplied flow definition; require the broad
// jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
check_scopes(&authed, || format!("jobs:run"))?;
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
// Step restrictions apply to a preview as they do to a deployed run, so they are checked
// the same way: an invalid entry or an older worker would leave a step unrestricted.
if windmill_common::scopes::validate_flow_step_job_token_scopes(&raw_flow.value)? {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
// A builder must be able to test what it composes, but the submitted value is not the stored
// one: without this the preview is a way to run inline code the write path refuses.
if flows_level == AuthorLevel::Compose {
validate_operator_composed_flow(
&raw_flow.value,
&raw_flow.tag,
&authed,
&db,
&user_db,
&w_id,
)
.await?;
}
// Restarting copies the source runs' step results into the new run, and the queue resolves
// them with the service pool, so every run the request names must be readable as the caller.
let mut level = raw_flow.restarted_from.as_ref();
while let Some(r) = level {
require_job_update_read_access(&db, &user_db, &authed, &w_id, &r.flow_job_id, None).await?;
level = r.nested.as_deref();
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
let obo = preview_on_behalf_of(&authed, raw_flow.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, tx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
let flow_path = raw_flow.path.clone().unwrap_or_default();
let user_message = raw_flow
.args
.as_ref()
.and_then(|args| args.get("user_message"))
.cloned();
let mut flow_args = raw_flow.args.unwrap_or_default();
if let Some(ref temp_script_refs) = raw_flow.temp_script_refs {
flow_args.insert(
"_TEMP_SCRIPT_REFS".to_string(),
to_raw_value(temp_script_refs),
);
}
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed)
.await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
JobPayload::RawFlow {
value: raw_flow.value,
path: raw_flow.path,
restarted_from: raw_flow.restarted_from,
},
PushArgs::from(&flow_args),
authed.display_username(),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
None,
None,
None,
run_query.job_id,
false,
false,
None,
true,
tag,
None,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
if let Some(obo) = obo.as_ref() {
let audited_path = Some(flow_path.as_str()).filter(|p| !p.is_empty());
audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path, obo).await?;
}
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
// Handle conversation messages for chat-enabled flows
if chat_input_enabled {
handle_chat_conversation_messages(
&mut tx,
&authed,
&w_id,
&flow_path,
&run_query,
user_message.as_ref(),
uuid,
// Run from the editor's test panel: a trial, not a real conversation.
true,
&flow_args,
)
.await?;
}
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
async fn run_wait_result_preview_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
Json(raw_flow): Json<PreviewFlow>,
) -> error::Result<Response> {
let (_status_code, uuid) = run_preview_flow_job(
authed.clone(),
Extension(db.clone()),
Extension(user_db.clone()),
Path(w_id.clone()),
Query(run_query.clone()),
Json(raw_flow),
)
.await?;
let uuid = uuid
.parse::<Uuid>()
.map_err(|_| Error::BadRequest("Invalid UUID".to_string()))?;
let result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await;
return result;
}
async fn run_dynamic_select(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(run_query): Query<RunJobQuery>,
Json(mut request): Json<DynamicSelectRequest>,
) -> error::Result<Response> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
if matches!(
request.runnable_ref,
DynamicSelectRunnableRef::Inline { .. }
) && !role_can_code(&db, &w_id, &authed).await?
{
return Err(operator_preview_refusal(None));
}
if !is_valid_entrypoint_name(&request.entrypoint_function) {
return Err(error::Error::BadRequest(format!(
"Invalid entrypoint_function {:?}: must match \
^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \
not starting with a digit)",
request.entrypoint_function
)));
}
// The dyn-select code is the runnable's, not necessarily the caller's: a deployed flow
// has no deployed runnable for it, so it runs as a `JobKind::Preview` job, and the
// executors honor `_TEMP_SCRIPT_REFS` on every preview — it resolves the code's
// workspace imports from the temporary script store, which any workspace member can
// write. A caller of this route (an operator, for a deployed flow) only fills in that
// code's inputs; the function to call is taken from `entrypoint_function`, validated
// above and inserted server-side below, never from the args. So the reserved control
// keys are dropped here, in the spellings `push` would store them under. Nothing
// server-side puts them in `args`: the request has no `temp_script_refs` field, and the
// run form does not send them.
if let Some(args) = request.args.as_mut() {
sanitize_untrusted_args(args);
}
// Deployed scripts keep their normal deployed-run path (a `script` job, resolved below by
// push_script_job_by_path_into_queue). Deployed flows and inline snippets have no deployed
// runnable, so they run their dyn-select code as a `preview`; the flow carries its path and
// worker tag so its option-fetching job lines up with the script run.
let dynamic_input: DynamicInput;
let runnable_path: Option<String>;
let mut tag: Option<String> = None;
match request.runnable_ref {
DynamicSelectRunnableRef::Deployed { path, runnable_kind } => match runnable_kind {
RunnableKind::Script => {
let mut script_args = request.args.unwrap_or_default();
script_args.insert(
ENTRYPOINT_OVERRIDE.to_string(),
serde_json::value::to_raw_value(&request.entrypoint_function)?,
);
let push_args = PushArgsOwned { extra: None, args: script_args };
let (uuid, _, _) = push_script_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
user_db.clone(),
w_id.clone(),
StripPath(path),
run_query.clone(),
push_args,
None,
)
.await?;
return Ok((StatusCode::CREATED, uuid.to_string()).into_response());
}
RunnableKind::Flow => {
// Runs the deployed flow's dynamic-select code. Path-scoped so a token not
// scoped to this flow cannot trigger its code through dynamic select.
check_scopes(&authed, || format!("jobs:run:flows:{path}"))?;
let mut conn = user_db.clone().begin(&authed).await?;
// Read the flow's tag under RLS. This runs on every request (including the
// cache hit below), so it doubles as the access check and routes the
// option-fetching preview to the flow's worker group, matching the script branch.
let Some(flow_tag) = sqlx::query_scalar!(
"SELECT tag FROM flow WHERE workspace_id = $1 AND path = $2",
&w_id,
&path
)
.fetch_optional(&mut *conn)
.await?
else {
conn.commit().await?;
let exists = sqlx::query_scalar!(
"SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 LIMIT 1",
&w_id,
&path
)
.fetch_optional(&db)
.await?
.is_some();
if exists {
return Err(error::Error::NotAuthorized(format!(
"You are not authorized to access this flow: {path}"
)));
}
return Err(Error::NotFound(format!("Flow not found at path {path}")));
};
tag = flow_tag;
let dynamic_input_res = match DYNAMIC_INPUT_CACHE.get(&format!("{}:{}", w_id, path))
{
Some(cached) => cached.as_ref().clone(),
None => {
let dynamic_input = sqlx::query_scalar!(
r#"
SELECT
schema
FROM
flow
WHERE
workspace_id = $1 AND
path = $2
"#,
&w_id,
&path
)
.fetch_one(&mut *conn)
.await?
.and_then(|dynamic_input| {
Some(serde_json::from_value::<DynamicInput>(dynamic_input))
})
.transpose()?;
let Some(dynamic_input) = dynamic_input else {
return Err(Error::BadRequest(format!(
"Flow at path {} does not have a dynamic select schema",
path
)));
};
let dynamic_input_key =
windmill_common::jobs::generate_dynamic_input_key(&w_id, &path);
DYNAMIC_INPUT_CACHE
.insert(dynamic_input_key, Arc::new(dynamic_input.clone()));
dynamic_input
}
};
conn.commit().await?;
dynamic_input = dynamic_input_res;
runnable_path = Some(path);
}
},
DynamicSelectRunnableRef::Inline { code, lang: language } => {
// Inline dynamic select runs arbitrary, request-supplied code; require the broad
// jobs:run scope so a narrowly-scoped token cannot escape its scope. The Deployed
// branches are path-scoped instead.
check_scopes(&authed, || format!("jobs:run"))?;
dynamic_input = DynamicInput {
x_windmill_dyn_select_code: code,
x_windmill_dyn_select_lang: language.unwrap_or_default(),
};
runnable_path = None;
}
}
// Invoke the dyn-select entrypoint instead of `main`.
let mut args = request.args.unwrap_or_default();
args.insert(
ENTRYPOINT_OVERRIDE.to_string(),
serde_json::value::to_raw_value(&request.entrypoint_function)?,
);
// Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue):
// a caller allowed to read the flow must still be allowed to use its worker tag. No-op for
// inline (tag is None); the script branch checked this inside its helper and returned above.
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&args), &authed).await?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
JobPayload::Code(RawCode {
hash: None,
content: dynamic_input.x_windmill_dyn_select_code,
path: runnable_path,
language: dynamic_input.x_windmill_dyn_select_lang,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: ConcurrencySettings::default().into(),
debouncing_settings: DebouncingSettings::default(),
modules: None,
// RawCode.tag is ignored by the queue path (`JobPayload::Code` destructures it as
// `tag: _`); the effective tag is the `tag` argument to `push` below.
tag: None,
}),
PushArgs::from(&args),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
None,
None,
None,
run_query.job_id,
false,
false,
None,
true,
// Deployed flow → the flow's worker tag; inline → `None` (language default).
tag,
run_query.timeout,
None,
None,
Some(&authed.clone().into()),
false,
None,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()).into_response())
}
pub async fn run_job_by_hash(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_script_hash(script_hash),
run_query.skip_preprocessor,
)
.await?;
let (uuid, _, _) = run_job_by_hash_inner(
authed,
db,
user_db,
w_id,
script_hash,
run_query,
args,
None,
)
.await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
pub async fn run_job_by_hash_inner(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
script_hash: ScriptHash,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(Uuid, Option<bool>, Option<i32>)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let hash = script_hash.0;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let script_info = get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash)
.await?
.prefetch_cached(&db)
.await?;
let on_behalf_of = script_info.on_behalf_of(&w_id, &db).await?;
let ScriptHashInfo {
path,
tag,
runnable_settings:
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
mut cache_ttl,
mut cache_ignore_s3_path,
language,
dedicated_worker,
priority,
timeout,
has_preprocessor,
delete_after_use,
delete_after_secs,
labels,
job_token_scopes,
..
} = script_info;
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
if let Some(run_query_cache_ttl) = run_query.cache_ttl {
cache_ttl = Some(run_query_cache_ttl);
cache_ignore_s3_path = run_query.cache_ignore_s3_path;
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag);
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
(
&obo.email,
obo.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
&authed.email,
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let job_payload = JobPayload::ScriptHash {
hash: ScriptHash(hash),
path: path,
concurrency_settings,
debouncing_settings,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
&& has_preprocessor.unwrap_or(false),
labels,
job_token_scopes,
};
let job_payload = with_run_retry(&run_query, job_payload, &push_args, &tag)?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, trigger.as_ref()).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let (uuid, tx) = push(
&db,
tx,
&w_id,
job_payload,
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(trigger),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
Ok((uuid, delete_after_use, delete_after_secs))
}
async fn get_log_file(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, file_p)): Path<(String, String)>,
) -> error::Result<Response> {
if file_p.contains("..") {
return Err(error::Error::BadRequest("Invalid path".to_string()));
}
// Validate path format: must be exactly 2 parts, first is UUID, second ends with .txt
let parts: Vec<&str> = file_p.split('/').collect();
if parts.len() != 2 {
return Err(error::Error::BadRequest(
"Invalid path: must have exactly 2 components".to_string(),
));
}
let job_id = Uuid::parse_str(parts[0]).map_err(|_| {
error::Error::BadRequest("Invalid path: first component must be a valid UUID".to_string())
})?;
if !parts[1].ends_with(".txt") {
return Err(error::Error::BadRequest(
"Invalid path: file must end with .txt".to_string(),
));
}
// Authorization: the log file directory is the job id, so gate access the same
// way as get_job_logs — the caller must be able to read the job.
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
.flatten();
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
job_id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
// SECURITY (defense in depth): refuse to read through a symlink so a planted
// symlink in the logs directory cannot be used to exfiltrate arbitrary files.
// `symlink_metadata` returns the link's own metadata without following it.
match tokio::fs::symlink_metadata(&local_file).await {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(error::Error::BadRequest("Invalid path".to_string()));
}
Ok(_) => {
let mut file = tokio::fs::File::open(&local_file)
.await
.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
let res = Response::builder()
.header(http::header::CONTENT_TYPE, "text/plain")
.body(Body::from(bytes::Bytes::from(buffer)))
.unwrap();
return Ok(res);
}
Err(_) => {}
}
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = windmill_object_store::get_object_store().await {
let file = os
.get(&windmill_object_store::object_store_reexports::Path::from(
format!("logs/{file_p}"),
))
.await;
if let Ok(file) = file {
if let Ok(bytes) = file.bytes().await {
use axum::http::header;
let res = Response::builder()
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(bytes::Bytes::from(bytes)))
.unwrap();
return Ok(res);
} else {
return Err(error::Error::internal_err(format!(
"Error getting bytes from file: {}",
file_p
)));
}
} else {
return Err(error::Error::NotFound(format!(
"File not found: {}",
file_p
)));
}
} else {
return Err(error::Error::internal_err(format!(
"Object store client not present and file not found on server logs volume at {local_file}"
)));
}
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
return Err(error::Error::NotFound(format!(
"File not found on server logs volume {}/logs and no distributed logs s3 storage for {}",
*WINDMILL_DIR, file_p
)));
}
async fn get_job_update(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
stream_offset,
get_progress,
running,
only_result,
no_logs,
is_flow,
..
}): Query<JobUpdateQuery>,
) -> JsonResult<JobUpdate> {
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
// A public share link authorizes a logged-out read of this job, seeding the latch
// that would otherwise confine it to anonymously-run jobs.
let mut unauthed_read_authorized = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
Ok(Json(
get_job_update_data(
&opt_authed,
&opt_tokened,
&db,
&w_id,
&job_id,
log_offset,
stream_offset,
get_progress.unwrap_or(false),
running,
true,
false,
only_result,
no_logs,
is_flow,
None,
None,
false,
&mut false,
&mut unauthed_read_authorized,
)
.await?,
))
}
async fn get_job_update_sse(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
stream_offset,
get_progress,
running,
no_logs,
only_result,
fast,
is_flow,
poll_delay_ms,
}): Query<JobUpdateQuery>,
) -> error::Result<Response> {
// Authorize once at connection time; `created_by` cannot change for a given job,
// mirroring the per-stream `unauthed_read_authorized` latch in the streaming loop.
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
let unauthed_read_authorized = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
let (tx, rx) = tokio::sync::mpsc::channel(32);
start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
w_id,
job_id,
log_offset,
stream_offset,
get_progress,
running,
only_result,
fast,
no_logs,
is_flow,
tx,
poll_delay_ms,
None,
false,
unauthed_read_authorized,
);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
format!(
"data: {}\n\n",
serde_json::to_string(&x).unwrap_or_default()
)
});
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Ok(Response::builder()
.status(200)
.header("X-Accel-Buffering", "no")
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(body)
.unwrap())
}
pub fn start_job_update_sse_stream(
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
w_id: String,
job_id: Uuid,
initial_log_offset: Option<i32>,
initial_stream_offset: Option<i32>,
get_progress: Option<bool>,
running: Option<bool>,
only_result: Option<bool>,
fast: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
poll_delay_ms: Option<u64>,
early_return: Option<String>,
has_failure_module: bool,
// Seeds the per-stream latch below: set when the caller is logged out but presented
// a public share link for this job, which authorizes the whole stream up front.
unauthed_read_authorized: bool,
) -> () {
tokio::spawn(async move {
let mut log_offset = initial_log_offset;
let mut stream_offset = initial_stream_offset;
let mut last_update_hash: Option<String> = None;
let mut flow_stream_job_id = None;
// Latched once the early_return node's failure is observed alongside a
// failure_module — subsequent polls then skip the redundant per-node lookup.
let mut early_return_suppressed = false;
// Latched once a logged-out caller's read of this job has been authorized — for
// unauthenticated SSE streams, this gates access and is checked once per
// stream rather than once per poll (created_by cannot change).
let mut unauthed_read_authorized = unauthed_read_authorized;
// Send initial update immediately
let mut running = running;
let mut mem_peak = 0;
let mut get_progress_m: bool = false;
match get_job_update_data(
&opt_authed,
&opt_tokened,
&db,
&w_id,
&job_id,
log_offset,
stream_offset,
get_progress.unwrap_or(false),
running,
true,
true,
only_result,
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
has_failure_module,
&mut early_return_suppressed,
&mut unauthed_read_authorized,
)
.await
{
Ok(mut update) => {
if update.progress.is_some() {
get_progress_m = true;
}
last_update_hash = Some(update.hash_str());
let completion_sent = update.completed.unwrap_or(false);
if running.is_some() && update.running.is_some_and(|x| x) {
running = Some(true);
}
if let Some(new_mem_peak) = update.mem_peak {
mem_peak = new_mem_peak;
}
if let Some(new_offset) = update.log_offset {
if new_offset != log_offset.unwrap_or(0) {
log_offset = Some(new_offset);
} else {
update.log_offset = None;
}
}
if let Some(new_stream_offset) = update.stream_offset {
if new_stream_offset != stream_offset.unwrap_or(0) {
stream_offset = Some(new_stream_offset);
} else {
update.stream_offset = None;
}
}
if update.flow_stream_job_id.is_some() {
flow_stream_job_id = update.flow_stream_job_id;
}
if tx.send(JobUpdateSSEStream::Update(update)).await.is_err() {
tracing::warn!("Failed to send initial job update for job {job_id}");
return;
}
if completion_sent {
return;
}
}
Err(e) => {
if tx
.send(JobUpdateSSEStream::Error { error: e.to_string() })
.await
.is_err()
{
tracing::warn!("Failed to send initial job update for job {job_id}");
return;
}
}
}
// Poll for updates every 1 second
let mut i = 0;
let start = Instant::now();
let mut last_ping = Instant::now();
let mut last_progress_check = Instant::now();
loop {
i += 1;
#[allow(unused_mut)]
let mut ms_duration = if i > 100 || !fast.unwrap_or(false) {
3000
} else if i > 10 {
500
} else {
100
};
#[allow(unused_variables)]
if let Some(poll_delay_ms) = poll_delay_ms {
#[cfg(feature = "enterprise")]
if poll_delay_ms < 50 {
tracing::warn!("Poll delay ms is less than 50, setting it to 50");
ms_duration = 50;
} else {
ms_duration = poll_delay_ms;
}
#[cfg(not(feature = "enterprise"))]
tracing::warn!("Settable poll delay requires EE");
}
if last_ping.elapsed().as_secs() > 5 {
if tx.send(JobUpdateSSEStream::Ping).await.is_err() {
tracing::warn!("Failed to send job ping for job {job_id}");
return;
}
last_ping = Instant::now();
}
if start.elapsed().as_secs() > *TIMEOUT_SSE_STREAM {
if tx.send(JobUpdateSSEStream::Timeout).await.is_err() {
tracing::warn!("Failed to send job timeout for job {job_id}");
}
return;
}
tokio::time::sleep(std::time::Duration::from_millis(ms_duration)).await;
// Check progress if the user requested it, and check periodically if the job has progress
// Once it has progress, we always check progress
let check_progress = get_progress.unwrap_or(false)
&& (get_progress_m || last_progress_check.elapsed().as_secs() > 5);
match get_job_update_data(
&opt_authed,
&opt_tokened,
&db,
&w_id,
&job_id,
log_offset,
stream_offset,
check_progress,
running,
false,
true,
only_result,
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
has_failure_module,
&mut early_return_suppressed,
&mut unauthed_read_authorized,
)
.await
{
Ok(mut update) => {
if running.is_some() && update.running.is_some_and(|x| x) {
running = Some(true);
}
if update.completed.is_some_and(|x| !x) {
update.completed = None;
}
if update.new_logs.as_ref().is_some_and(|x| x.is_empty()) {
update.new_logs = None;
}
if check_progress {
if update.progress.is_some() {
get_progress_m = true;
} else {
last_progress_check = Instant::now();
}
}
// A job that finishes in between two throttled progress checks would
// otherwise report no progress at all, leaving the caller unable to tell
// at which progress it ended.
if get_progress.unwrap_or(false)
&& update.progress.is_none()
&& update.completed.unwrap_or(false)
{
match sqlx::query_scalar!(
"SELECT (scalar_int)::int FROM job_stats WHERE job_id = $1 AND workspace_id = $2 AND metric_id = 'progress_perc'",
job_id, &w_id)
.fetch_optional(&db)
.await
{
Ok(progress) => update.progress = progress.flatten(),
Err(e) => tracing::warn!(
"Failed to get final progress for job {job_id}: {e:#}"
),
}
}
// if !only_result.unwrap_or(false) {
// tracing::error!("update {:?}", update);
// }
let update_last_status = update.hash_str();
// Only send if the update has changed
if last_update_hash.as_ref() != Some(&update_last_status) {
// Update log offset if available
if let Some(new_offset) = update.log_offset {
if new_offset != log_offset.unwrap_or(0) {
// let logs = update.new_logs.clone().unwrap_or_default();
// tracing::error!(
// "new_offset: {new_offset}; log_offset: {log_offset:?}; {} {logs}",
// logs.len()
// );
log_offset = Some(new_offset);
} else {
update.log_offset = None;
}
}
if let Some(new_stream_offset) = update.stream_offset {
if new_stream_offset != stream_offset.unwrap_or(0) {
stream_offset = Some(new_stream_offset);
} else {
update.stream_offset = None;
}
}
if update.flow_stream_job_id.is_some() {
if flow_stream_job_id.is_none() {
flow_stream_job_id = update.flow_stream_job_id;
} else {
update.flow_stream_job_id = None;
}
}
if let Some(new_mem_peak) = update.mem_peak {
if new_mem_peak != mem_peak {
mem_peak = new_mem_peak;
} else {
update.mem_peak = None;
}
}
let completed = update.completed.unwrap_or(false);
if tx.send(JobUpdateSSEStream::Update(update)).await.is_err() {
break;
}
if completed {
break;
}
last_update_hash = Some(update_last_status);
}
}
Err(e) => {
tracing::error!("Error getting job update: {:?}", e);
if tx.send(JobUpdateSSEStream::NotFound).await.is_err() {
tracing::warn!("Failed to send job not found for job {job_id}");
}
return;
}
}
}
});
}
async fn get_flow_stream_delta(
db: &DB,
flow_stream_job_id: Option<Uuid>,
stream_offset: Option<i32>,
) -> error::Result<Option<(Option<String>, Option<i32>)>> {
if let Some(job_id) = flow_stream_job_id {
let record = sqlx::query!(
"
SELECT
string_agg(stream, '' order by idx asc) as stream,
max(idx) + 1 as offset
FROM job_result_stream_v2
WHERE job_id = $2 AND idx >= $1
",
stream_offset.unwrap_or(0),
job_id,
)
.fetch_optional(db)
.await?;
if let Some(record) = record {
Ok(Some((record.stream, record.offset)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
async fn get_job_update_data(
opt_authed: &Option<ApiAuthed>,
opt_tokened: &OptTokened,
db: &DB,
w_id: &str,
job_id: &Uuid,
log_offset: Option<i32>,
stream_offset: Option<i32>,
get_progress: bool,
running: Option<bool>,
log_view: bool,
get_full_job_on_completion: bool,
only_result: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
flow_stream_job_id: Option<Uuid>,
early_return: Option<&str>,
has_failure_module: bool,
early_return_suppressed: &mut bool,
// Latched gate for logged-out callers: once true, this job's updates are readable
// without a session (job run anonymously, or a public share link presented).
unauthed_read_authorized: &mut bool,
) -> error::Result<JobUpdate> {
let tags = if log_view {
log_job_view(
db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
w_id,
job_id,
)
.await?;
opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten()
} else {
None
};
let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some();
if only_result.unwrap_or(false) {
// Unauthenticated callers are confined to jobs they may read logged out.
// The non-only_result branch enforces this via `record.created_by` from its
// main query, but the only_result branch below fetches solely the result by
// (workspace_id, job_id), so we guard here to close the gap. The latch is
// preserved across SSE poll iterations so the lookup only happens once per
// stream — `created_by` cannot change for a given job once it has been created.
if opt_authed.is_none() && !*unauthed_read_authorized {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
*unauthed_read_authorized = true;
}
let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) =
if let Some(tags) = tags {
let r = sqlx::query!(
"
WITH result_stream AS (
SELECT
string_agg(stream, '' order by idx asc) as stream,
job_id,
max(idx) + 1 as offset
FROM job_result_stream_v2
WHERE job_id = $2 AND idx >= $3
GROUP BY job_id
)
SELECT
jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",
v2_job.tag,
v2_job_queue.running as \"running: Option<bool>\",
rs.stream AS \"result_stream: Option<String>\",
rs.offset AS stream_offset,
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM v2_job
LEFT JOIN v2_job_queue USING (id)
LEFT JOIN v2_job_completed jc USING (id)
LEFT JOIN v2_job_status js USING (id)
LEFT JOIN result_stream rs ON rs.job_id = $2
WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
w_id,
job_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if !tags.contains(&r.tag.as_str()) {
return Err(Error::NotAuthorized(format!(
"Job tag {} is not in the scope tags: {}",
r.tag,
tags.join(", ")
)));
}
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
if running.is_some_and(|x| !x) {
let r = sqlx::query!(
"
WITH result_stream AS (
SELECT
string_agg(stream, '' order by idx asc) as stream,
job_id,
max(idx) + 1 as offset
FROM job_result_stream_v2
WHERE job_id = $1 AND idx >= $3
GROUP BY job_id
)
SELECT
COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",
jq.running as \"running: Option<bool>\",
rs.stream AS \"result_stream: Option<String>\",
rs.offset AS stream_offset,
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM (
SELECT $1::uuid as job_id, $2::text as workspace_id
) base
LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id
LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id
LEFT JOIN v2_job_status js ON js.id = base.job_id
LEFT JOIN result_stream rs ON rs.job_id = base.job_id
WHERE base.job_id = $1",
job_id,
w_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
).fetch_optional(db).await?;
if let Some(r) = r {
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
(None, None, None, None, None)
}
} else {
let q = sqlx::query!(
"
WITH result_stream AS (
SELECT
string_agg(stream, '' order by idx asc) as stream,
job_id,
max(idx) + 1 as offset
FROM job_result_stream_v2
WHERE job_id = $2 AND idx >= $3
GROUP BY job_id
)
SELECT
COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",
rs.stream AS \"result_stream: Option<String>\",
rs.offset AS stream_offset,
COALESCE(js.flow_status, jc.flow_status) as \"flow_status: sqlx::types::Json<Box<RawValue>>\",
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM (
SELECT $2::uuid as job_id, $1::text as workspace_id
) base
LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id
LEFT JOIN v2_job_status js ON js.id = base.job_id
LEFT JOIN result_stream rs ON rs.job_id = base.job_id
WHERE base.job_id = $2",
w_id,
job_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
)
.fetch_optional(db)
.await?;
if let Some(r) = q {
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
(None, None, None, None, None)
}
}
};
let flow_stream_job_id = flow_stream_job_id.or(new_flow_stream_job_id);
let result = if let Some(early_return) = early_return.filter(|_| !*early_return_suppressed)
{
match get_result_and_success_by_id_from_flow(db, w_id, job_id, early_return, None).await
{
// When the early_return node failed but the flow has a failure_module,
// the error handler will run and may recover. Keep the completed flow
// result instead (it reflects the failure_module's output). Latch the
// observation so subsequent polls skip this query — the early-return
// node's failure is final once observed.
Ok((_, early_success)) if has_failure_module && !early_success => {
*early_return_suppressed = true;
result
}
Ok((early_result, _)) => Some(early_result),
_ => result,
}
} else {
result
};
let flow_stream_delta =
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;
if let Some((flow_result_stream, flow_stream_offset)) = flow_stream_delta {
result_stream = flow_result_stream;
new_stream_offset = flow_stream_offset;
}
Ok(JobUpdate {
running,
completed: if result.is_some() { Some(true) } else { None },
log_offset: None,
new_logs: None,
new_result_stream: result_stream,
stream_offset: new_stream_offset,
mem_peak: None,
progress: None,
job: None,
flow_status: None,
workflow_as_code_status: None,
only_result: result,
flow_stream_job_id,
})
} else {
let mut record = sqlx::query!(
"
WITH result_stream AS (
SELECT
string_agg(stream, '' order by idx asc) as stream,
job_id,
max(idx) + 1 as offset
FROM job_result_stream_v2
WHERE job_id = $3 AND idx >= $8
GROUP BY job_id
)
SELECT
c.id IS NOT NULL AS completed,
CASE
WHEN q.id IS NOT NULL THEN (CASE WHEN NOT $5 AND q.running THEN true ELSE null END)
ELSE false
END AS running,
CASE WHEN $7::BOOLEAN THEN NULL ELSE SUBSTR(logs, GREATEST($1 - log_offset, 0)) END AS logs,
rs.stream AS new_result_stream,
COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,
COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",
(COALESCE(c.flow_status, f.flow_status)->>'stream_job')::uuid AS stream_job,
COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",
CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,
rs.offset AS stream_offset,
created_by AS \"created_by!\",
CASE WHEN $4::BOOLEAN THEN (
SELECT scalar_int FROM job_stats WHERE job_id = $3 AND metric_id = 'progress_perc'
) END AS progress,
rs.stream AS \"result_stream: Option<String>\"
FROM v2_job j
LEFT JOIN v2_job_queue q USING (id)
LEFT JOIN v2_job_runtime r USING (id)
LEFT JOIN v2_job_status f USING (id)
LEFT JOIN v2_job_completed c USING (id)
LEFT JOIN result_stream rs ON rs.job_id = $3
LEFT JOIN job_logs ON job_logs.job_id = $3
WHERE j.workspace_id = $2 AND j.id = $3
AND ($6::text[] IS NULL OR j.tag = ANY($6))",
log_offset,
w_id,
job_id,
get_progress,
running,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
no_logs.unwrap_or(false),
stream_offset.unwrap_or(0),
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if opt_authed.is_none() && !*unauthed_read_authorized {
if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
*unauthed_read_authorized = true;
}
let job = if record.completed.unwrap_or(false) && get_full_job_on_completion {
let get = GetQuery::new()
.with_auth(&opt_authed)
// Already authorized above, latch included — don't re-derive it from
// `created_by` here or a public share link would lose the full job.
.with_public_view_grant(*unauthed_read_authorized)
.without_logs()
.without_code();
Some(get.fetch(&db, job_id, &w_id).await?)
} else {
None
};
let flow_stream_job_id = flow_stream_job_id.or(record.stream_job);
let flow_stream_delta =
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;
if let Some((new_result_stream, stream_offset)) = flow_stream_delta {
record.new_result_stream = new_result_stream;
record.stream_offset = stream_offset;
}
Ok(JobUpdate {
running: record.running,
completed: record.completed,
log_offset: record.log_offset,
new_logs: record.logs,
new_result_stream: record.new_result_stream,
stream_offset: record.stream_offset,
mem_peak: record.mem_peak,
progress: record.progress,
workflow_as_code_status: record
.workflow_as_code_status
.map(|x: sqlx::types::Json<Box<RawValue>>| x.0),
job,
flow_status: record
.flow_status
.map(|x: sqlx::types::Json<Box<RawValue>>| x.0),
only_result: None,
flow_stream_job_id,
})
}
}
async fn list_completed_jobs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<ListableCompletedJob>> {
let include_args = lq.include_args.unwrap_or(false);
if include_args && *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
"include_args is not supported on cloud hosted Windmill".to_string(),
));
}
let (per_page, offset) = paginate(pagination);
let args_field = if include_args {
"v2_job.args"
} else {
"null as args"
};
let mut sqlb = list_completed_jobs_query(
&w_id,
Some(per_page),
offset,
&lq,
&[
"v2_job_completed.id",
"v2_job_completed.workspace_id",
"v2_job.parent_job",
"v2_job.created_by",
"v2_job.created_at",
"v2_job_completed.started_at",
"v2_job_completed.duration_ms",
"v2_job_completed.status = 'success' OR v2_job_completed.status = 'skipped' as success",
"v2_job.runnable_id as script_hash",
"v2_job.runnable_path as script_path",
"false as deleted",
"v2_job_completed.status = 'canceled' as canceled",
"v2_job_completed.canceled_by",
"v2_job_completed.canceled_reason",
"v2_job.kind as job_kind",
"CASE WHEN v2_job.trigger_kind = 'schedule' THEN v2_job.trigger END as schedule_path",
"v2_job.permissioned_as",
"null as raw_code",
"null as flow_status",
"null as raw_flow",
"v2_job.flow_step_id IS NOT NULL as is_flow_step",
"v2_job.script_lang as language",
"v2_job_completed.status = 'skipped' as is_skipped",
"v2_job.permissioned_as_email as email",
"v2_job.visible_to_owner",
"v2_job_completed.memory_peak as mem_peak",
"v2_job.tag",
"v2_job.priority",
"v2_job.labels",
args_field,
"'CompletedJob' as type",
],
false,
get_scope_tags(&authed),
);
confine_list_to_read_scope(&mut sqlb, &authed);
let sql = sqlb.sql()?;
let mut tx = user_db.begin(&authed).await?;
let jobs = sqlx::query_as::<_, ListableCompletedJob>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(jobs))
}
async fn get_completed_job<'a>(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
let public_view_grant = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
let job_o = GetQuery::new()
.with_auth(&opt_authed)
.with_in_tags(tags.as_ref())
.with_public_view_grant(public_view_grant)
.fetch_completed(&db, &id, &w_id)
.await?;
let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?;
// `public_view_grant` already settled the logged-out case above — don't re-derive it.
if !public_view_grant {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&cj.created_by,
view_token.as_deref(),
)
.await?;
}
let response = Json(cj).into_response();
// let extra_log = query_scalar!(
// "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3",
// log_offset - len,
// &w_id,
// &job_id
// )
// .fetch_optional(db)
// .await.ok().flatten().flatten();
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(response)
}
#[derive(FromRow)]
pub struct RawResult {
pub result: Option<sqlx::types::Json<Box<RawValue>>>,
pub result_columns: Option<Vec<String>>,
pub created_by: Option<String>,
}
async fn get_completed_job_result(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query<JsonPath>,
) -> error::Result<Response> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
let result_o = if let Some(json_path) = json_path {
sqlx::query_as!(
RawResult,
"SELECT
result #> $3 AS \"result: sqlx::types::Json<Box<RawValue>>\",
result_columns,
created_by AS \"created_by!\"
FROM v2_job_completed c
JOIN v2_job USING (id)
WHERE c.id = $1 AND c.workspace_id = $2 AND ($4::text[] IS NULL OR tag = ANY($4))",
id,
&w_id,
json_path.split(".").collect::<Vec<_>>() as Vec<&str>,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?
} else {
sqlx::query_as!(
RawResult,
"SELECT
result AS \"result: sqlx::types::Json<Box<RawValue>>\",
result_columns,
created_by AS \"created_by!\"
FROM v2_job_completed c
JOIN v2_job USING (id)
WHERE c.id = $1 AND c.workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?
};
let mut raw_result = not_found_if_none(result_o, "Completed Job", id.to_string())?;
let created_by = raw_result.created_by.take().unwrap_or_default();
// A valid approval secret for the suspended parent flow grants access to this
// node's result for ANY caller — logged in or not — since the approval page
// renders its form from this result. Try it first. If the secret triple is absent,
// or present but invalid, fall through to normal authorization: an authenticated
// reader with ACL must NOT be blocked just because a stale/garbage secret was
// attached (pre-fix the secret branch was skipped entirely for authed callers),
// while an unauthenticated caller, for whom the secret is the only credential,
// still ends up rejected below.
let approval_secret_ok = match (suspended_job, resume_id, secret) {
(Some(suspended_job), Some(resume_id), Some(secret)) => {
// Walk from `id` up to the claimed suspended parent.
let mut parent_job = id;
let mut reached = true;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
match p_job {
Some(p_job) => parent_job = p_job,
None => {
reached = false;
break;
}
}
}
reached
&& verify_suspended_secret(
&w_id,
&db,
suspended_job,
resume_id,
&QueryApprover { approver, flow_level: None },
secret,
)
.await
.is_ok()
}
_ => false,
};
if approval_secret_ok {
// The approval secret skips the gate below, and with it the run-scope
// confinement that gate carries — re-apply it, as `get_job` does for the
// approval token. Anonymous approval access is untouched.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_read_scope(&db, authed, &w_id, &id).await?;
}
} else {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
format_result(
raw_result.result_columns.as_ref(),
raw_result.result.as_mut(),
);
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(raw_result.result).into_response())
}
#[derive(Deserialize)]
struct CountByTagQuery {
horizon_secs: Option<i64>,
workspace_id: Option<String>,
}
#[derive(Serialize)]
struct TagCount {
tag: String,
count: i64,
}
async fn count_by_tag(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<CountByTagQuery>,
) -> JsonResult<Vec<TagCount>> {
require_super_admin(&db, &authed).await?;
let horizon = query.horizon_secs.unwrap_or(3600); // Default to 1 hour if not specified
let counts = sqlx::query_as!(
TagCount,
r#"
SELECT j.tag as "tag!", COUNT(*) as "count!"
FROM v2_job_completed c JOIN v2_job j USING (id)
WHERE c.started_at > NOW() - make_interval(secs => $1) AND ($2::text IS NULL OR j.workspace_id = $2)
GROUP BY j.tag
ORDER BY "count!" DESC
"#,
horizon as f64,
query.workspace_id
)
.fetch_all(&db)
.await?;
Ok(Json(counts))
}
#[derive(Serialize)]
struct CompletedJobResult {
started: Option<bool>,
success: Option<bool>,
completed: bool,
result: Option<sqlx::types::Json<Box<RawValue>>>,
}
#[derive(Deserialize)]
struct GetCompletedJobQuery {
get_started: Option<bool>,
}
async fn get_completed_job_result_maybe(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
) -> error::Result<Response> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
let result_o = sqlx::query!(
"SELECT
result AS \"result: sqlx::types::Json<Box<RawValue>>\",
result_columns,
status = 'success' AS \"success!\",
created_by AS \"created_by!\"
FROM v2_job_completed c
JOIN v2_job j USING (id)
WHERE c.id = $1 AND c.workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?;
if let Some(mut res) = result_o {
format_result(res.result_columns.as_ref(), res.result.as_mut());
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&res.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
opt_authed.as_ref(),
opt_tokened.token.as_deref(),
&w_id,
&id,
)
.await?;
Ok(Json(CompletedJobResult {
started: Some(true),
success: Some(res.success),
completed: true,
result: res.result,
})
.into_response())
} else if get_started.is_some_and(|x| x) {
// No completed row yet — the job may be queued/running. Returning its
// running-state still discloses information about a (possibly private) job, so
// authorize first when the job exists. If it doesn't exist, fall through to a
// `started: false` response (which leaks nothing).
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if let Some(created_by) = created_by {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
let started = sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
id,
w_id
)
.fetch_optional(&db)
.await?
.unwrap_or(false);
Ok(Json(CompletedJobResult {
started: Some(started),
completed: false,
success: None,
result: None,
})
.into_response())
} else {
Ok(Json(CompletedJobResult {
started: None,
completed: false,
success: None,
result: None,
})
.into_response())
}
}
#[derive(Serialize)]
struct JobTiming {
created_at: chrono::DateTime<Utc>,
started_at: Option<chrono::DateTime<Utc>>,
duration_ms: Option<i64>,
}
/// One row of the producer's "Dispatch" panel — what the asset-trigger
/// dispatcher decided for a single (subscriber, asset write) pair. See
/// `windmill_queue::asset_dispatch` for the writer and the discriminants
/// of the `outcome` / `reason` fields.
#[derive(Serialize)]
struct DispatchEvent {
subscriber_path: String,
asset_kind: windmill_common::assets::AssetKind,
asset_path: String,
outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
child_job_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
partition: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
received_inputs: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
required_inputs: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
debounce_s: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
created_at: chrono::DateTime<Utc>,
}
async fn get_dispatch_events(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<Vec<DispatchEvent>> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
// Gate on the producer job's visibility, exactly like
// get_completed_job_timing on the same unauthed router: scope tags
// first, then the shared per-audience read gate. The dispatch_event FK
// to v2_job(id) guarantees the producer row exists for any extant event.
let producer = sqlx::query!(
r#"SELECT created_by AS "created_by!"
FROM v2_job
WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))"#,
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?;
let producer = not_found_if_none(producer, "Job", id.to_string())?;
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&producer.created_by,
view_token.as_deref(),
)
.await?;
let rows = sqlx::query!(
r#"SELECT
subscriber_path AS "subscriber_path!",
asset_kind AS "asset_kind!: windmill_common::assets::AssetKind",
asset_path AS "asset_path!",
outcome::text AS "outcome!",
child_job_id,
partition,
received_inputs,
required_inputs,
debounce_s,
reason,
created_at AS "created_at!"
FROM dispatch_event
WHERE producer_job_id = $1 AND workspace_id = $2
ORDER BY id"#,
id,
&w_id,
)
.fetch_all(&db)
.await?;
Ok(Json(
rows.into_iter()
.map(|r| DispatchEvent {
subscriber_path: r.subscriber_path,
asset_kind: r.asset_kind,
asset_path: r.asset_path,
outcome: r.outcome,
child_job_id: r.child_job_id,
partition: r.partition,
received_inputs: r.received_inputs,
required_inputs: r.required_inputs,
debounce_s: r.debounce_s,
reason: r.reason,
created_at: r.created_at,
})
.collect(),
))
}
/// One asset-cascade dispatch record, for reconstructing the cascade graph of a
/// pipeline folder in the Activity panel. `dispatched` rows carry the resolved
/// `child_job_id` (a real producer→child job edge); `join_pending` rows are the
/// pre-completion inputs of an AND-join (no child yet) — the client links them
/// to the eventual child of the same `subscriber_path` so a join's separate
/// trigger chains merge into one group. `skipped` is omitted.
#[derive(Serialize)]
struct AssetDispatchEdge {
producer_job_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
child_job_id: Option<Uuid>,
subscriber_path: String,
outcome: String,
asset_kind: windmill_common::assets::AssetKind,
asset_path: String,
created_at: chrono::DateTime<Utc>,
}
#[derive(Deserialize)]
struct AssetDispatchEdgesQuery {
/// Folder path prefix the children live under, e.g. `f/orders/`. Matched
/// against `subscriber_path` — every intra-pipeline cascade edge has its
/// child in the folder, so this captures the whole folder's cascades.
path_start: String,
/// Only edges dispatched at/after this instant (align with the activity
/// window the client already loaded). Omit for the default cap.
created_after: Option<chrono::DateTime<Utc>>,
}
/// Asset-cascade edges for a pipeline folder. RLS on the joined `v2_job`
/// producer row limits this to cascades whose producer the caller can already
/// see (same visibility as the folder's job list).
async fn list_asset_dispatch_edges(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(query): Query<AssetDispatchEdgesQuery>,
) -> error::JsonResult<Vec<AssetDispatchEdge>> {
let like = format!("{}%", query.path_start);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
r#"SELECT
de.producer_job_id AS "producer_job_id!",
de.child_job_id,
de.subscriber_path AS "subscriber_path!",
de.outcome::text AS "outcome!",
de.asset_kind AS "asset_kind!: windmill_common::assets::AssetKind",
de.asset_path AS "asset_path!",
de.created_at AS "created_at!"
FROM dispatch_event de
JOIN v2_job pj ON pj.id = de.producer_job_id
WHERE de.workspace_id = $1
AND de.outcome IN ('dispatched', 'join_pending')
AND de.subscriber_path LIKE $2
AND ($3::timestamptz IS NULL OR de.created_at >= $3)
ORDER BY de.created_at DESC, de.id DESC
LIMIT 4000"#,
&w_id,
like,
query.created_after,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| AssetDispatchEdge {
producer_job_id: r.producer_job_id,
child_job_id: r.child_job_id,
subscriber_path: r.subscriber_path,
outcome: r.outcome,
asset_kind: r.asset_kind,
asset_path: r.asset_path,
created_at: r.created_at,
})
.collect(),
))
}
async fn get_completed_job_timing(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<JobTiming> {
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed))
.flatten();
let result = sqlx::query!(
"SELECT
j.created_at AS \"created_at!\",
c.started_at,
c.duration_ms,
j.created_by AS \"created_by!\"
FROM v2_job_completed c
JOIN v2_job j USING (id)
WHERE c.id = $1 AND c.workspace_id = $2 AND ($3::text[] IS NULL OR j.tag = ANY($3))",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&db)
.await?;
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&result.created_by,
view_token.as_deref(),
)
.await?;
Ok(Json(JobTiming {
created_at: result.created_at,
started_at: result.started_at,
duration_ms: Some(result.duration_ms),
}))
}
async fn delete_completed_job<'a>(
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let mut tx = user_db.clone().begin(&authed).await?;
require_admin(authed.is_admin, &authed.username)?;
let tags = get_scope_tags(&authed);
let job_o = sqlx::query_scalar!(
"UPDATE v2_job_completed c SET
result = NULL,
deleted = TRUE
FROM v2_job j
WHERE c.id = $1
AND j.id = c.id
AND c.workspace_id = $2
AND ($3::TEXT[] IS NULL OR tag = ANY($3))
RETURNING c.id
",
id,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_optional(&mut *tx)
.await?;
not_found_if_none(job_o, "Completed Job", id.to_string())?;
sqlx::query!("UPDATE v2_job SET args = NULL WHERE id = $1", id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM job_logs WHERE job_id = $1", id)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"jobs.delete",
ActionKind::Delete,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
tx.commit().await?;
return get_completed_job(
OptViewToken(None),
OptAuthed(Some(authed)),
OptTokened { token: Some(token) },
Extension(db),
Extension(user_db),
Path((w_id, id)),
)
.await;
}
#[derive(Deserialize)]
struct ResolveJobsRequest {
job_ids: Vec<Uuid>,
note: Option<String>,
/// Id of a later successful run of the same runnable, when the caller claims the failure
/// was superseded. Evidence rather than an assertion: the claim is proven in SQL below and
/// the wording it produces belongs to the server, so this cannot attach arbitrary text to a
/// failure nor stamp provenance onto one that was never re-run.
superseded_by: Option<Uuid>,
}
/// Provenance Windmill established itself, as opposed to a person's explanation, which is why
/// this is recorded outside enterprise while a typed note is not.
const SUPERSEDED_NOTE: &str = "Superseded by a successful re-run";
/// Bounded so the per-id audit rows written below stay bounded too.
const MAX_RESOLUTION_BATCH: usize = 1000;
/// The note is copied onto every row the request resolves, so its size multiplies by the
/// batch size. Bounded to keep a single call from writing an outsized amount of TOAST/WAL.
/// Counted in characters, not bytes, so the limit matches what the client and the OpenAPI
/// `maxLength` count and a non-ASCII note never fails a check it appeared to pass.
const MAX_RESOLUTION_NOTE_LEN: usize = 2000;
/// Who resolved a failure and why is enterprise-only, mirroring `audit_log` being a no-op
/// outside EE: CE records *that* a failure was handled, EE records the accountability. The
/// resolution itself, the filter and the automatic retry sweep are unaffected, so gating
/// stays on this write and never reaches the runs-list read path.
/// The runtime license check matters as much as the feature gate: an EE binary keeps the
/// `enterprise` feature when its key expires, so without this a direct API client could keep
/// persisting attribution the UI has already stopped offering.
#[cfg(feature = "enterprise")]
fn resolution_attribution<'a>(
authed: &'a ApiAuthed,
note: Option<&'a str>,
) -> (Option<&'a str>, Option<&'a str>) {
if !windmill_common::ee_oss::LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) {
return (None, None);
}
(Some(authed.username.as_str()), note)
}
#[cfg(not(feature = "enterprise"))]
fn resolution_attribution<'a>(
_authed: &'a ApiAuthed,
_note: Option<&'a str>,
) -> (Option<&'a str>, Option<&'a str>) {
(None, None)
}
async fn check_resolution_request(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
job_ids: &[Uuid],
note: Option<&str>,
) -> error::Result<()> {
if !role_can_code(db, w_id, authed).await? {
return Err(error::Error::PermissionDenied(
"Your role cannot resolve jobs in this workspace".to_string(),
));
}
if job_ids.len() > MAX_RESOLUTION_BATCH {
return Err(error::Error::BadRequest(format!(
"Cannot resolve more than {MAX_RESOLUTION_BATCH} jobs at once, got {}",
job_ids.len()
)));
}
if let Some(note) = note {
let len = note.chars().count();
if len > MAX_RESOLUTION_NOTE_LEN {
return Err(error::Error::BadRequest(format!(
"Resolution note cannot exceed {MAX_RESOLUTION_NOTE_LEN} characters, got {len}"
)));
}
}
Ok(())
}
/// Marks failed or canceled jobs as handled. Returns the ids actually affected: an id that is
/// not visible to the caller, carries an out-of-scope tag, or did not fail nor get canceled is silently
/// absent rather than an error, so a bulk selection never fails as a whole.
async fn resolve_completed_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<ResolveJobsRequest>,
) -> error::JsonResult<Vec<Uuid>> {
check_resolution_request(&authed, &db, &w_id, &req.job_ids, req.note.as_deref()).await?;
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed);
let (resolved_by, typed_note) = resolution_attribution(&authed, req.note.as_deref());
let system_note = req.superseded_by.map(|_| SUPERSEDED_NOTE);
// The join on v2_job is what authorizes this write: v2_job_completed has RLS
// disabled, so v2_job's policies are the only thing scoping rows to the caller.
// Only a failed or canceled run can be resolved: a success has nothing to handle.
let resolved = sqlx::query_scalar!(
"INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)
SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false
FROM v2_job_completed c
JOIN v2_job j ON j.id = c.id
WHERE c.id = ANY($1)
AND c.workspace_id = $2
AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))
AND c.status IN ('failure', 'canceled')
-- Resolution is a top-level triage state: a step resolved on its own
-- would render orange inside a flow whose status is still red.
AND j.flow_step_id IS NULL
-- A supersession claim has to be proven, not trusted: a later success of the
-- same identified runnable, itself visible to the caller. An unproven claim
-- resolves nothing, so the caller learns it was rejected instead of having
-- the fiction recorded as provenance.
AND ($6::UUID IS NULL OR EXISTS (
SELECT 1 FROM v2_job_completed sc
JOIN v2_job sj ON sj.id = sc.id
WHERE sc.id = $6
AND sc.workspace_id = $2
-- Tag scope is a read restriction enforced outside RLS, so it has
-- to bind the evidence as well: otherwise the result reveals
-- whether an out-of-scope run succeeded.
AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))
AND sc.status = 'success'
AND sc.completed_at >= c.completed_at
AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)
AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id
AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path
))
ON CONFLICT (job_id) DO UPDATE SET
resolved_at = now(),
-- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the
-- licence lapses, and bulk selections routinely include already-resolved rows,
-- so overwriting would erase metadata recorded while it was valid. Clear either
-- by unresolving first.
resolved_by = COALESCE($4, job_resolution.resolved_by),
-- A person's explanation replaces what was there; machine provenance only fills
-- a blank, so re-running an already-explained failure never erases their words.
note = COALESCE($5, job_resolution.note, $7),
-- A human taking over an automatic resolution makes it no longer automatic.
automatic = false
RETURNING job_id",
&req.job_ids,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
resolved_by,
typed_note,
req.superseded_by,
system_note,
)
.fetch_all(&mut *tx)
.await?;
for id in &resolved {
audit_log(
&mut *tx,
&authed,
"jobs.resolve",
ActionKind::Update,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
}
tx.commit().await?;
Ok(Json(resolved))
}
async fn unresolve_completed_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<ResolveJobsRequest>,
) -> error::JsonResult<Vec<Uuid>> {
check_resolution_request(&authed, &db, &w_id, &req.job_ids, None).await?;
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed);
let unresolved = sqlx::query_scalar!(
"DELETE FROM job_resolution r
USING v2_job_completed c
JOIN v2_job j ON j.id = c.id
WHERE r.job_id = c.id
AND c.id = ANY($1)
AND c.workspace_id = $2
AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))
RETURNING r.job_id",
&req.job_ids,
&w_id,
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
)
.fetch_all(&mut *tx)
.await?;
for id in &unresolved {
audit_log(
&mut *tx,
&authed,
"jobs.unresolve",
ActionKind::Update,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
}
tx.commit().await?;
Ok(Json(unresolved))
}
async fn get_otel_traces(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Json<Vec<serde_json::Value>>> {
// Check job exists and user has permission to view it
let job = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
w_id
)
.fetch_optional(&db)
.await?;
match job {
Some(created_by) => {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
None => {
return Err(Error::NotFound(format!("Job {} not found", id)));
}
}
let trace_id = id.as_bytes().as_slice();
let traces = sqlx::query_scalar!(
r#"SELECT json_build_object(
'trace_id', encode(trace_id, 'hex'), -- BYTEA to hex string
'span_id', encode(span_id, 'hex'), -- BYTEA to hex string
'parent_span_id', encode(parent_span_id, 'hex'), -- BYTEA to hex string
'trace_state', trace_state,
'flags', flags,
'name', name,
'kind', kind,
'start_time_unix_nano', start_time_unix_nano,
'end_time_unix_nano', end_time_unix_nano,
'attributes', attributes,
'dropped_attributes_count', dropped_attributes_count,
'events', events,
'dropped_events_count', dropped_events_count,
'links', links,
'dropped_links_count', dropped_links_count,
'status', status
) as "span!"
FROM otel_traces
WHERE trace_id = $1
ORDER BY start_time_unix_nano"#,
trace_id
)
.fetch_all(&db)
.await?;
Ok(Json(traces))
}
#[cfg(test)]
mod approval_view_gate_tests {
use super::*;
fn authed(username: &str, is_admin: bool, groups: Vec<String>) -> ApiAuthed {
ApiAuthed {
email: format!("{username}@example.com"),
username: username.to_string(),
is_admin,
is_operator: false,
groups,
folders: vec![],
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
fn conds(user_auth_required: bool, groups: Vec<String>) -> ApprovalConditions {
ApprovalConditions {
user_auth_required,
user_groups_required: groups,
self_approval_disabled: false,
}
}
// Mirrors the view gate applied in get_approval_info and get_job:
// details are revealed only when the step doesn't require auth OR the caller
// is an authorized approver.
fn can_view(
opt_authed: &Option<ApiAuthed>,
approval_conditions: &Option<ApprovalConditions>,
script_path: Option<&str>,
trigger_email: &str,
) -> bool {
let user_auth_required = approval_conditions
.as_ref()
.map(|c| c.user_auth_required)
.unwrap_or(false);
!user_auth_required
|| can_approve_step(opt_authed, approval_conditions, script_path, trigger_email)
}
#[test]
fn anonymous_cannot_view_when_auth_required() {
// The regression: an unauthenticated holder of the approval token must see nothing.
let c = Some(conds(true, vec![]));
assert!(!can_view(
&None,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
}
#[test]
fn anonymous_can_view_when_no_auth_required() {
// Unchanged behaviour: token alone is sufficient when auth isn't required.
let c = Some(conds(false, vec![]));
assert!(can_view(
&None,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
// No approval conditions at all also allows token-only view.
assert!(can_view(
&None,
&None,
Some("f/team/flow"),
"trigger@example.com"
));
}
#[test]
fn admin_can_view_when_auth_required() {
let c = Some(conds(true, vec!["approvers".to_string()]));
let a = Some(authed("alice", true, vec![]));
assert!(can_view(&a, &c, Some("f/team/flow"), "trigger@example.com"));
}
#[test]
fn owner_can_view_when_auth_required() {
let c = Some(conds(true, vec!["approvers".to_string()]));
let a = Some(authed("bob", false, vec![]));
// bob owns u/bob/flow regardless of group membership.
assert!(can_view(&a, &c, Some("u/bob/flow"), "trigger@example.com"));
}
#[cfg(feature = "enterprise")]
#[test]
fn group_membership_decides_view_when_auth_required() {
let c = Some(conds(true, vec!["approvers".to_string()]));
let member = Some(authed("carol", false, vec!["approvers".to_string()]));
let outsider = Some(authed("dave", false, vec!["other".to_string()]));
// Use a non-owned folder path so ownership doesn't short-circuit the check.
assert!(can_view(
&member,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
assert!(!can_view(
&outsider,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
}
#[test]
fn approval_step_is_the_last_one_passed() {
let flow: FlowValue = serde_json::from_value(serde_json::json!({ "modules": [
{ "id": "a", "value": { "type": "identity" }, "suspend": {} },
{ "id": "b", "value": { "type": "identity" }, "suspend": {} },
{ "id": "c", "value": { "type": "identity" } }
]}))
.unwrap();
let step_at = |step: i32, types: [(&str, bool); 3]| {
let mut status = FlowStatus::new(&flow);
status.step = step;
status.modules = ["a", "b", "c"]
.into_iter()
.zip(types)
.map(|(id, (kind, skipped))| {
serde_json::from_value(serde_json::json!({
"type": kind, "id": id, "job": Uuid::nil(), "count": 1,
"failed_retries": [], "skipped": skipped
}))
.unwrap()
})
.collect();
last_reached_approval_step(&flow, &status).map(|module| module.id.clone())
};
let waiting = ("WaitingForEvents", false);
let pending = ("WaitingForPriorSteps", false);
let ran = ("Success", false);
let skipped = ("Success", true);
// Awaiting a's approval: b, itself an approval step, already holds `WaitingForEvents`.
assert_eq!(step_at(1, [ran, waiting, pending]).as_deref(), Some("a"));
assert_eq!(step_at(2, [ran, ran, waiting]).as_deref(), Some("b"));
assert_eq!(step_at(3, [ran, skipped, ran]).as_deref(), Some("a"));
assert_eq!(step_at(0, [pending, pending, pending]), None);
}
#[test]
fn approved_step_stays_gated_by_its_settings() {
let from_settings = |suspend: serde_json::Value| {
approval_conditions_from_settings(&serde_json::from_value(suspend).unwrap())
};
let login = from_settings(serde_json::json!({
"user_auth_required": true,
"user_groups_required": { "type": "static", "value": ["approvers"] }
}));
assert!(!can_view(
&None,
&login,
Some("f/team/flow"),
"trigger@example.com"
));
assert_eq!(login.unwrap().user_groups_required, ["approvers"]);
assert!(from_settings(serde_json::json!({})).is_none());
}
}