From dda59767c2b997e675ffe799bc6b1dcc79c2a52d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 31 Jul 2026 16:46:08 +0200 Subject: [PATCH] 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] --- ...d778a27619dd7fad71ba656f6b54c4915d1e.json} | 6 +- ...9c77a37ef784fdac78ca1e7a971e141e780f.json} | 6 +- ...446a5e8e23d87eebc46bbb90bdbdf128935b.json} | 6 +- backend/ee-repo-ref.txt | 2 +- backend/tests/postgres_trigger_scope.rs | 1 + backend/tests/trigger_listener_queries.rs | 1 + backend/windmill-api-auth/src/auth.rs | 14 ++- backend/windmill-api-auth/src/lib.rs | 71 ++++++++++++++ .../tests/native_triggers.rs | 1 + backend/windmill-api-jobs/src/execution.rs | 4 +- backend/windmill-api-jobs/src/types.rs | 2 + .../windmill-api-workspaces/src/workspaces.rs | 2 + backend/windmill-api/openapi.yaml | 5 + backend/windmill-api/src/jobs.rs | 21 ++-- backend/windmill-api/src/lib.rs | 1 + backend/windmill-common/src/auth.rs | 7 ++ backend/windmill-queue/src/jobs.rs | 29 +++--- .../windmill-queue/tests/native_retry_test.rs | 2 +- backend/windmill-queue/tests/schedule_push.rs | 2 +- .../windmill-trigger/src/global_handler.rs | 7 +- backend/windmill-types/src/jobs.rs | 95 +++++++++++++++++-- backend/windmill-worker/src/common.rs | 2 +- .../windmill-worker/src/result_processor.rs | 2 +- backend/windmill-worker/src/worker.rs | 4 +- .../components/runs/JobDetailFieldConfig.ts | 45 +++------ frontend/src/lib/components/triggers/utils.ts | 25 +++-- 26 files changed, 270 insertions(+), 93 deletions(-) rename backend/.sqlx/{query-67e25a7c19ea0ffaf7ea5303fcd04af5a7eb488c76f783e690af0c2153b1d6a8.json => query-09ae123099f05fe3db8bd67ca2c0d778a27619dd7fad71ba656f6b54c4915d1e.json} (93%) rename backend/.sqlx/{query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json => query-9d331b63c901071a7e82c0a4a6859c77a37ef784fdac78ca1e7a971e141e780f.json} (89%) rename backend/.sqlx/{query-d4211392e174a0e8f89c7fcebdf120e5b0f629f9f04e08a2982df33ff23ac7a9.json => query-d0df9d1fda20f0366505bf6abf67446a5e8e23d87eebc46bbb90bdbdf128935b.json} (94%) diff --git a/backend/.sqlx/query-67e25a7c19ea0ffaf7ea5303fcd04af5a7eb488c76f783e690af0c2153b1d6a8.json b/backend/.sqlx/query-09ae123099f05fe3db8bd67ca2c0d778a27619dd7fad71ba656f6b54c4915d1e.json similarity index 93% rename from backend/.sqlx/query-67e25a7c19ea0ffaf7ea5303fcd04af5a7eb488c76f783e690af0c2153b1d6a8.json rename to backend/.sqlx/query-09ae123099f05fe3db8bd67ca2c0d778a27619dd7fad71ba656f6b54c4915d1e.json index 0eb26d5dc0..60c3820b3d 100644 --- a/backend/.sqlx/query-67e25a7c19ea0ffaf7ea5303fcd04af5a7eb488c76f783e690af0c2153b1d6a8.json +++ b/backend/.sqlx/query-09ae123099f05fe3db8bd67ca2c0d778a27619dd7fad71ba656f6b54c4915d1e.json @@ -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" } diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-9d331b63c901071a7e82c0a4a6859c77a37ef784fdac78ca1e7a971e141e780f.json similarity index 89% rename from backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json rename to backend/.sqlx/query-9d331b63c901071a7e82c0a4a6859c77a37ef784fdac78ca1e7a971e141e780f.json index 3bbd0f043e..1b11e0c470 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-9d331b63c901071a7e82c0a4a6859c77a37ef784fdac78ca1e7a971e141e780f.json @@ -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" } diff --git a/backend/.sqlx/query-d4211392e174a0e8f89c7fcebdf120e5b0f629f9f04e08a2982df33ff23ac7a9.json b/backend/.sqlx/query-d0df9d1fda20f0366505bf6abf67446a5e8e23d87eebc46bbb90bdbdf128935b.json similarity index 94% rename from backend/.sqlx/query-d4211392e174a0e8f89c7fcebdf120e5b0f629f9f04e08a2982df33ff23ac7a9.json rename to backend/.sqlx/query-d0df9d1fda20f0366505bf6abf67446a5e8e23d87eebc46bbb90bdbdf128935b.json index 0bd066afd1..2578d91d10 100644 --- a/backend/.sqlx/query-d4211392e174a0e8f89c7fcebdf120e5b0f629f9f04e08a2982df33ff23ac7a9.json +++ b/backend/.sqlx/query-d0df9d1fda20f0366505bf6abf67446a5e8e23d87eebc46bbb90bdbdf128935b.json @@ -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>>\",\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>\",\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>>\",\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>\",\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" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 969e2eeb1e..45c0876efc 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -68aedf0deb0200f7e7612175177bc9d579ce11a6 \ No newline at end of file +7de7daff5eed410e0c815ad6b292d2b4303f02f2 diff --git a/backend/tests/postgres_trigger_scope.rs b/backend/tests/postgres_trigger_scope.rs index d07435f414..cf98a1e6de 100644 --- a/backend/tests/postgres_trigger_scope.rs +++ b/backend/tests/postgres_trigger_scope.rs @@ -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, } diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index 16b4e6c55e..52088557ac 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -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, } diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 988b22790d..b3ad811b7f 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -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, } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 72b63957f3..cc7fdcb167 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -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, 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) -> Option { + if trigger.is_some() { + return trigger; + } + if self.is_session_token { + return None; + } + Some(TriggerMetadata::new(None, JobTriggerKind::Webhook)) + } } impl From for Authed { @@ -130,6 +155,7 @@ impl From 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 diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 6249105ff6..8ff515a888 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -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, } diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index c3036e59ff..4fc301478a 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -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?; diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index 0d96a66750..6c2d776e30 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -455,6 +455,7 @@ impl From 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 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), diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 6a133600a1..fd42b96831 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, }) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bf70b380a7..7a2512313a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 8c88abc674..e21d196aca 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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, } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index f1c7ad4909..fdac9b27ad 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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, }, diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 1773045da4..8dfc3fc324 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -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 { diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index ea9765b833..fd80a741dc 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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, pub script_entrypoint_override: Option, pub trigger: Option, - pub trigger_kind: Option, + pub trigger_kind: Option, pub visible_to_owner: bool, pub permissioned_as_end_user_email: Option, pub runnable_settings_handle: Option, @@ -3080,7 +3080,7 @@ pub struct MiniCompletedJob { pub script_lang: Option, pub permissioned_as_email: String, pub flow_step_id: Option, - pub trigger_kind: Option, + pub trigger_kind: Option, pub trigger: Option, pub priority: Option, pub concurrent_limit: Option, @@ -3107,7 +3107,7 @@ impl From 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, + trigger_kind: &Option, trigger: &Option, ) -> Option { 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, pub permissioned_as_email: String, pub flow_step_id: Option, - pub trigger_kind: Option, + pub trigger_kind: Option, pub trigger: Option, pub priority: Option, pub concurrent_limit: Option, @@ -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, diff --git a/backend/windmill-queue/tests/native_retry_test.rs b/backend/windmill-queue/tests/native_retry_test.rs index 6b6c2d65ce..8da40b7cad 100644 --- a/backend/windmill-queue/tests/native_retry_test.rs +++ b/backend/windmill-queue/tests/native_retry_test.rs @@ -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, diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index 3ac4cd2bd9..2069ce06b7 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -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, diff --git a/backend/windmill-trigger/src/global_handler.rs b/backend/windmill-trigger/src/global_handler.rs index 00727eac68..6e1c92d588 100644 --- a/backend/windmill-trigger/src/global_handler.rs +++ b/backend/windmill-trigger/src/global_handler.rs @@ -37,10 +37,13 @@ async fn get_suspended_trigger( path: &str, ) -> Result { // Only trigger kinds backed by a `_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 diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 8385e57bd7..42f1285644 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -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 for TriggerKindLabel { + fn from(kind: JobTriggerKind) -> Self { + Self(kind.as_str().to_string()) + } +} + +impl sqlx::Type 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 { + Ok(Self( + <&str as sqlx::Decode>::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, + // 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, } 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, + // 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, } 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, + } + + 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)); + } } diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index a406f3610c..743b2acda4 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -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, }; diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index ccf12939d7..693d0295c8 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -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()); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 65b17c21aa..ccac9a3731 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -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), diff --git a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts index b324fe335e..72845b0820 100644 --- a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts +++ b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts @@ -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 = { - 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]) diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 6ff2ea3ea0..287bd29e10 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -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 + 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 /** * Converts a TriggerType to a CaptureTriggerKind when a mapping exists