feat: stamp webhook trigger_kind on token-driven job runs (#10431)

* feat: stamp ui vs webhook trigger_kind on direct job runs

* fix: gate ui trigger kind on min worker version and dedupe display names

* docs: state that the ui trigger kind attributes rather than proves

* refactor: fold the trigger fallback into one trigger_or_fallback helper

* feat: hold trigger_kind as a tolerant label on the worker paths

* chore: refresh the sqlx offline cache for the trigger_kind label queries

* chore: update ee-repo-ref to 7de7daff5eed410e0c815ad6b292d2b4303f02f2

This commit updates the EE repository reference after PR #700 was merged in windmill-ee-private.

Previous ee-repo-ref: 974ab910d9a30c5565e1198ee312acc6d11239f3

New ee-repo-ref: 7de7daff5eed410e0c815ad6b292d2b4303f02f2

Automated by sync-ee-ref workflow.

* fix: keep the API job structs tolerant of unknown trigger kinds too

* chore: point ee-repo-ref at the merged EE main

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-07-31 16:46:08 +02:00
committed by GitHub
parent 02c4a9e515
commit dda59767c2
26 changed files with 270 additions and 93 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: JobTriggerKind\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
@@ -137,7 +137,7 @@
},
{
"ordinal": 14,
"name": "trigger_kind: JobTriggerKind",
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
@@ -236,5 +236,5 @@
true
]
},
"hash": "67e25a7c19ea0ffaf7ea5303fcd04af5a7eb488c76f783e690af0c2153b1d6a8"
"hash": "09ae123099f05fe3db8bd67ca2c0d778a27619dd7fad71ba656f6b54c4915d1e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id,\n q.runnable_settings_handle,\n q.workspace_id,\n j.runnable_id as \"runnable_id: ScriptHash\",\n scheduled_for,\n parent_job,\n flow_innermost_root_job,\n runnable_path,\n kind as \"kind: JobKind\",\n started_at,\n permissioned_as,\n created_by,\n script_lang as \"script_lang: ScriptLang\",\n permissioned_as_email,\n flow_step_id,\n trigger_kind as \"trigger_kind: JobTriggerKind\",\n trigger,\n q.priority,\n concurrent_limit,\n q.tag,\n cache_ttl,\n cache_ignore_s3_path,\n r.ping as last_ping,\n worker,\n memory_peak,\n running\n FROM v2_job_queue q\n JOIN v2_job j USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1",
"query": "SELECT\n id,\n q.runnable_settings_handle,\n q.workspace_id,\n j.runnable_id as \"runnable_id: ScriptHash\",\n scheduled_for,\n parent_job,\n flow_innermost_root_job,\n runnable_path,\n kind as \"kind: JobKind\",\n started_at,\n permissioned_as,\n created_by,\n script_lang as \"script_lang: ScriptLang\",\n permissioned_as_email,\n flow_step_id,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n trigger,\n q.priority,\n concurrent_limit,\n q.tag,\n cache_ttl,\n cache_ignore_s3_path,\n r.ping as last_ping,\n worker,\n memory_peak,\n running\n FROM v2_job_queue q\n JOIN v2_job j USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1",
"describe": {
"columns": [
{
@@ -142,7 +142,7 @@
},
{
"ordinal": 15,
"name": "trigger_kind: JobTriggerKind",
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
@@ -259,5 +259,5 @@
false
]
},
"hash": "b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc"
"hash": "9d331b63c901071a7e82c0a4a6859c77a37ef784fdac78ca1e7a971e141e780f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: JobTriggerKind\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
"query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
"describe": {
"columns": [
{
@@ -227,7 +227,7 @@
},
{
"ordinal": 32,
"name": "trigger_kind: JobTriggerKind",
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
@@ -313,5 +313,5 @@
null
]
},
"hash": "d4211392e174a0e8f89c7fcebdf120e5b0f629f9f04e08a2982df33ff23ac7a9"
"hash": "d0df9d1fda20f0366505bf6abf67446a5e8e23d87eebc46bbb90bdbdf128935b"
}
+1 -1
View File
@@ -1 +1 @@
68aedf0deb0200f7e7612175177bc9d579ce11a6
7de7daff5eed410e0c815ad6b292d2b4303f02f2
+1
View File
@@ -21,6 +21,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
scopes: Some(scopes.into_iter().map(str::to_string).collect()),
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
}
@@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
}
+13 -1
View File
@@ -26,7 +26,8 @@ use windmill_common::DB;
use windmill_common::{
auth::{
get_folders_for_user, get_groups_for_user, hash_token, safe_token_prefix, JWTAuthClaims,
get_folders_for_user, get_groups_for_user, hash_token, is_session_label, safe_token_prefix,
JWTAuthClaims,
},
error::{Error, JsonResult},
jwt,
@@ -196,6 +197,7 @@ impl AuthCache {
tracing::error!("JWT auth error: workspace_id mismatch");
return None;
}
let is_session_token = is_session_label(claims.label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(claims.label);
@@ -213,6 +215,7 @@ impl AuthCache {
scopes: claims.scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: claims.audit_span,
read_only: false,
};
@@ -267,6 +270,7 @@ impl AuthCache {
(Some(owner), Some(email), super_admin, _, label, read_only)
if w_id.is_some() =>
{
let is_session_token = is_session_label(label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
@@ -312,6 +316,7 @@ impl AuthCache {
scopes: None,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
@@ -363,6 +368,7 @@ impl AuthCache {
scopes: None,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
@@ -391,6 +397,7 @@ impl AuthCache {
}
}
(_, Some(email), super_admin, scopes, label, read_only) => {
let is_session_token = is_session_label(label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(label);
if w_id.is_some() {
@@ -436,6 +443,7 @@ impl AuthCache {
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
@@ -458,6 +466,7 @@ impl AuthCache {
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
}),
@@ -482,6 +491,7 @@ impl AuthCache {
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
@@ -518,6 +528,7 @@ impl AuthCache {
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
};
@@ -726,6 +737,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
}
+71
View File
@@ -23,6 +23,8 @@ use windmill_common::{
},
db::{Authable, Authed, AuthedRef},
error::{self, Error, Result},
jobs::JobTriggerKind,
triggers::TriggerMetadata,
users::username_to_permissioned_as,
DB,
};
@@ -64,6 +66,10 @@ pub struct ApiAuthed {
/// end-user override passes a `created_by` through verbatim, and that may itself be a
/// `label-*` string. Only `username_override_from_label` sets it.
pub username_override_is_token_label: bool,
/// Whether the request authenticated with the session token minted at browser login.
/// Only `trigger_or_fallback` reads it — see `is_session_label` for why it attributes
/// rather than proves, and must not gate authority.
pub is_session_token: bool,
pub token_prefix: Option<String>,
pub read_only: bool,
}
@@ -101,6 +107,25 @@ impl ApiAuthed {
self.username_override = username_override;
self.username_override_is_token_label = false;
}
/// The `trigger_kind` a run started through a `/jobs/run*` route is stamped with: a trigger
/// that built its own metadata always wins, and a run driven by any other token — webhooks,
/// the CLI, the SDKs — is `webhook`, matching the `wm_trigger.kind` the preprocessor already
/// reports for these routes. Derived from the token, never from the request, because the
/// column is authority-bearing for other kinds (`app` marks a file as app-produced).
///
/// A browser session is left unstamped rather than marked [`JobTriggerKind::Ui`]: that label
/// is one a worker built before this release cannot decode, and it would strand the jobs
/// carrying it. `webhook` has always been decodable, so it is safe to write today.
pub fn trigger_or_fallback(&self, trigger: Option<TriggerMetadata>) -> Option<TriggerMetadata> {
if trigger.is_some() {
return trigger;
}
if self.is_session_token {
return None;
}
Some(TriggerMetadata::new(None, JobTriggerKind::Webhook))
}
}
impl From<ApiAuthed> for Authed {
@@ -130,6 +155,7 @@ impl From<Authed> for ApiAuthed {
scopes: value.scopes,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: value.token_prefix,
read_only: false,
}
@@ -883,6 +909,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
scopes: authed.scopes,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: authed.token_prefix,
read_only: false,
};
@@ -1284,6 +1311,50 @@ mod tests {
);
}
/// A browser session is the one shape left unstamped, so the Runs page can say "a token
/// started this" without claiming the converse. Every other label — every shape a member
/// can pass to `create_token` — is `webhook`.
#[test]
fn only_a_browser_session_is_left_unstamped() {
let kind_of = |label: Option<&str>| {
ApiAuthed {
is_session_token: windmill_common::auth::is_session_label(label),
..Default::default()
}
.trigger_or_fallback(None)
.map(|t| t.trigger_kind.to_string())
};
assert_eq!(kind_of(Some("session")), None);
for label in [
Some("my-personal-token"),
Some("webhook-f/svc/my_script"),
Some("Ephemeral lsp token"),
Some("ephemeral-script"),
Some("ephemeral-webhook-google-abc12"),
Some("mcp-oauth-mcp-client-9f3a1c"),
Some(""),
// A label-less token: the job WM_TOKEN, and any token created without one.
None,
] {
assert_eq!(kind_of(label).as_deref(), Some("webhook"), "label {label:?}");
}
}
/// A trigger that built its own metadata must survive the fallback, or a scheduled or
/// routed run started under a personal token would be re-attributed to a webhook.
#[test]
fn a_real_trigger_wins_over_the_token_fallback() {
let authed = ApiAuthed::default();
let schedule =
TriggerMetadata::new(Some("u/alice/nightly".to_string()), JobTriggerKind::Schedule);
let kept = authed.trigger_or_fallback(Some(schedule)).unwrap();
assert_eq!(kept.trigger_kind.to_string(), "schedule");
assert_eq!(kept.trigger_path.as_deref(), Some("u/alice/nightly"));
}
// Regression tests for the Preview path traversal: a Preview's path skips the
// DB `proper_id` CHECK and reaches the worker, where it builds on-disk module
// dirs. Traversal must be rejected even for admins, who otherwise bypass the
@@ -51,6 +51,7 @@ fn test_authed() -> ApiAuthed {
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
}
+2 -2
View File
@@ -770,7 +770,7 @@ pub async fn run_flow<'c>(
push_authed.as_ref(),
false,
None,
trigger,
authed.trigger_or_fallback(trigger),
run_query.suspended_mode,
)
.await?;
@@ -990,7 +990,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
push_authed.as_ref(),
false,
None,
trigger,
authed.trigger_or_fallback(trigger),
run_query.suspended_mode,
)
.await?;
+2
View File
@@ -455,6 +455,7 @@ impl From<UnifiedJob> for Job {
resolved_at: None,
resolution_note: None,
resolved_automatically: None,
trigger_kind: None,
},
)),
"QueuedJob" => Job::QueuedJob(JobExtended::new(
@@ -505,6 +506,7 @@ impl From<UnifiedJob> for Job {
runnable_settings_handle: uj.runnable_settings_handle,
labels: uj.labels,
is_retry: uj.is_retry,
trigger_kind: None,
},
)),
t => panic!("job type {} not valid", t),
@@ -9747,6 +9747,7 @@ async fn load_workspace_authed(
scopes: base_authed.scopes.clone(),
username_override: base_authed.username_override.clone(),
username_override_is_token_label: base_authed.username_override_is_token_label,
is_session_token: base_authed.is_session_token,
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
});
@@ -9777,6 +9778,7 @@ async fn load_workspace_authed(
scopes: base_authed.scopes.clone(),
username_override: base_authed.username_override.clone(),
username_override_is_token_label: base_authed.username_override_is_token_label,
is_session_token: base_authed.is_session_token,
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
})
+5
View File
@@ -25778,6 +25778,8 @@ components:
type: boolean
is_retry:
type: boolean
trigger_kind:
$ref: "#/components/schemas/JobTriggerKind"
worker:
type: string
required:
@@ -25919,6 +25921,8 @@ components:
true when a succeeding retry resolved this rather than a person. Explicit rather
than inferred from an absent resolved_by, which is also absent for a manual
resolution outside enterprise
trigger_kind:
$ref: "#/components/schemas/JobTriggerKind"
worker:
type: string
required:
@@ -27776,6 +27780,7 @@ components:
- asset
- freshness
- app
- ui
TriggerMode:
description: job trigger mode
+11 -10
View File
@@ -1527,7 +1527,7 @@ macro_rules! get_job_query {
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, permissioned_as, \
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} \
@@ -6208,7 +6208,7 @@ pub async fn restart_flow(
Some(&authed.clone().into()),
false,
None,
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
)
.await?;
@@ -6790,7 +6790,7 @@ pub async fn run_wait_result_job_by_path_get(
push_authed.as_ref(),
false,
None,
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
)
.await?;
@@ -6935,7 +6935,7 @@ pub async fn run_wait_result_script_by_path_internal(
push_authed.as_ref(),
false,
None,
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
)
.await?;
@@ -7061,7 +7061,7 @@ pub async fn run_wait_result_script_by_hash(
push_authed.as_ref(),
false,
None,
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
)
.await?;
@@ -7543,7 +7543,7 @@ async fn run_preview_script(
Some(&authed.clone().into()),
false,
None,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
@@ -7909,7 +7909,7 @@ async fn run_bundle_preview_script(
Some(&authed.clone().into()),
false,
None,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
@@ -8559,7 +8559,7 @@ async fn run_preview_flow_job(
Some(&authed.clone().into()),
false,
None,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
@@ -8826,7 +8826,7 @@ async fn run_dynamic_select(
Some(&authed.clone().into()),
false,
None,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
@@ -8974,7 +8974,7 @@ pub async fn run_job_by_hash_inner(
push_authed.as_ref(),
false,
None,
trigger,
authed.trigger_or_fallback(trigger),
run_query.suspended_mode,
)
.await?;
@@ -10952,6 +10952,7 @@ mod approval_view_gate_tests {
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
}
+1
View File
@@ -329,6 +329,7 @@ async fn inject_agent_authed(
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
},
+7
View File
@@ -59,6 +59,13 @@ pub fn is_server_minted_label(label: &str) -> bool {
|| label.starts_with("mcp-oauth-")
}
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
pub fn is_session_label(label: Option<&str>) -> bool {
label == Some("session")
}
/// Hash a raw token using SHA-256 (hex-encoded, 64 chars).
/// Used to store and look up tokens without keeping plaintext in the DB.
pub fn hash_token(token: &str) -> String {
+14 -15
View File
@@ -41,7 +41,7 @@ use windmill_common::audit::AuditAuthor;
use windmill_common::auth::JobPerms;
#[cfg(feature = "benchmark")]
use windmill_common::bench::BenchmarkIter;
use windmill_common::jobs::{JobTriggerKind, EMAIL_ERROR_HANDLER_USER_EMAIL};
use windmill_common::jobs::{JobTriggerKind, TriggerKindLabel, EMAIL_ERROR_HANDLER_USER_EMAIL};
use windmill_common::min_version::{
MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2,
};
@@ -3004,7 +3004,7 @@ pub struct MiniPulledJob {
pub preprocessed: Option<bool>,
pub script_entrypoint_override: Option<String>,
pub trigger: Option<String>,
pub trigger_kind: Option<JobTriggerKind>,
pub trigger_kind: Option<TriggerKindLabel>,
pub visible_to_owner: bool,
pub permissioned_as_end_user_email: Option<String>,
pub runnable_settings_handle: Option<i64>,
@@ -3080,7 +3080,7 @@ pub struct MiniCompletedJob {
pub script_lang: Option<ScriptLang>,
pub permissioned_as_email: String,
pub flow_step_id: Option<String>,
pub trigger_kind: Option<JobTriggerKind>,
pub trigger_kind: Option<TriggerKindLabel>,
pub trigger: Option<String>,
pub priority: Option<i16>,
pub concurrent_limit: Option<i32>,
@@ -3107,7 +3107,7 @@ impl From<QueuedJobV2> for MiniCompletedJob {
script_lang: job.script_lang,
permissioned_as_email: job.permissioned_as_email,
flow_step_id: job.flow_step_id,
trigger_kind: job.trigger_kind,
trigger_kind: job.trigger_kind.map(Into::into),
trigger: job.trigger,
priority: job.priority,
concurrent_limit: job.concurrent_limit,
@@ -3196,12 +3196,12 @@ impl MiniCompletedJob {
}
fn schedule_path(
trigger_kind: &Option<JobTriggerKind>,
trigger_kind: &Option<TriggerKindLabel>,
trigger: &Option<String>,
) -> Option<String> {
if trigger_kind
.as_ref()
.is_some_and(|t| matches!(t, JobTriggerKind::Schedule))
.is_some_and(|t| t.is(JobTriggerKind::Schedule))
{
trigger.clone()
} else {
@@ -3279,11 +3279,10 @@ impl MiniPulledJob {
preprocessed: job.preprocessed.clone(),
script_entrypoint_override: job.script_entrypoint_override.clone(),
trigger: job.schedule_path.clone(),
trigger_kind: if job.schedule_path.is_some() {
Some(JobTriggerKind::Schedule)
} else {
None
},
trigger_kind: job
.schedule_path
.is_some()
.then(|| JobTriggerKind::Schedule.into()),
visible_to_owner: job.visible_to_owner.clone(),
permissioned_as_end_user_email: None,
}
@@ -3493,7 +3492,7 @@ pub async fn get_mini_pulled_job<'c>(
preprocessed,
script_entrypoint_override,
trigger,
trigger_kind as \"trigger_kind: JobTriggerKind\",
trigger_kind as \"trigger_kind: TriggerKindLabel\",
visible_to_owner,
NULL as permissioned_as_end_user_email
FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
@@ -3520,7 +3519,7 @@ pub struct QueuedJobV2 {
pub script_lang: Option<ScriptLang>,
pub permissioned_as_email: String,
pub flow_step_id: Option<String>,
pub trigger_kind: Option<JobTriggerKind>,
pub trigger_kind: Option<TriggerKindLabel>,
pub trigger: Option<String>,
pub priority: Option<i16>,
pub concurrent_limit: Option<i32>,
@@ -3562,7 +3561,7 @@ pub async fn get_queued_job_v2<'c>(
script_lang as "script_lang: ScriptLang",
permissioned_as_email,
flow_step_id,
trigger_kind as "trigger_kind: JobTriggerKind",
trigger_kind as "trigger_kind: TriggerKindLabel",
trigger,
q.priority,
concurrent_limit,
@@ -5062,7 +5061,7 @@ pub fn get_mini_completed_job<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres>
MiniCompletedJob,
"SELECT
j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,
j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: JobTriggerKind\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle
j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle
FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id
WHERE j.id = $1 AND j.workspace_id = $2",
id,
@@ -36,7 +36,7 @@ mod native_retry {
script_lang: Some(ScriptLang::Deno),
permissioned_as_email: "test@windmill.dev".to_string(),
flow_step_id: None,
trigger_kind: Some(JobTriggerKind::Schedule),
trigger_kind: Some(JobTriggerKind::Schedule.into()),
trigger: Some(SCHED.to_string()),
priority: None,
concurrent_limit: None,
@@ -83,7 +83,7 @@ mod schedule_push {
script_lang: None,
permissioned_as_email: schedule.email.clone(),
flow_step_id: None,
trigger_kind: Some(JobTriggerKind::Schedule),
trigger_kind: Some(JobTriggerKind::Schedule.into()),
trigger: Some(schedule.path.clone()),
priority: None,
concurrent_limit: None,
@@ -37,10 +37,13 @@ async fn get_suspended_trigger(
path: &str,
) -> Result<SuspendedTrigger> {
// Only trigger kinds backed by a `<kind>_trigger` table support reassignment.
// `app` (and webhook/schedule) have no such table, so reject them with a clear
// `app`/`ui` (and webhook/schedule) have no such table, so reject them with a clear
// error instead of failing on a missing-relation database error below.
match trigger_kind {
JobTriggerKind::Webhook | JobTriggerKind::Schedule | JobTriggerKind::App => {
JobTriggerKind::Webhook
| JobTriggerKind::Schedule
| JobTriggerKind::App
| JobTriggerKind::Ui => {
return Err(Error::BadRequest(format!(
"{} triggers do not support job reassignment",
trigger_kind
+89 -6
View File
@@ -55,11 +55,17 @@ pub enum JobTriggerKind {
// direct `/jobs/run` cannot set it, so it distinguishes files an app actually
// produced from files a viewer forged by running a declared runnable directly.
App,
// A run started from the browser (the `/jobs/run*` request carried the session token
// minted at login). Nothing writes this yet — it exists so every binary from this release
// can decode it, which is what a later release needs before it can start stamping it.
// When that happens: attribution, not authority, unlike `App` — a member can have a
// session token minted for a script (`/users/refresh_token`), so nothing may gate on it.
Ui,
}
impl std::fmt::Display for JobTriggerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let kind = match self {
impl JobTriggerKind {
pub const fn as_str(&self) -> &'static str {
match self {
JobTriggerKind::Webhook => "webhook",
JobTriggerKind::Http => "http",
JobTriggerKind::Websocket => "websocket",
@@ -80,8 +86,52 @@ impl std::fmt::Display for JobTriggerKind {
JobTriggerKind::Asset => "asset",
JobTriggerKind::Freshness => "freshness",
JobTriggerKind::App => "app",
};
write!(f, "{}", kind)
JobTriggerKind::Ui => "ui",
}
}
}
impl std::fmt::Display for JobTriggerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// `v2_job.trigger_kind` as a worker reads it back, kept as the raw label rather than as
/// [`JobTriggerKind`]. A worker has to keep running jobs stamped with a kind added after it was
/// built, and decoding into the enum turns an unfamiliar label into a hard error — sqlx on the
/// pull query, serde on the agent-worker payload — which strands the job instead of running it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TriggerKindLabel(pub String);
impl TriggerKindLabel {
pub fn is(&self, kind: JobTriggerKind) -> bool {
self.0 == kind.as_str()
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<JobTriggerKind> for TriggerKindLabel {
fn from(kind: JobTriggerKind) -> Self {
Self(kind.as_str().to_string())
}
}
impl sqlx::Type<sqlx::Postgres> for TriggerKindLabel {
fn type_info() -> sqlx::postgres::PgTypeInfo {
sqlx::postgres::PgTypeInfo::with_name("JOB_TRIGGER_KIND")
}
}
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for TriggerKindLabel {
fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
Ok(Self(
<&str as sqlx::Decode<sqlx::Postgres>>::decode(value)?.to_owned(),
))
}
}
@@ -252,6 +302,13 @@ pub struct QueuedJob {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub is_retry: Option<bool>,
// How the run was started. NULL on every job pushed before the API began stamping it, and
// on the paths that still don't, so the run page treats it as "unknown" rather than
// "manual". `#[sqlx(default)]` lets the queries that don't select it omit the column, and
// the label type keeps a kind added after this binary was built readable instead of fatal.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub trigger_kind: Option<TriggerKindLabel>,
}
impl QueuedJob {
@@ -328,6 +385,7 @@ impl Default for QueuedJob {
runnable_settings_handle: None,
labels: None,
is_retry: None,
trigger_kind: None,
}
}
}
@@ -415,6 +473,13 @@ pub struct CompletedJob {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub resolved_automatically: Option<bool>,
// How the run was started. NULL on every job pushed before the API began stamping it, and
// on the paths that still don't, so the run page treats it as "unknown" rather than
// "manual". `#[sqlx(default)]` lets the queries that don't select it omit the column, and
// the label type keeps a kind added after this binary was built readable instead of fatal.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub trigger_kind: Option<TriggerKindLabel>,
}
impl CompletedJob {
@@ -660,7 +725,7 @@ pub fn generate_dynamic_input_key(workspace_id: &str, path: &str) -> String {
#[cfg(test)]
mod tests {
use super::is_valid_entrypoint_name;
use super::{is_valid_entrypoint_name, JobTriggerKind, TriggerKindLabel};
#[test]
fn valid_entrypoint_names_are_accepted() {
@@ -705,4 +770,22 @@ mod tests {
// Over-long names are rejected.
assert!(!is_valid_entrypoint_name(&"a".repeat(256)));
}
/// The point of [`TriggerKindLabel`]: a worker built before a trigger kind existed must
/// still be able to read — and therefore run — a job stamped with it. Decoding into
/// [`JobTriggerKind`] instead makes an unfamiliar label a hard error and strands the job.
#[test]
fn an_unknown_trigger_kind_label_survives_deserialization() {
#[derive(serde::Deserialize)]
struct Job {
trigger_kind: Option<TriggerKindLabel>,
}
let job: Job = serde_json::from_str(r#"{"trigger_kind":"a_kind_from_the_future"}"#).unwrap();
assert_eq!(job.trigger_kind.as_ref().map(|k| k.as_str()), Some("a_kind_from_the_future"));
assert!(!job.trigger_kind.unwrap().is(JobTriggerKind::Schedule));
let job: Job = serde_json::from_str(r#"{"trigger_kind":"schedule"}"#).unwrap();
assert!(job.trigger_kind.unwrap().is(JobTriggerKind::Schedule));
}
}
+1 -1
View File
@@ -528,7 +528,7 @@ pub async fn get_reserved_variables(
};
let tested_runnable = match (&job.trigger_kind, &job.trigger) {
(Some(windmill_common::jobs::JobTriggerKind::CiTest), Some(t)) => Some(t.clone()),
(Some(k), Some(t)) if k.is(windmill_common::jobs::JobTriggerKind::CiTest) => Some(t.clone()),
_ => None,
};
@@ -212,7 +212,7 @@ async fn process_jc(
span.record("root_job", root_job.to_string().as_str());
}
if let Some(trigger_kind) = jc.job.trigger_kind.as_ref() {
span.record("trigger_kind", trigger_kind.to_string().as_str());
span.record("trigger_kind", trigger_kind.as_str());
}
if let Some(trigger) = jc.job.trigger.as_ref() {
span.record("trigger", trigger.as_str());
+2 -2
View File
@@ -1576,7 +1576,7 @@ pub fn create_span_with_name(
span.record("hostname", hostname);
}
if let Some(trigger_kind) = arc_job.trigger_kind.as_ref() {
span.record("trigger_kind", trigger_kind.to_string().as_str());
span.record("trigger_kind", trigger_kind.as_str());
}
if let Some(trigger) = arc_job.trigger.as_ref() {
span.record("trigger", trigger.as_str());
@@ -1686,7 +1686,7 @@ pub fn log_context_for_job(
flow_step_id: arc_job.flow_step_id.clone(),
parent_job: arc_job.parent_job.map(|id| id.to_string()),
root_job: arc_job.flow_innermost_root_job.map(|id| id.to_string()),
trigger_kind: arc_job.trigger_kind.as_ref().map(|k| k.to_string()),
trigger_kind: arc_job.trigger_kind.as_ref().map(|k| k.as_str().to_string()),
trigger: arc_job.trigger.clone(),
hostname: hostname.map(|h| h.to_string()),
inbound_traceparent: job_inbound_traceparent(arc_job),
@@ -1,5 +1,5 @@
import type { Job } from '$lib/gen'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { triggerDisplayNamesMap, triggerIconMap } from '$lib/components/triggers/utils'
import { formatMemory } from '$lib/utils'
import { flowPathToHref } from '$lib/scripts'
import { Calendar, Bot } from 'lucide-svelte'
@@ -125,14 +125,13 @@ export function getTriggerInfo(job: Job): { type: string; icon: any; detail?: st
}
// Check for trigger type from job trigger_kind if available
if ('trigger_kind' in job) {
const triggerKind = (job as any).trigger_kind
if (triggerKind && triggerIconMap[triggerKind]) {
return {
type: getTriggerDisplayName(triggerKind),
icon: triggerIconMap[triggerKind],
detail: triggerKind
}
const triggerKind = job.trigger_kind
if (triggerKind && triggerKind in triggerIconMap) {
// No detail: unlike the schedule branch above, the kind carries no path to add,
// and repeating it would render as "Webhook: webhook".
return {
type: triggerDisplayNamesMap[triggerKind],
icon: triggerIconMap[triggerKind]
}
}
@@ -154,28 +153,6 @@ export function getTriggerInfo(job: Job): { type: string; icon: any; detail?: st
return null
}
/**
* Gets human-readable display name for trigger kinds
*/
function getTriggerDisplayName(triggerKind: string): string {
const displayNames: Record<string, string> = {
webhook: 'Webhook',
http: 'HTTP',
websocket: 'WebSocket',
postgres: 'PostgreSQL',
kafka: 'Kafka',
nats: 'NATS',
mqtt: 'MQTT',
sqs: 'SQS',
gcp: 'GCP Pub/Sub',
email: 'Email',
schedule: 'Schedule',
app: 'App',
ui: 'UI'
}
return displayNames[triggerKind] || triggerKind.toUpperCase()
}
/**
* Field configurations for all possible fields
*/
@@ -556,6 +533,12 @@ export function getRelevantFields(job: Job): FieldConfig[] {
// Always show schedule_path when it exists, regardless of category configuration
return job.schedule_path !== null && job.schedule_path !== undefined
}
if (fieldName === 'trigger_info' && job.trigger_kind) {
// A stamped trigger kind is how the run was started, whatever the category — but
// only one `getTriggerInfo` can name, or the row renders empty. A schedule is
// left out because its own field already says the same thing.
return !job.schedule_path && job.trigger_kind in triggerIconMap
}
return fieldsPresence[fieldName]
})
.map((fieldName) => fieldConfigs[fieldName])
+15 -10
View File
@@ -8,7 +8,8 @@ import {
Terminal,
Timer,
Zap,
LayoutDashboard
LayoutDashboard,
MousePointerClick
} from 'lucide-svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
@@ -101,6 +102,8 @@ export const jobTriggerKinds: JobTriggerKind[] = [
'asset',
'freshness',
'app'
// `ui` is deliberately absent: the backend does not stamp it yet, so offering it here
// would be a filter that can only ever return nothing.
]
export type Trigger = {
@@ -138,13 +141,14 @@ export const triggerIconMap = {
nextcloud: NextcloudIcon,
google: GoogleIcon,
github: GithubIcon,
// Job-attribution-only kinds (no trigger CRUD page): the pipeline asset
// cascade, the freshness watchdog, and app-component runs. Needed so the Runs
// filter and job detail render these trigger kinds instead of a blank label /
// no icon.
// Job-attribution-only kinds (no trigger CRUD page): the pipeline asset cascade,
// the freshness watchdog and app-component runs. Needed so the Runs filter and job
// detail render these trigger kinds instead of a blank label / no icon. `ui` is
// mapped ahead of the backend stamping it, so the day it does nothing renders blank.
asset: Zap,
freshness: Timer,
app: LayoutDashboard
app: LayoutDashboard,
ui: MousePointerClick
}
export const triggerDisplayNamesMap = {
@@ -169,10 +173,11 @@ export const triggerDisplayNamesMap = {
github: 'GitHub',
asset: 'Asset cascade',
freshness: 'Freshness',
app: 'App'
// `asset` / `freshness` / `app` are job-attribution-only (JobTriggerKind, not
// TriggerType) — hence the union in the satisfies below.
} as const satisfies Record<TriggerType | 'asset' | 'freshness' | 'app', string>
app: 'App',
ui: 'UI'
// `asset` / `freshness` / `app` / `ui` are job-attribution-only (JobTriggerKind,
// not TriggerType) — hence the union in the satisfies below.
} as const satisfies Record<TriggerType | 'asset' | 'freshness' | 'app' | 'ui', string>
/**
* Converts a TriggerType to a CaptureTriggerKind when a mapping exists