From 8e95bfe6157ebe4f8fc5f6e13c36a6f0bdb9969d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 7 Aug 2026 12:37:04 +0200 Subject: [PATCH 001/192] fix: scope a fork's cloned app policy and custom path to its creator (#10589) * fix: scope cloned app policy and custom path to the fork's creator Co-Authored-By: Claude Opus 5 (1M context) * refactor: share the app custom-path scoping rule across its call sites Co-Authored-By: Claude Opus 5 (1M context) * docs: tighten the cloned-app-policy comments Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the execution_mode and custom-path scoping rationale Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...30629a82f8d2150be91cff20ad7e400e0135f.json | 22 +++++ ...8d556534d74b06570bb16be7a2f4530451651.json | 26 +++++ ...918bc042b58714ba5d66568770e7f11153ece.json | 14 +++ .../tests/fork_clone_on_behalf_of.rs | 99 ++++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 73 ++++++++++---- backend/windmill-api/src/apps.rs | 12 +-- backend/windmill-common/src/apps.rs | 7 ++ 7 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json create mode 100644 backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json create mode 100644 backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json diff --git a/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json new file mode 100644 index 0000000000..05c3ac8a7a --- /dev/null +++ b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path)\n VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path')\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f" +} diff --git a/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json new file mode 100644 index 0000000000..395d05d6da --- /dev/null +++ b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true + ] + }, + "hash": "e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651" +} diff --git a/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json new file mode 100644 index 0000000000..a2751953bd --- /dev/null +++ b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece" +} diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index 93c9f5adaf..2b9f63d392 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -3,6 +3,97 @@ use sqlx::{Pool, Postgres}; use windmill_test_utils::*; +/// Seed an anonymous public app owned by the parent's admin, then fork as `token`. Returns the +/// cloned app's policy and custom path. +async fn fork_with_public_app( + db: &Pool, + token: &str, +) -> anyhow::Result<(serde_json::Value, Option)> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + let app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path) + VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path') + RETURNING id", + json!({ + "on_behalf_of": "u/test-user", + "on_behalf_of_email": "test@windmill.dev", + "execution_mode": "anonymous", + }) + ) + .fetch_one(db) + .await?; + // The clone re-aggregates `versions` from `app_version`, so an app without one lands in the + // fork with a NULL array. + sqlx::query!( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + VALUES ($1, '{}'::json, 'test-user') RETURNING id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + app_id + ) + .execute(db) + .await?; + + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "id": "wm-fork-app", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let cloned = + sqlx::query!("SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'") + .fetch_one(db) + .await?; + Ok((cloned.policy, cloned.custom_path)) +} + +/// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs +/// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may +/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a +/// plain member of the parent. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_downgrades_app_policy_for_unprivileged_creator( + db: Pool, +) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user-2")); + assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev")); + assert_eq!(policy["execution_mode"], json!("publisher")); + assert_eq!(custom_path, None); + + Ok(()) +} + +/// An admin could have set any of this through the app API, so their fork keeps the policy — which +/// is also what keeps dev workspaces, always admin-created, behaving like their parent. The custom +/// path still goes: it is the instance-wide address of the parent's live public app, and two rows +/// claiming it make it resolve to either one. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_keeps_app_policy_for_admin_creator(db: Pool) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user")); + assert_eq!(policy["on_behalf_of_email"], json!("test@windmill.dev")); + assert_eq!(policy["execution_mode"], json!("anonymous")); + assert_eq!(custom_path, None); + + Ok(()) +} + /// A principal only means something in the workspace whose `usr`/`group_` rows define it, and a /// fork copies the creator and the groups but not the rest of the membership. Carrying one over /// blindly would leave a runnable naming somebody who cannot authenticate there; dropping them @@ -106,11 +197,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any .count(); assert_eq!(orphaned, 0, "a dropped principal leaves no address behind"); assert_eq!( - sqlx::query_scalar!( - "SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'" - ) - .fetch_one(&db) - .await?, + sqlx::query_scalar!("SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'") + .fetch_one(&db) + .await?, None ); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 5b08e7e5de..80db26fe47 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5377,16 +5377,16 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } -// `authed_email` is the forker's email — `clone_drafts` only carries this -// user's per-user drafts (and the legacy NULL-email workspace draft, if any) -// across, since other users aren't added to the fork's `usr` table and -// their drafts would dangle as orphans. +// `authed` is the forker — `clone_drafts` only carries this user's per-user +// drafts (and the legacy NULL-email workspace draft, if any) across, since other +// users aren't added to the fork's `usr` table and their drafts would dangle as +// orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, db: &DB, source_workspace_id: &str, target_workspace_id: &str, - authed_email: &str, + authed: &ApiAuthed, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -5440,7 +5440,7 @@ async fn clone_workspace_data( clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; // Clone apps with new IDs and app scripts - let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; + let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; @@ -5451,7 +5451,7 @@ async fn clone_workspace_data( // own a `usr` row in the fork (see `clone_workspace_full`) so their // drafts would dangle and the home-page `draft_users` aggregate would // surface them as duplicate legacy entries. - clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?; // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -6392,11 +6392,45 @@ async fn clone_flow_nodes( Ok(()) } +/// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app` +/// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what +/// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives +/// any revocation in the parent. Anonymous apps also lose their unauthenticated endpoint; +/// re-publishing goes through the app API, which enforces the anonymous-deployment rule. +fn downgrade_cloned_app_policy(policy: &mut serde_json::Value, authed: &ApiAuthed) { + let Some(obj) = policy.as_object_mut() else { + return; + }; + obj.insert( + "on_behalf_of".to_string(), + serde_json::Value::String(username_to_permissioned_as(&authed.username)), + ); + obj.insert( + "on_behalf_of_email".to_string(), + serde_json::Value::String(authed.email.clone()), + ); + // A policy without `execution_mode` gets one too: `Policy` declares no serde default + // for the field, so such a row does not read back as a policy until something writes it. + let anonymous = obj + .get("execution_mode") + .and_then(|m| m.as_str()) + .unwrap_or("anonymous") + == "anonymous"; + if anonymous { + obj.insert( + "execution_mode".to_string(), + serde_json::Value::String("publisher".to_string()), + ); + } +} + async fn clone_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed: &ApiAuthed, ) -> Result> { + let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed); // Get all apps from source workspace let apps = sqlx::query!( "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path @@ -6416,10 +6450,21 @@ async fn clone_apps( let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs - for app in apps { + for mut app in apps { if let Some(¤t_version) = app.versions.last() { latest_version_ids.insert(current_version); } + if !preserve_identity { + downgrade_cloned_app_policy(&mut app.policy, authed); + } + // An instance-wide custom path addresses one app (`create_app` rejects one already + // taken in any workspace), so a clone that kept it would make the parent's live + // public URL resolve to either row. + let custom_path = if windmill_common::apps::custom_path_is_workspace_scoped() { + app.custom_path + } else { + None + }; let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -6430,7 +6475,7 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.custom_path, + custom_path, ) .fetch_one(&mut **tx) .await?; @@ -7283,14 +7328,8 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = clone_workspace_data( - &mut tx, - &db, - &parent_workspace_id, - &forked_id, - &authed.email, - ) - .await + if let Err(e) = + clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 037f701346..35741c6cc2 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -56,7 +56,7 @@ use std::str; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ - apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, + apps::{AppScriptId, ListAppQuery}, auth::TOKEN_PREFIX_LEN, cache::{self, future::FutureCachedExt}, db::{DbWithOptAuthed, UserDB}, @@ -1114,13 +1114,13 @@ async fn custom_path_exists( Extension(db): Extension, Path((w_id, custom_path)): Path<(String, String)>, ) -> JsonResult { - let as_workspaced_route = APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))", custom_path, - if *CLOUD_HOSTED || as_workspaced_route { Some(&w_id) } else { None } + if scoped { Some(&w_id) } else { None } ) .fetch_one(&db) .await?.unwrap_or(false); @@ -2179,8 +2179,7 @@ async fn create_app_internal<'a>( } if let Some(custom_path) = &app.custom_path { require_admin(authed.is_admin, &authed.username)?; - let scoped = - *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); let conflict = sqlx::query!( "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1", @@ -3055,8 +3054,7 @@ async fn update_app_internal<'a>( if let Some(ncustom_path) = &ns.custom_path { require_admin(authed.is_admin, &authed.username)?; - let scoped = - *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); if ncustom_path.is_empty() { sqlb.set("custom_path", "NULL"); diff --git a/backend/windmill-common/src/apps.rs b/backend/windmill-common/src/apps.rs index 36b2d9b704..e1caa784bd 100644 --- a/backend/windmill-common/src/apps.rs +++ b/backend/windmill-common/src/apps.rs @@ -18,6 +18,13 @@ lazy_static::lazy_static! { pub static ref APP_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); } +/// Whether an app's custom path names it within its workspace rather than instance-wide. +/// A path stored under a narrower scope than the one its resolver applies leaves two apps +/// answering the same public URL, so storage and resolution must decide it the same way. +pub fn custom_path_is_workspace_scoped() -> bool { + *crate::worker::CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed) +} + /// Traverse FlowValue while invoking provided by caller callback on leafs // #[async_recursion::async_recursion(?Send)] pub fn traverse_app_inline_scripts< From 330175f83b006c00b0f49b2864df0ce2956bae39 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 7 Aug 2026 12:39:44 +0200 Subject: [PATCH 002/192] =?UTF-8?q?Revert=20"fix:=20scope=20a=20fork's=20c?= =?UTF-8?q?loned=20app=20policy=20and=20custom=20path=20to=20its=20creator?= =?UTF-8?q?=20=E2=80=A6"=20(#10592)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8e95bfe6157ebe4f8fc5f6e13c36a6f0bdb9969d. --- ...30629a82f8d2150be91cff20ad7e400e0135f.json | 22 ----- ...8d556534d74b06570bb16be7a2f4530451651.json | 26 ----- ...918bc042b58714ba5d66568770e7f11153ece.json | 14 --- .../tests/fork_clone_on_behalf_of.rs | 99 +------------------ .../windmill-api-workspaces/src/workspaces.rs | 73 ++++---------- backend/windmill-api/src/apps.rs | 12 ++- backend/windmill-common/src/apps.rs | 7 -- 7 files changed, 29 insertions(+), 224 deletions(-) delete mode 100644 backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json delete mode 100644 backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json delete mode 100644 backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json diff --git a/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json deleted file mode 100644 index 05c3ac8a7a..0000000000 --- a/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path)\n VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path')\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Jsonb" - ] - }, - "nullable": [ - false - ] - }, - "hash": "10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f" -} diff --git a/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json deleted file mode 100644 index 395d05d6da..0000000000 --- a/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "policy", - "type_info": "Jsonb" - }, - { - "ordinal": 1, - "name": "custom_path", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - true - ] - }, - "hash": "e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651" -} diff --git a/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json deleted file mode 100644 index a2751953bd..0000000000 --- a/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece" -} diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index 2b9f63d392..93c9f5adaf 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -3,97 +3,6 @@ use sqlx::{Pool, Postgres}; use windmill_test_utils::*; -/// Seed an anonymous public app owned by the parent's admin, then fork as `token`. Returns the -/// cloned app's policy and custom path. -async fn fork_with_public_app( - db: &Pool, - token: &str, -) -> anyhow::Result<(serde_json::Value, Option)> { - initialize_tracing().await; - - let server = ApiServer::start(db.clone()).await?; - let base_url = format!("http://localhost:{}/api", server.addr.port()); - - let app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path) - VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path') - RETURNING id", - json!({ - "on_behalf_of": "u/test-user", - "on_behalf_of_email": "test@windmill.dev", - "execution_mode": "anonymous", - }) - ) - .fetch_one(db) - .await?; - // The clone re-aggregates `versions` from `app_version`, so an app without one lands in the - // fork with a NULL array. - sqlx::query!( - "WITH v AS ( - INSERT INTO app_version (app_id, value, created_by) - VALUES ($1, '{}'::json, 'test-user') RETURNING id - ) - UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", - app_id - ) - .execute(db) - .await?; - - let resp = reqwest::Client::new() - .post(format!( - "{base_url}/w/test-workspace/workspaces/create_fork" - )) - .header("Authorization", format!("Bearer {token}")) - .json(&json!({ "id": "wm-fork-app", "name": "Fork", "color": "#0000ff" })) - .send() - .await?; - assert!( - resp.status().is_success(), - "creating the fork: {}", - resp.text().await? - ); - - let cloned = - sqlx::query!("SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'") - .fetch_one(db) - .await?; - Ok((cloned.policy, cloned.custom_path)) -} - -/// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs -/// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may -/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a -/// plain member of the parent. -#[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_fork_downgrades_app_policy_for_unprivileged_creator( - db: Pool, -) -> anyhow::Result<()> { - let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?; - - assert_eq!(policy["on_behalf_of"], json!("u/test-user-2")); - assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev")); - assert_eq!(policy["execution_mode"], json!("publisher")); - assert_eq!(custom_path, None); - - Ok(()) -} - -/// An admin could have set any of this through the app API, so their fork keeps the policy — which -/// is also what keeps dev workspaces, always admin-created, behaving like their parent. The custom -/// path still goes: it is the instance-wide address of the parent's live public app, and two rows -/// claiming it make it resolve to either one. -#[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_fork_keeps_app_policy_for_admin_creator(db: Pool) -> anyhow::Result<()> { - let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN").await?; - - assert_eq!(policy["on_behalf_of"], json!("u/test-user")); - assert_eq!(policy["on_behalf_of_email"], json!("test@windmill.dev")); - assert_eq!(policy["execution_mode"], json!("anonymous")); - assert_eq!(custom_path, None); - - Ok(()) -} - /// A principal only means something in the workspace whose `usr`/`group_` rows define it, and a /// fork copies the creator and the groups but not the rest of the membership. Carrying one over /// blindly would leave a runnable naming somebody who cannot authenticate there; dropping them @@ -197,9 +106,11 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any .count(); assert_eq!(orphaned, 0, "a dropped principal leaves no address behind"); assert_eq!( - sqlx::query_scalar!("SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'") - .fetch_one(&db) - .await?, + sqlx::query_scalar!( + "SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'" + ) + .fetch_one(&db) + .await?, None ); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 80db26fe47..5b08e7e5de 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5377,16 +5377,16 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } -// `authed` is the forker — `clone_drafts` only carries this user's per-user -// drafts (and the legacy NULL-email workspace draft, if any) across, since other -// users aren't added to the fork's `usr` table and their drafts would dangle as -// orphans. +// `authed_email` is the forker's email — `clone_drafts` only carries this +// user's per-user drafts (and the legacy NULL-email workspace draft, if any) +// across, since other users aren't added to the fork's `usr` table and +// their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, db: &DB, source_workspace_id: &str, target_workspace_id: &str, - authed: &ApiAuthed, + authed_email: &str, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -5440,7 +5440,7 @@ async fn clone_workspace_data( clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; // Clone apps with new IDs and app scripts - let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; + let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; @@ -5451,7 +5451,7 @@ async fn clone_workspace_data( // own a `usr` row in the fork (see `clone_workspace_full`) so their // drafts would dangle and the home-page `draft_users` aggregate would // surface them as duplicate legacy entries. - clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?; + clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -6392,45 +6392,11 @@ async fn clone_flow_nodes( Ok(()) } -/// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app` -/// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what -/// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives -/// any revocation in the parent. Anonymous apps also lose their unauthenticated endpoint; -/// re-publishing goes through the app API, which enforces the anonymous-deployment rule. -fn downgrade_cloned_app_policy(policy: &mut serde_json::Value, authed: &ApiAuthed) { - let Some(obj) = policy.as_object_mut() else { - return; - }; - obj.insert( - "on_behalf_of".to_string(), - serde_json::Value::String(username_to_permissioned_as(&authed.username)), - ); - obj.insert( - "on_behalf_of_email".to_string(), - serde_json::Value::String(authed.email.clone()), - ); - // A policy without `execution_mode` gets one too: `Policy` declares no serde default - // for the field, so such a row does not read back as a policy until something writes it. - let anonymous = obj - .get("execution_mode") - .and_then(|m| m.as_str()) - .unwrap_or("anonymous") - == "anonymous"; - if anonymous { - obj.insert( - "execution_mode".to_string(), - serde_json::Value::String("publisher".to_string()), - ); - } -} - async fn clone_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, - authed: &ApiAuthed, ) -> Result> { - let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed); // Get all apps from source workspace let apps = sqlx::query!( "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path @@ -6450,21 +6416,10 @@ async fn clone_apps( let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs - for mut app in apps { + for app in apps { if let Some(¤t_version) = app.versions.last() { latest_version_ids.insert(current_version); } - if !preserve_identity { - downgrade_cloned_app_policy(&mut app.policy, authed); - } - // An instance-wide custom path addresses one app (`create_app` rejects one already - // taken in any workspace), so a clone that kept it would make the parent's live - // public URL resolve to either row. - let custom_path = if windmill_common::apps::custom_path_is_workspace_scoped() { - app.custom_path - } else { - None - }; let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -6475,7 +6430,7 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - custom_path, + app.custom_path, ) .fetch_one(&mut **tx) .await?; @@ -7328,8 +7283,14 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = - clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await + if let Err(e) = clone_workspace_data( + &mut tx, + &db, + &parent_workspace_id, + &forked_id, + &authed.email, + ) + .await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 35741c6cc2..037f701346 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -56,7 +56,7 @@ use std::str; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ - apps::{AppScriptId, ListAppQuery}, + apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, auth::TOKEN_PREFIX_LEN, cache::{self, future::FutureCachedExt}, db::{DbWithOptAuthed, UserDB}, @@ -1114,13 +1114,13 @@ async fn custom_path_exists( Extension(db): Extension, Path((w_id, custom_path)): Path<(String, String)>, ) -> JsonResult { - let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); + let as_workspaced_route = APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))", custom_path, - if scoped { Some(&w_id) } else { None } + if *CLOUD_HOSTED || as_workspaced_route { Some(&w_id) } else { None } ) .fetch_one(&db) .await?.unwrap_or(false); @@ -2179,7 +2179,8 @@ async fn create_app_internal<'a>( } if let Some(custom_path) = &app.custom_path { require_admin(authed.is_admin, &authed.username)?; - let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); + let scoped = + *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); let conflict = sqlx::query!( "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1", @@ -3054,7 +3055,8 @@ async fn update_app_internal<'a>( if let Some(ncustom_path) = &ns.custom_path { require_admin(authed.is_admin, &authed.username)?; - let scoped = windmill_common::apps::custom_path_is_workspace_scoped(); + let scoped = + *CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); if ncustom_path.is_empty() { sqlb.set("custom_path", "NULL"); diff --git a/backend/windmill-common/src/apps.rs b/backend/windmill-common/src/apps.rs index e1caa784bd..36b2d9b704 100644 --- a/backend/windmill-common/src/apps.rs +++ b/backend/windmill-common/src/apps.rs @@ -18,13 +18,6 @@ lazy_static::lazy_static! { pub static ref APP_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); } -/// Whether an app's custom path names it within its workspace rather than instance-wide. -/// A path stored under a narrower scope than the one its resolver applies leaves two apps -/// answering the same public URL, so storage and resolution must decide it the same way. -pub fn custom_path_is_workspace_scoped() -> bool { - *crate::worker::CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed) -} - /// Traverse FlowValue while invoking provided by caller callback on leafs // #[async_recursion::async_recursion(?Send)] pub fn traverse_app_inline_scripts< From 099efa358aa615dcd1cbba45cfb7d7b4d9a411e0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 7 Aug 2026 12:41:05 +0200 Subject: [PATCH 003/192] chore(main): release 1.783.0 (#10578) * chore(main): release 1.783.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 ++ backend/Cargo.lock | 180 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 20 files changed, 151 insertions(+), 134 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2653a1e1de..9a978495de 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.782.0" + ".": "1.783.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d0c3c376..1b3283588c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.783.0](https://github.com/windmill-labs/windmill/compare/v1.782.0...v1.783.0) (2026-08-07) + + +### Features + +* add public sharing option for job pages ([#10573](https://github.com/windmill-labs/windmill/issues/10573)) ([5ce29b3](https://github.com/windmill-labs/windmill/commit/5ce29b34364d6429411621752c64e306426aa2dd)) +* offer more dev workspace environment labels ([#10570](https://github.com/windmill-labs/windmill/issues/10570)) ([8c6211c](https://github.com/windmill-labs/windmill/commit/8c6211c27718912ad1e1a29f770747ea07fdb161)) +* open a session edit in the preview panel from the edits list ([#10486](https://github.com/windmill-labs/windmill/issues/10486)) ([d008975](https://github.com/windmill-labs/windmill/commit/d0089758e6091b07009dee5b8387f3f4b6d97455)) +* preview merge result in git-sync PR diff check ([#10542](https://github.com/windmill-labs/windmill/issues/10542)) ([c61404a](https://github.com/windmill-labs/windmill/commit/c61404a0f4008cd1de3c977c653c712ef3d9e0a3)) + + +### Bug Fixes + +* **frontend:** collapse the dev-workspace edit notice into a badge ([#10576](https://github.com/windmill-labs/windmill/issues/10576)) ([fdd76a6](https://github.com/windmill-labs/windmill/commit/fdd76a6f1358404b13326c68eed1eac14097bd88)) +* **frontend:** hide the fork workspace banner from operators ([#10575](https://github.com/windmill-labs/windmill/issues/10575)) ([57ed0f7](https://github.com/windmill-labs/windmill/commit/57ed0f77e1f83751ea04820b365fb53f098d38bf)) +* scope a fork's cloned app policy and custom path to its creator ([#10589](https://github.com/windmill-labs/windmill/issues/10589)) ([8e95bfe](https://github.com/windmill-labs/windmill/commit/8e95bfe6157ebe4f8fc5f6e13c36a6f0bdb9969d)) + ## [1.782.0](https://github.com/windmill-labs/windmill/compare/v1.781.3...v1.782.0) (2026-08-06) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9743b85d7b..69d6ccfff2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2129,13 +2129,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2416,9 +2416,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -2426,9 +2426,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -14570,7 +14570,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-nats", @@ -14655,7 +14655,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.782.0" +version = "1.783.0" dependencies = [ "async-stream", "async-trait", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14701,7 +14701,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "argon2", @@ -14841,7 +14841,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14864,7 +14864,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14881,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14907,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.782.0" +version = "1.783.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14917,7 +14917,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14934,7 +14934,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14995,7 +14995,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15017,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15052,7 +15052,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-nats", @@ -15087,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "flate2", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15152,7 +15152,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15172,7 +15172,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15210,7 +15210,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15238,7 +15238,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.782.0" +version = "1.783.0" dependencies = [ "lazy_static", "serde", @@ -15250,7 +15250,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.782.0" +version = "1.783.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15275,7 +15275,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15289,7 +15289,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.782.0" +version = "1.783.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15324,7 +15324,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.782.0" +version = "1.783.0" dependencies = [ "chrono", "lazy_static", @@ -15338,7 +15338,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15357,7 +15357,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.782.0" +version = "1.783.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15461,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.782.0" +version = "1.783.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15480,7 +15480,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.782.0" +version = "1.783.0" dependencies = [ "regex", "serde", @@ -15495,7 +15495,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "futures", @@ -15536,7 +15536,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.782.0" +version = "1.783.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15552,7 +15552,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -15573,7 +15573,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -15604,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "arc-swap", @@ -15629,7 +15629,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-stream", @@ -15663,7 +15663,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "futures", @@ -15681,7 +15681,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.782.0" +version = "1.783.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15690,7 +15690,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15702,7 +15702,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -15714,7 +15714,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "gosyn", @@ -15726,7 +15726,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15738,7 +15738,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -15750,7 +15750,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "nu-parser", @@ -15761,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15772,7 +15772,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15784,7 +15784,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15795,7 +15795,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-recursion", @@ -15817,7 +15817,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -15829,7 +15829,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15843,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde", @@ -15885,7 +15885,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15903,7 +15903,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15919,7 +15919,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15935,7 +15935,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -15949,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-recursion", @@ -15988,7 +15988,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "const_format", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.782.0" +version = "1.783.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16039,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-recursion", @@ -16073,7 +16073,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16130,7 +16130,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16157,7 +16157,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16190,7 +16190,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16210,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16244,7 +16244,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16280,7 +16280,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16303,7 +16303,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16327,7 +16327,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-nats", @@ -16351,7 +16351,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16386,7 +16386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16414,7 +16414,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-trait", @@ -16439,7 +16439,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16458,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-once-cell", @@ -16574,7 +16574,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.782.0" +version = "1.783.0" dependencies = [ "bytes", "futures", @@ -17274,18 +17274,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fbc48186a9..9163904548 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.782.0" +version = "1.783.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.782.0" +version = "1.783.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 0810b35e6d..8bd1d5bb1b 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.782.0" +version = "1.783.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.782.0" +version = "1.783.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.782.0" +version = "1.783.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.782.0" +version = "1.783.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index c6369aad52..2e527cef86 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.782.0" +version = "1.783.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 11beacd294..c34a118b28 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.782.0 + version: 1.783.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 0ce64d88a3..f4b3d6b689 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.782.0"; +export const VERSION = "v1.783.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 07dc340c6b..ccfc78dbef 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.782.0"; +export const VERSION = "1.783.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b058e4f82f..7113b74147 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.782.0", + "version": "1.783.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.782.0", + "version": "1.783.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ee7bb411b2..ff080f353b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.782.0", + "version": "1.783.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index c6e57ade6a..b6e51b4eba 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.782.0" +wmill = ">=1.783.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 6b43b9e14d..dcf0b6498b 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.782.0 + version: 1.783.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 9e0d6f0a6f..cb45694566 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.782.0' + ModuleVersion = '1.783.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 48d922ab1b..51794989e4 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.782.0" +version = "1.783.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index e9c093b06f..162303dac5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.782.0", + "version": "1.783.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index c88b9daee6..b47f3dbaa0 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.782.0", + "version": "1.783.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 5adb3228fe..0e9fc2c499 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.782.0 +1.783.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 50c861e4ac..e1f881fb0c 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.782.0", + "version": "1.783.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.782.0", + "version": "1.783.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 509fb9637a..76e512660f 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.782.0", + "version": "1.783.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From d638fab5f6c2a38ad55dd6951220eed25d2598b3 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Sat, 8 Aug 2026 00:07:23 +0200 Subject: [PATCH 004/192] stack login options in one column below four (#10598) * fix(frontend): stack login options in one column below four * style(frontend): drop redundant w-full on login buttons --- frontend/src/lib/components/Login.svelte | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index e59dcaa021..8aad566fdf 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -455,6 +455,8 @@ $effect(() => { error && sendUserToast(escapeHtml(error), true) }) + + let loginOptionCount = $derived((logins?.length ?? 0) + (saml ? 1 : 0))
@@ -462,9 +464,7 @@

Signing you in…

{/if}
{#if !logins} {#each Array(4) as _} @@ -483,17 +483,13 @@ {/if} {/each} {#each logins.filter((login) => !providersType?.includes(login.type)) as login} - {/each} {/if} {#if saml} - + {/if}
{#if !autoRedirecting && !disablePasswordLogin && (saml || (logins && logins.length > 0))} From 676256baccbd2e4421e8c13a5a239825a9c47551 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 10 Aug 2026 12:29:31 +0200 Subject: [PATCH 005/192] feat(flow-editor): measure step panel placement (#10543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow-editor): measure the redesigned step panels Instruments the flow editor's step, loop and branch panels on the existing anonymous `feature_usage` channel, so the redesign can be judged on how the panels are actually used rather than on nothing. Eight event kinds under a new `flow_editor` feature: panel opens and their dwell (bucketed, per placement), placement-preference overrides, which settings get configured or cleared, settings that read as invalid, the prop-picker connect lifecycle, AI input suggestions, and the step header menu that "Save to workspace" now lives behind. Settings changes are diffed off `describeStepSettings`, the same view the graph badges render, so the telemetry vocabulary cannot drift from the one on screen. Only `panel_open` and `setting` carry an entity id — one opaque id per editor mount — since a per-entity row is only worth its cost where the spread per editing session is the question. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow-editor): keep the panel telemetry honest Review follow-ups on the instrumentation: - The top dwell bucket was `120s+`, and `+` is outside the charset `is_identifier_shaped` accepts, so `log_feature_usage` skipped those events and still answered 204 — the longest visits vanished with no error on either side. Renamed to `120s_plus` and pinned every emittable key against the backend's charset in a test, since the producer is TypeScript and the validator is Rust. - Dropped the per-session entity id from `setting`: it would pay a row per session per day across twenty-four keys, for a distribution its plain counter already largely answers. - An armed connect that went away with its component never reported, so `open` did not balance against `insert` + `abandon`. - Session preview tabs keep hidden editors mounted, which billed panel time nobody spent. `FlowEditorView` now publishes the visibility it already knows about. - Re-picking the active placement row logged a move, which also made `auto:from_docked` mean two different things. - The last dwell of a session was lost on tab close, since Svelte tears components down on navigation but not on `pagehide`. Co-Authored-By: Claude Opus 5 (1M context) * refactor(flow-editor): narrow the telemetry to panel placement The eight-kind instrumentation measured more than could be read. With nothing recorded before the redesign there is no baseline to compare panel opens, dwell times, settings usage or connect funnels against, so those counters answered questions nobody could act on while costing a row per key per day in an instance-wide table. What remains are the three numbers the modal panel is actually judged on: how often the 1280px breakpoint puts the panel in a modal, and how often people override that in each direction. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow-editor): stop counting placement in session preview tabs Preview tabs keep every flow editor mounted and laid out at panel width whether or not it is the visible one, and that panel is narrower than the breakpoint by construction. Each flow tab opened in a session therefore emitted a `breakpoint_modal` on mount, and one drag of the session panel across 1280px emitted one per mounted tab — with no host dimension in the key to separate that from the crossings the counter exists to measure. Also corrects the comment on the no-op placement guard, which justified itself with a key vocabulary that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow-editor): make the three placement counters comparable Sessions were excluded from the breakpoint counter but not from the two override counters, so a pin made in a session landed in the same bucket used to judge the breakpoint, with no crossing in the denominator to read it against. All three are now gated together. An override is also only counted when it moves the panel. Choosing "Detached" on an editor the width had already put in a modal states a preference without changing anything, and the aggregate carries no width to separate that from the wide-screen override that is the actual signal. Co-Authored-By: Claude Opus 5 (1M context) * docs(flow-editor): describe the two override keys by what emits them They documented themselves as overriding `auto`, which is no longer the rule: pinning Attached on a wide editor overrides `auto` and emits nothing, while going from an Attached pin to Detached below the breakpoint emits `force_detach` even though `auto` would have produced a modal there too. This file is what someone reads when interpreting the numbers, and "override of auto" is the misreading the emission rule exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow-editor): count the panel moving, not the breakpoint being armed The tracker held "the breakpoint is responsible for this modal" rather than "the panel is modal", so on a narrow editor pinning Detached and releasing it back to Auto emitted a second breakpoint_modal for a panel that never moved. It also died with the editor, which FlowBuilder rebuilds through a `{#key}` on every reload — each rebuild re-armed it and counted the same narrow editor again. Both inflate the denominator that the two override counters are read against, and both bias it the same way: toward concluding that nobody overrides the breakpoint. The tracker now follows the panel's placement across preference changes, and FlowBuilder owns it from above the `{#key}`, which also puts the session exclusion in one place instead of at each call site. The moves-only rule moves into `forcedPlacementEvent` so both halves of it sit in the module the tests can reach. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow-editor): ignore placements measured before the editor is laid out A reload rebuilds the editor through `{#key renderCount}`, and the panel controller is rebuilt with it: its width restarts at zero, which resolves to `docked` because that is what is safe to render rather than because the editor is wide. The breakpoint tracker read that transient as the panel having docked and counted the real width landing as a fresh crossing, inflating the denominator both override ratios are read against. `useFlowPanelMode` now exposes `measured`, and the tracker skips anything unmeasured instead of recording it as a placement. Co-Authored-By: Claude Opus 5 (1M context) * docs(flow-editor): state the placement invariants once each The width-zero rule had accumulated at four sites, two of which forward it without being able to break it. Keep it beside the guards that enforce it. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 1 + .../src/lib/components/FlowBuilder.svelte | 3 + .../lib/components/flows/FlowEditor.svelte | 12 +++ .../flows/flowEditorTelemetry.test.ts | 71 ++++++++++++++ .../components/flows/flowEditorTelemetry.ts | 94 +++++++++++++++++++ .../components/flows/flowPanelMode.svelte.ts | 7 ++ 6 files changed, 188 insertions(+) create mode 100644 frontend/src/lib/components/flows/flowEditorTelemetry.test.ts create mode 100644 frontend/src/lib/components/flows/flowEditorTelemetry.ts diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 5b08e7e5de..127757108c 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -11439,6 +11439,7 @@ const FEATURE_USAGE_KINDS: &[(&str, &str)] = &[ ("ai_chat", "message"), ("ai_chat", "model"), ("ai_chat", "tool"), + ("flow_editor", "panel_placement"), ]; fn is_identifier_shaped(s: &str, max_len: usize) -> bool { diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index a469efa957..52af60d7a7 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -58,6 +58,7 @@ import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte' import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte' import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' + import { setFlowPanelPlacementTelemetry } from './flows/flowEditorTelemetry' import { SelectionManager } from './graph/selectionUtils.svelte' import { NoteEditor } from './graph/noteEditor.svelte' import { setNoteEditorContext } from './graph/noteEditor.svelte' @@ -248,6 +249,8 @@ // presence keyed on that URL leaks a phantom self-badge. Hide it here. const inSessionPane = !!getContext('aiChatManager') + setFlowPanelPlacementTelemetry(!inSessionPane) + function hasAIChanges(): boolean { return aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false } diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index e20f3b856b..a8c18f70ba 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -12,6 +12,7 @@ import Portal from '$lib/components/Portal.svelte' import { isFlowLevelPanelTarget } from '$lib/components/graph/selectionUtils.svelte' import { useFlowPanelMode } from './flowPanelMode.svelte' + import { useFlowPanelPlacementTelemetry } from './flowEditorTelemetry' import { writable } from 'svelte/store' import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker' @@ -129,6 +130,13 @@ const panelMode = $derived(panelController.mode) let panelModalOpen = $state(false) + // Owned by FlowBuilder: this component is inside a `{#key}` that rebuilds it on a reload, + // and the crossing count belongs to the editing session rather than to one mount. + const placementTelemetry = useFlowPanelPlacementTelemetry() + $effect(() => { + placementTelemetry.observe(panelController.preference, panelMode, panelController.measured) + }) + // Auto can move the panel back into the pane under a modal that is open — leaving it // open would keep an overlay registered for a modal nothing renders, swallowing Escape. $effect(() => { @@ -241,10 +249,14 @@ enabled: () => modalPanel, preference: () => panelController.preference, setPreference: (preference) => { + // Picking the row that is already active is not a move, and counting it would + // report a placement being forced that the panel was already in. + if (preference === panelController.preference) return // Moving the panel must not lose what it was showing: docked, it is always on // screen, so the modal it becomes has to open on arrival. The reverse is handled // by the effect above, which closes a modal that is no longer rendered. const wasVisible = panelMode === 'docked' || panelModalOpen + placementTelemetry.forced(preference, panelMode) panelController.preference = preference panelModalOpen = panelController.mode === 'modal' && wasVisible } diff --git a/frontend/src/lib/components/flows/flowEditorTelemetry.test.ts b/frontend/src/lib/components/flows/flowEditorTelemetry.test.ts new file mode 100644 index 0000000000..22174c7e14 --- /dev/null +++ b/frontend/src/lib/components/flows/flowEditorTelemetry.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + createBreakpointTracker, + forcedPlacementEvent, + type FlowPanelPlacementEvent +} from './flowEditorTelemetry' + +describe('createBreakpointTracker', () => { + it('counts a crossing into modal once, however many times the width settles', () => { + const events: FlowPanelPlacementEvent[] = [] + const tracker = createBreakpointTracker((event) => events.push(event)) + + tracker.observe('auto', 'docked', true) + tracker.observe('auto', 'modal', true) + // A window drag re-resolves the mode continuously; the breakpoint activated once. + tracker.observe('auto', 'modal', true) + tracker.observe('auto', 'docked', true) + tracker.observe('auto', 'modal', true) + + expect(events).toEqual(['breakpoint_modal', 'breakpoint_modal']) + }) + + it('does not re-count when a pin is released back to auto without moving the panel', () => { + const events: FlowPanelPlacementEvent[] = [] + const tracker = createBreakpointTracker((event) => events.push(event)) + + tracker.observe('auto', 'modal', true) + // Pinning Detached on a narrow editor, then releasing it, leaves the panel modal + // throughout — re-arming on the preference alone would read that as a second crossing. + tracker.observe('modal', 'modal', true) + tracker.observe('auto', 'modal', true) + + expect(events).toEqual(['breakpoint_modal']) + }) + + it('does not re-count when a remount reports an unmeasured editor', () => { + const events: FlowPanelPlacementEvent[] = [] + const tracker = createBreakpointTracker((event) => events.push(event)) + + tracker.observe('auto', 'modal', true) + // A reload rebuilds the controller, which resolves to docked until its width lands. + // Reading that as the panel having docked would arm the tracker for a second crossing + // the user never made. + tracker.observe('auto', 'docked', false) + tracker.observe('auto', 'modal', true) + + expect(events).toEqual(['breakpoint_modal']) + }) + + it('ignores a modal the user pinned', () => { + const events: FlowPanelPlacementEvent[] = [] + const tracker = createBreakpointTracker((event) => events.push(event)) + + tracker.observe('modal', 'modal', true) + tracker.observe('docked', 'docked', true) + + expect(events).toEqual([]) + }) +}) + +describe('forcedPlacementEvent', () => { + it('reports only the pins that move the panel', () => { + expect(forcedPlacementEvent('docked', 'modal')).toBe('force_attach') + expect(forcedPlacementEvent('modal', 'docked')).toBe('force_detach') + // Already where it is pinned to: a stated preference, not an override. + expect(forcedPlacementEvent('docked', 'docked')).toBeUndefined() + expect(forcedPlacementEvent('modal', 'modal')).toBeUndefined() + expect(forcedPlacementEvent('auto', 'modal')).toBeUndefined() + expect(forcedPlacementEvent('auto', 'docked')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/flows/flowEditorTelemetry.ts b/frontend/src/lib/components/flows/flowEditorTelemetry.ts new file mode 100644 index 0000000000..3d826dc8d9 --- /dev/null +++ b/frontend/src/lib/components/flows/flowEditorTelemetry.ts @@ -0,0 +1,94 @@ +import { getContext, setContext } from 'svelte' +import { logFeatureUsage } from '$lib/utils/featureUsage' +import type { FlowPanelMode, FlowPanelPreference } from './panelPlacement' + +// Anonymous counters for where the flow editor's step panel ends up. Same rules as every +// other `logFeatureUsage` caller: aggregated counts only, and the three keys below are the +// whole vocabulary — no path, expression or step id ever reaches here. + +const FEATURE = 'flow_editor' +const KIND = 'panel_placement' + +export type FlowPanelPlacementEvent = + /** The width moved the panel into the modal, under `auto`. */ + | 'breakpoint_modal' + /** The user pinned the panel into the pane while it was in the modal. */ + | 'force_attach' + /** The user pinned the panel out into the modal while it was in the pane. */ + | 'force_detach' + +type Log = (event: FlowPanelPlacementEvent) => void + +const log: Log = (event) => logFeatureUsage(FEATURE, KIND, { key: event }) + +/** + * The event a placement pin should produce, or nothing. `auto` is not a placement being + * forced, and a pin that matches where the panel already is moves nothing — the counters + * carry no width, so counting that would be indistinguishable from the override that did + * move the panel. + */ +export function forcedPlacementEvent( + preference: FlowPanelPreference, + mode: FlowPanelMode +): FlowPanelPlacementEvent | undefined { + if (preference === mode) return undefined + if (preference === 'docked') return 'force_attach' + if (preference === 'modal') return 'force_detach' + return undefined +} + +/** + * Counts the width moving the panel into the modal, once per crossing — `mode` re-resolves + * continuously as a drag settles, so counting per evaluation would read one drag as hundreds. + * + * Both guards below are there because a modal panel is not by itself a crossing: `wasModal` + * follows where the panel was rather than whether the breakpoint put it there, and an + * unmeasured editor is no placement at all. Rune-free so each edge is testable directly. + */ +export function createBreakpointTracker(emit: Log) { + let wasModal = false + + return { + observe(preference: FlowPanelPreference, mode: FlowPanelMode, measured: boolean) { + if (!measured) return + if (mode === 'modal' && !wasModal && preference === 'auto') emit('breakpoint_modal') + wasModal = mode === 'modal' + } + } +} + +export interface FlowPanelPlacementTelemetry { + /** The panel's current placement; emits `breakpoint_modal` on a crossing into the modal. */ + observe(preference: FlowPanelPreference, mode: FlowPanelMode, measured: boolean): void + /** A placement the user pinned, against where the panel was when they pinned it. */ + forced(preference: FlowPanelPreference, mode: FlowPanelMode): void +} + +const CONTEXT_KEY = 'flowPanelPlacementTelemetry' + +const NOOP: FlowPanelPlacementTelemetry = { observe: () => {}, forced: () => {} } + +/** + * Published by `FlowBuilder`, above the `{#key}` that rebuilds the editor on a reload: a + * tracker recreated mid-edit would re-arm and count a still-narrow editor again. + * + * `enabled` is false in session preview tabs. Those stay mounted and laid out at panel width + * even while hidden, and that panel is narrower than the breakpoint by construction: their + * crossings would bury the ones this measures, and their overrides would then be read + * against a denominator that no longer contains them. + */ +export function setFlowPanelPlacementTelemetry(enabled: boolean): void { + const emit: Log = enabled ? log : () => {} + const tracker = createBreakpointTracker(emit) + setContext(CONTEXT_KEY, { + observe: tracker.observe, + forced: (preference, mode) => { + const event = forcedPlacementEvent(preference, mode) + if (event) emit(event) + } + }) +} + +export function useFlowPanelPlacementTelemetry(): FlowPanelPlacementTelemetry { + return getContext(CONTEXT_KEY) ?? NOOP +} diff --git a/frontend/src/lib/components/flows/flowPanelMode.svelte.ts b/frontend/src/lib/components/flows/flowPanelMode.svelte.ts index 2d9b505d89..1b595c53ea 100644 --- a/frontend/src/lib/components/flows/flowPanelMode.svelte.ts +++ b/frontend/src/lib/components/flows/flowPanelMode.svelte.ts @@ -20,6 +20,13 @@ export function useFlowPanelMode(opts: { enabled: () => boolean }) { get mode(): FlowPanelMode { return resolvePanelMode({ enabled: opts.enabled(), preference, width }) }, + /** + * Whether `mode` reflects a real layout. Until the first measurement lands, `mode` is + * `docked` because that is the safe thing to render, not because the editor is wide. + */ + get measured(): boolean { + return width > 0 + }, /** Fed by the editor root's measured width; drives `auto` in both directions. */ measure(measured: number | null | undefined) { width = measured ?? 0 From 1a709d49f990530c6f5b14a69001ecda4775c5de Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 10 Aug 2026 19:25:02 +0200 Subject: [PATCH 006/192] unblock the frontend check (type error + svelte-check OOM) (#10617) * fix(frontend): count login options inside a closure so tsc keeps their type * ci: give svelte-check a heap above node's 4GB default --- .github/workflows/frontend-check.yml | 3 +++ frontend/src/lib/components/Login.svelte | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 90d839aca5..a675c17444 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -23,5 +23,8 @@ jobs: cache-dependency-path: "frontend/package-lock.json" - name: "npm check" timeout-minutes: 5 + env: + # svelte-check peaks past node's ~4GB default ceiling on this runner and aborts. + NODE_OPTIONS: --max-old-space-size=8192 run: cd frontend && npm ci && npm run generate-backend-client && npm run check diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 8aad566fdf..cefe3aa1a1 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -456,7 +456,9 @@ error && sendUserToast(escapeHtml(error), true) }) - let loginOptionCount = $derived((logins?.length ?? 0) + (saml ? 1 : 0)) + // Read inside a closure: at this scope TS narrows `logins` to its initializer's + // `undefined`, which makes `logins?.length` an access on `never`. + let loginOptionCount = $derived.by(() => (logins?.length ?? 0) + (saml ? 1 : 0))
From 85916cedf812eeb2ab96a428939e1198fd55ceaf Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 10 Aug 2026 19:26:25 +0200 Subject: [PATCH 007/192] fix: order workspace members and invites by email (#10604) `list_users` and `list_pending_invites` had no ORDER BY, so Postgres returned rows in heap order. An UPDATE rewrites the row at the end of the heap, which sent the member whose role was just toggled to the bottom of the list the settings page refetches right after. Co-authored-by: Claude Opus 5 (1M context) --- ...1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498.json} | 4 ++-- ...06e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0.json} | 4 ++-- backend/windmill-api-users/src/users.rs | 1 + backend/windmill-api-workspaces/src/workspaces.rs | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) rename backend/.sqlx/{query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json => query-3ce57583042b1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498.json} (87%) rename backend/.sqlx/{query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json => query-e5775b1417d506e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0.json} (92%) diff --git a/backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json b/backend/.sqlx/query-3ce57583042b1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498.json similarity index 87% rename from backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json rename to backend/.sqlx/query-3ce57583042b1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498.json index f2b40e0184..c5a7dd6079 100644 --- a/backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json +++ b/backend/.sqlx/query-3ce57583042b1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id\n WHERE workspace_id = $1", + "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id\n WHERE workspace_id = $1\n ORDER BY workspace_invite.email", "describe": { "columns": [ { @@ -42,5 +42,5 @@ true ] }, - "hash": "ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880" + "hash": "3ce57583042b1daa2bd9d88502f6f4ab76c509dafee7371ac620c85e1b5d2498" } diff --git a/backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json b/backend/.sqlx/query-e5775b1417d506e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0.json similarity index 92% rename from backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json rename to backend/.sqlx/query-e5775b1417d506e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0.json index 9f80167752..3fa97c8ae5 100644 --- a/backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json +++ b/backend/.sqlx/query-e5775b1417d506e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account\n FROM usr\n WHERE workspace_id = $1\n ", + "query": "\n SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account\n FROM usr\n WHERE workspace_id = $1\n ORDER BY email\n ", "describe": { "columns": [ { @@ -72,5 +72,5 @@ false ] }, - "hash": "704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84" + "hash": "e5775b1417d506e90953dc8e2b769ba049d314d93e4436f8f14f4812eb4990a0" } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 9cae878764..96b89e322c 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -413,6 +413,7 @@ async fn list_users( SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account FROM usr WHERE workspace_id = $1 + ORDER BY email ", w_id ) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 127757108c..2acdd6ac71 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -659,7 +659,8 @@ async fn list_pending_invites( workspace_invite.operator, workspace.parent_workspace_id FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id - WHERE workspace_id = $1", + WHERE workspace_id = $1 + ORDER BY workspace_invite.email", w_id ) .fetch_all(&mut *tx) From c725d62fb07e059a377346d13ea491bbc97bd666 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 10 Aug 2026 19:30:24 +0200 Subject: [PATCH 008/192] fix(frontend): call a dev workspace a dev workspace in the merge UI (#10605) * fix(frontend): call a dev workspace a dev workspace in the merge UI Co-Authored-By: Claude Opus 5 (1M context) * refactor(frontend): drop the unreachable dev-workspace guard on the fork modals Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../src/lib/components/CompareDrafts.svelte | 10 +++++++--- .../src/lib/components/CompareWorkspaces.svelte | 14 +++++++++----- .../lib/components/DatatableSchemaDiff.svelte | 17 ++++++++++++----- .../lib/components/ForkWorkspaceBanner.svelte | 9 +++++---- frontend/src/lib/utils/devWorkspaceLabel.ts | 11 +++++++++++ .../(root)/(logged)/forks/compare/+page.svelte | 12 ++++++++---- 6 files changed, 52 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 28cd3fd17a..88107f8bba 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -29,7 +29,8 @@ useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' - import { userStore } from '$lib/stores' + import { userStore, userWorkspaces } from '$lib/stores' + import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' interface Props { currentWorkspaceId: string @@ -161,6 +162,10 @@ // (unrelated to the fork's own work) are the common case worth hiding. let hideUnchanged = $state(true) + const currentNoun = $derived( + childWorkspaceNoun($userWorkspaces.find((w) => w.id === currentWorkspaceId)) + ) + // The list (and, in the default view, the Draft Count) come from the Workspace // Drafts module; deploy/discard invalidate the resource, so the list refetches // and deployed items drop off without a manual reload here. @@ -663,8 +668,7 @@ size="xs" options={{ right: 'Hide unchanged drafts', - rightTooltip: - "Hide drafts identical to the parent workspace. A fork inherits the parent's drafts when it's created; those are unrelated to the changes made in this fork." + rightTooltip: `Hide drafts identical to the parent workspace. A ${currentNoun} inherits the parent's drafts when it's created; those are unrelated to the changes made in this ${currentNoun}.` }} /> {/if} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 2b644e900d..875eda25ce 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -68,6 +68,7 @@ import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import CompareTargetPicker from './CompareTargetPicker.svelte' import { displayDate } from '$lib/utils' + import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' import { editUrlFor } from './sessions/forkEditUrl' import { diffInMask } from './sessions/modifiedItemsMask' import DatatableSchemaDiff from './DatatableSchemaDiff.svelte' @@ -400,6 +401,8 @@ let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId)) let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId)) + let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceInfo)) + // An arbitrary target is one-way: the pair has no tally, so nothing distinguishes // a change made here from one made there, and the "update current" direction // would list every difference as an incoming change. @@ -448,7 +451,7 @@ ? `No changes between this workspace and ${parentWorkspaceId}.` : mergeIntoParent ? `Nothing to deploy — ${parentWorkspaceId} already has every change from this workspace.` - : `Nothing to update — this fork is up to date with ${parentWorkspaceId}.` + : `Nothing to update — this ${currentNoun} is up to date with ${parentWorkspaceId}.` ) let conflictingDiffs = $derived( @@ -1437,7 +1440,8 @@ {#if mergeIntoParent} This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}. - Only deployed versions in this fork can be sent to {parentWorkspaceId} — deploy + Only deployed versions in this {currentNoun} can be sent to {parentWorkspaceId} — + deploy {draftCount !== 1 ? 'them' : 'it'} first, otherwise those changes won't be included. {:else} This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}. @@ -1461,14 +1465,14 @@ {conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting - changes, it was modified on the original workspace while changes were made on this fork. - Make sure to resolve these before merging. + changes, it was modified on the original workspace while changes were made on this + {currentNoun}. Make sure to resolve these before merging. {/if} {#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)} diff --git a/frontend/src/lib/components/DatatableSchemaDiff.svelte b/frontend/src/lib/components/DatatableSchemaDiff.svelte index c660b122b5..579b7a1923 100644 --- a/frontend/src/lib/components/DatatableSchemaDiff.svelte +++ b/frontend/src/lib/components/DatatableSchemaDiff.svelte @@ -13,6 +13,7 @@ import SimpleEditor from '$lib/components/SimpleEditor.svelte' import { sendUserToast } from '$lib/toast' import { userWorkspaces } from '$lib/stores' + import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' import { runScriptAndPollResult } from '$lib/components/jobs/utils' import { writingJobOptions } from '$lib/components/jobs/writingJob' import YAML from 'yaml' @@ -34,6 +35,11 @@ let { currentWorkspaceId, parentWorkspaceId }: Props = $props() + let currentNoun = $derived( + childWorkspaceNoun($userWorkspaces.find((w) => w.id === currentWorkspaceId)) + ) + let currentNounCap = $derived(currentNoun.charAt(0).toUpperCase() + currentNoun.slice(1)) + let loading = $state(true) let error: string | undefined = $state(undefined) let diffs: DatatableDiff[] = $state([]) @@ -321,8 +327,9 @@
{#if diff.aheadChanges.length > 0}
-
Fork changes (ahead)
+
+ {currentNounCap} changes (ahead) +
{#each diff.aheadChanges as change}
{#if change.kind === 'added'} @@ -393,8 +400,8 @@ (drawerOpen = false)} title="{drawerChange.schemaName}.{drawerChange.tableName} ({drawerDirection === 'ahead' - ? 'Fork → Parent' - : 'Parent → Fork'})" + ? `${currentNounCap} → Parent` + : `Parent → ${currentNounCap}`})" > {#snippet actions()}
diff --git a/frontend/src/lib/utils/devWorkspaceLabel.ts b/frontend/src/lib/utils/devWorkspaceLabel.ts index e1b3e6604e..e12e490261 100644 --- a/frontend/src/lib/utils/devWorkspaceLabel.ts +++ b/frontend/src/lib/utils/devWorkspaceLabel.ts @@ -41,3 +41,14 @@ export function devLabelWord(label: string | null | undefined): string { export function devLabelNoun(label: string | null | undefined): string { return `${devLabelKey(label)} workspace` } + +/** + * How to name a child workspace in prose: its environment noun when it is a dev workspace + * ("dev workspace", "staging workspace"), "fork" otherwise. A dev workspace is a standing + * environment its whole team works in, so calling it a fork misreads it as throwaway. + */ +export function childWorkspaceNoun( + workspace: { is_dev_workspace?: boolean; dev_workspace_label?: string | null } | undefined +): string { + return workspace?.is_dev_workspace ? devLabelNoun(workspace.dev_workspace_label) : 'fork' +} diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index d5ff28ccca..a21fa36835 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -51,6 +51,10 @@ // prefix. Distinct from having a compare target: a root workspace has no parent // yet can still be pointed at an arbitrary one. const isFork = $derived(!!parentWorkspaceId) + // A dev workspace is a standing environment, torn down by detaching it in the + // dev-workspace settings — never by an archive/delete button sitting next to the + // merge it is here to perform. + const isDevWorkspace = $derived(!!currentWorkspaceData?.is_dev_workspace) const hasCompareTarget = $derived(!!compareTargetId) // Mode is seeded from the URL (?mode=draft|fork). `draft` is valid for any @@ -389,10 +393,10 @@
- - {#if isFork} + + {#if isFork && !isDevWorkspace} + {/each} +
+
+ {/snippet} + diff --git a/frontend/src/lib/components/copilot/chat/artifacts/ArtifactViewer.svelte b/frontend/src/lib/components/copilot/chat/artifacts/ArtifactViewer.svelte index 2aadcca63d..1ce7fe1d35 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/ArtifactViewer.svelte +++ b/frontend/src/lib/components/copilot/chat/artifacts/ArtifactViewer.svelte @@ -9,14 +9,68 @@ import { copyToClipboard, download } from '$lib/utils' import CodeDisplay from '../script/CodeDisplay.svelte' import LinkRenderer from '../LinkRenderer.svelte' - import { artifactFilename, artifactMimeType, type PersistedArtifact } from './artifactsDB' + import { + artifactFilename, + artifactMimeType, + currentVersion, + type ArtifactVersion, + type PersistedArtifact + } from './artifactsDB' import { markdownProse } from '$lib/components/markdownProse' + import ArtifactVersionPicker from './ArtifactVersionPicker.svelte' + import type { SessionArtifactsStore } from './artifactsState.svelte' + import { History } from 'lucide-svelte' + import TimeAgo from '$lib/components/TimeAgo.svelte' interface Props { artifact: PersistedArtifact + store: SessionArtifactsStore } - let { artifact }: Props = $props() + let { artifact, store }: Props = $props() + + const latest = $derived(currentVersion(artifact)) + // Nothing to pick between until a second version exists. + const hasHistory = $derived(latest > 1) + + // undefined = following the current version. An explicit pick survives later edits, so + // the AI writing v8 does not yank the reader out of the v3 they chose to read. + let pinned = $state(undefined) + let pinnedContent = $state(undefined) + + // The store hands us a fresh object on every edit, so this effect reruns constantly. + // Compare the id against the last one seen: clearing on every rerun would drop the + // reader's pin the moment the assistant writes a new version. + let pinnedFor: string | undefined + $effect(() => { + if (artifact.id === pinnedFor) return + pinnedFor = artifact.id + pinned = undefined + }) + + $effect(() => { + const version = pinned + if (version === undefined) { + pinnedContent = undefined + return + } + const id = artifact.id + void store.getVersion(id, version).then((snapshot) => { + if (pinned !== version || artifact.id !== id) return + // Pruned out from under the pin (history is capped): fall back to current rather + // than showing an empty document. + if (!snapshot) { + pinned = undefined + return + } + pinnedContent = snapshot + }) + }) + + const shown = $derived(pinnedContent ?? artifact) + // Label from the snapshot that is rendered, not from the one just requested: the read is + // async, so `pinned` names a version the body has not swapped to yet. + const shownVersion = $derived(pinnedContent?.version) // Markdown is the only rendered kind in v1; anything else shows source only. const canPreview = $derived(artifact.kind === 'md') @@ -25,12 +79,16 @@ let copied = $state(false) async function copyRaw() { - if (!(await copyToClipboard(artifact.content))) return + if (!(await copyToClipboard(shown.content))) return copied = true setTimeout(() => (copied = false), 1500) } function downloadFile() { - download(artifactFilename(artifact), artifact.content, artifactMimeType(artifact.kind)) + download( + artifactFilename({ name: shown.name, kind: artifact.kind }), + shown.content, + artifactMimeType(artifact.kind) + ) } const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }] @@ -40,9 +98,17 @@
- - {artifact.name} + + {shown.name} + {#if hasHistory} + (pinned = v)} + /> + {/if}
@@ -78,11 +144,31 @@
+ {#if pinnedContent} + +
+ + + + Viewing v{shownVersion} of {latest} · saved + ago + +
+ +
+
+ {/if} +
{#if source} - {#key `${artifact.id}:${artifact.updatedAt}`} - + {#key `${artifact.id}:${pinnedContent ? `v${pinnedContent.version}` : artifact.updatedAt}`} + {/key} {:else} + {#await import('./DiffEditor.svelte')} + + {:then Module} + + {/await} + {:else} +
+ +
+ {/if} +
+
+ {/if} + + diff --git a/frontend/src/lib/components/ScriptVersionHistory.svelte b/frontend/src/lib/components/ScriptVersionHistory.svelte index 4d053fc433..693ddef47c 100644 --- a/frontend/src/lib/components/ScriptVersionHistory.svelte +++ b/frontend/src/lib/components/ScriptVersionHistory.svelte @@ -1,6 +1,6 @@ + +
+ + {@render action?.()} +
diff --git a/frontend/src/lib/components/settings/CloudQuotas.svelte b/frontend/src/lib/components/settings/CloudQuotas.svelte index ec597101ee..02a49d88be 100644 --- a/frontend/src/lib/components/settings/CloudQuotas.svelte +++ b/frontend/src/lib/components/settings/CloudQuotas.svelte @@ -9,7 +9,7 @@ import { untrack } from 'svelte' import { Trash2 } from 'lucide-svelte' - type ResourceType = 'scripts' | 'flows' | 'apps' + type ResourceType = 'scripts' | 'flows' | 'apps' | 'resources' let quotas: | { @@ -80,6 +80,8 @@ return 'This will permanently delete all non-HEAD flow versions. Only the latest version of each flow will be kept. This frees up storage but does not reduce the flow count (quota counts unique flows, not versions).' case 'apps': return 'This will permanently delete all non-HEAD app versions. Only the latest version of each app will be kept. This frees up storage but does not reduce the app count (quota counts unique apps, not versions).' + case 'resources': + return 'This will permanently delete the value history of every resource. Only the current value of each resource will be kept, so past values can no longer be compared or restored. This frees up storage but does not reduce the resource count (quota counts unique resources, not versions).' } } @@ -88,7 +90,7 @@ { label: 'Flows', key: 'flows', prunable: true }, { label: 'Apps', key: 'apps', prunable: true }, { label: 'Variables', key: 'variables', prunable: false }, - { label: 'Resources', key: 'resources', prunable: false }, + { label: 'Resources', key: 'resources', prunable: true }, { label: 'Forks', key: 'forks', prunable: false } ] diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index fb3dbacc2c..27fdd286b5 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -1372,7 +1372,11 @@ - + Date: Mon, 10 Aug 2026 21:50:03 +0200 Subject: [PATCH 017/192] fix(cli): load the app's ESM svelte compiler, not its CJS one (#10622) --- cli/src/commands/app/bundle.ts | 56 +++++++++++++++++-- ...pp_svelte_compiler_resolution_unit.test.ts | 53 ++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 9987713e4f..0ebbdaa076 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -80,6 +80,51 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea } } +/** What an `import()` matches — the point being that it never matches "require". */ +const ESM_CONDITIONS = ["node", "import", "default"]; + +/** + * Walks one subpath of an exports map the way Node's ESM resolver would: first + * key in declaration order whose condition an `import()` matches wins. + */ +function esmConditionTarget(subpath: unknown): string | undefined { + if (typeof subpath === "string") return subpath; + if (!subpath || typeof subpath !== "object" || Array.isArray(subpath)) { + return undefined; + } + for (const [condition, target] of Object.entries(subpath)) { + if (!ESM_CONDITIONS.includes(condition)) continue; + const entry = esmConditionTarget(target); + if (entry) return entry; + } + return undefined; +} + +/** + * `require.resolve` answers with the `require` condition, which Svelte maps at a + * minified UMD bundle. Only the CJS loader can read that file's exports, so + * `import()`ing it yields a namespace holding nothing but `default` and every + * named export reads undefined. The exports map is the only place to ask for the + * ESM entry instead — `require.resolve` takes no conditions. + */ +function resolveAppSvelteCompiler(appDir: string): string { + const requireFromApp = createRequire( + path.join(path.resolve(appDir), "package.json") + ); + try { + const pkgPath = requireFromApp.resolve("svelte/package.json"); + const exportsMap = JSON.parse(readTextFileSync(pkgPath))?.exports; + const target = esmConditionTarget(exportsMap?.["./compiler"]); + if (target?.startsWith(".")) { + const entry = path.resolve(path.dirname(pkgPath), target); + if (fs.existsSync(entry)) return entry; + } + } catch { + // No exports map to read (or an unexpected shape) — let the CJS resolver try. + } + return requireFromApp.resolve("svelte/compiler"); +} + /** * Loads the Svelte compiler out of the *app's* node_modules. * @@ -94,15 +139,14 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea * Falls back to the CLI's own compiler when the app has none resolvable. */ export async function loadSvelteCompiler(appDir: string): Promise { + let mod: any; try { - const requireFromApp = createRequire( - path.join(path.resolve(appDir), "package.json") - ); - const entry = requireFromApp.resolve("svelte/compiler"); - return await import(pathToFileURL(entry).href); + mod = await import(pathToFileURL(resolveAppSvelteCompiler(appDir)).href); } catch { - return await import("svelte/compiler"); + mod = await import("svelte/compiler"); } + // A CJS entry still imports as a namespace whose only key is `default`. + return typeof mod?.compile === "function" ? mod : (mod?.default ?? mod); } /** diff --git a/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts index 5fbf05384e..a81c19c2af 100644 --- a/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts +++ b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts @@ -42,6 +42,50 @@ function installStubSvelte(dir: string, version: string) { ); } +/** + * A stand-in shaped like the real svelte: `./compiler` maps `require` at a UMD + * bundle and `default` at the ESM sources, and only the ESM half survives an + * `import()` with its named exports intact. + */ +function installDualStubSvelte(dir: string) { + const pkgDir = path.join(dir, "node_modules", "svelte"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "svelte", + version: "0.0.0-dual", + type: "module", + exports: { + "./package.json": "./package.json", + "./compiler": { + types: "./types/index.d.ts", + require: "./compiler/index.cjs", + default: "./src/compiler/index.js", + }, + }, + }), + "utf-8", + ); + fs.mkdirSync(path.join(pkgDir, "src", "compiler"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "src", "compiler", "index.js"), + `export const VERSION = "0.0.0-esm";\n` + + `export function compile() { return { js: { code: "", map: null }, warnings: [] }; }\n`, + "utf-8", + ); + fs.mkdirSync(path.join(pkgDir, "compiler"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "compiler", "index.cjs"), + // The UMD wrapper the published bundle uses: no static `exports.x = ...` for + // a lexer to find, so an `import()` of this file sees only `default`. + `!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):e(t)}` + + `(0,function(e){e.VERSION="0.0.0-cjs";` + + `e.compile=function(){return{js:{code:"",map:null},warnings:[]}}});\n`, + "utf-8", + ); +} + beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-compiler-")); fs.writeFileSync( @@ -64,6 +108,15 @@ describe("loadSvelteCompiler", () => { expect(compiler.VERSION).toBe("0.0.0-app-local"); }); + test("takes the ESM entry, not the `require` one, off a dual exports map", async () => { + installDualStubSvelte(tempDir); + + const compiler = await loadSvelteCompiler(tempDir); + + expect(compiler.VERSION).toBe("0.0.0-esm"); + expect(typeof compiler.compile).toBe("function"); + }); + test("resolves against the app even when given a relative dir", async () => { installStubSvelte(tempDir, "0.0.0-relative"); const cwd = process.cwd(); From 5ed846abd2092d9e16195062686ffb13859a2c01 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 10 Aug 2026 21:55:09 +0200 Subject: [PATCH 018/192] chore: internal accounting update (#10602) * chore: bump ee ref and refresh query cache Co-Authored-By: Claude Opus 5 (1M context) * chore: bump ee ref Co-Authored-By: Claude Opus 5 (1M context) * chore: bump ee ref Co-Authored-By: Claude Opus 5 (1M context) * chore: bump ee ref Co-Authored-By: Claude Opus 5 (1M context) * chore: bump ee ref Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 236115e11f074d86675aa5acdf5061dd3e64f43c This commit updates the EE repository reference after PR #719 was merged in windmill-ee-private. Previous ee-repo-ref: 62bc50118d09374b8a45756504520cfb6e5f0210 New ee-repo-ref: 236115e11f074d86675aa5acdf5061dd3e64f43c Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...bd8fc61409776d641ddb592a4c731e61a0468.json | 26 --------------- ...14d66b1678bfe3028df89dff4e343e2f4ab44.json | 26 --------------- ...b637cfbd67d9b1088bb6c8b366bedbf220bb7.json | 32 ------------------- backend/ee-repo-ref.txt | 2 +- 4 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 backend/.sqlx/query-08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468.json delete mode 100644 backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json delete mode 100644 backend/.sqlx/query-69274380aadda7b9333f38012c1b637cfbd67d9b1088bb6c8b366bedbf220bb7.json diff --git a/backend/.sqlx/query-08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468.json b/backend/.sqlx/query-08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468.json deleted file mode 100644 index 9da12fd0b8..0000000000 --- a/backend/.sqlx/query-08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT login_type, COUNT(*) FROM password GROUP BY login_type", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "login_type", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - null - ] - }, - "hash": "08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468" -} diff --git a/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json b/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json deleted file mode 100644 index 5f114c0156..0000000000 --- a/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH potential AS (\n SELECT email, operator FROM usr WHERE is_service_account IS false\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user\n WHERE email NOT IN (SELECT email FROM password WHERE disabled IS true)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "authors!", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "operators!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null - ] - }, - "hash": "4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44" -} diff --git a/backend/.sqlx/query-69274380aadda7b9333f38012c1b637cfbd67d9b1088bb6c8b366bedbf220bb7.json b/backend/.sqlx/query-69274380aadda7b9333f38012c1b637cfbd67d9b1088bb6c8b366bedbf220bb7.json deleted file mode 100644 index 66d0f2ace1..0000000000 --- a/backend/.sqlx/query-69274380aadda7b9333f38012c1b637cfbd67d9b1088bb6c8b366bedbf220bb7.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n COUNT(DISTINCT CASE WHEN NOT operator THEN email END)::INT as \"author_count!\",\n COUNT(DISTINCT CASE WHEN operator THEN email END)::INT as \"operator_count!\"\n FROM usr\n WHERE email IN (\n SELECT DISTINCT username FROM audit\n WHERE timestamp > NOW() - INTERVAL '1 month'\n AND operation IN ('users.login', 'oauth.login', 'users.token.refresh')\n )\n AND workspace_id NOT LIKE 'wm-fork%'\n GROUP BY workspace_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "author_count!", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "operator_count!", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - null, - null - ] - }, - "hash": "69274380aadda7b9333f38012c1b637cfbd67d9b1088bb6c8b366bedbf220bb7" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f6c4f387b4..054722489d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f0df8b82c4c089d384423ed64b8504506084820d +236115e11f074d86675aa5acdf5061dd3e64f43c From cf3ddaa3ccd4b3a90861b36f1f268d78e7bcb50d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 10 Aug 2026 22:56:55 +0200 Subject: [PATCH 019/192] chore(main): release 1.784.0 (#10603) * chore(main): release 1.784.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 + backend/Cargo.lock | 480 +++++++++++------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 20 files changed, 351 insertions(+), 237 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9a978495de..eb47521e1f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.783.0" + ".": "1.784.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3283588c..d63d9a198f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.784.0](https://github.com/windmill-labs/windmill/compare/v1.783.0...v1.784.0) (2026-08-10) + + +### Features + +* **flow-editor:** measure step panel placement ([#10543](https://github.com/windmill-labs/windmill/issues/10543)) ([676256b](https://github.com/windmill-labs/windmill/commit/676256baccbd2e4421e8c13a5a239825a9c47551)) +* version history for session artifacts ([#10574](https://github.com/windmill-labs/windmill/issues/10574)) ([77adf85](https://github.com/windmill-labs/windmill/commit/77adf85ccd512aad3ea54362bbaceae240e5c8a0)) +* version resource values with history, diff and restore ([#10596](https://github.com/windmill-labs/windmill/issues/10596)) ([c09de59](https://github.com/windmill-labs/windmill/commit/c09de594b6e35c0c1c88e504c90730ff217b42fd)) + + +### Bug Fixes + +* **cli:** attach the right job path to preview runs ([#10606](https://github.com/windmill-labs/windmill/issues/10606)) ([9eef70e](https://github.com/windmill-labs/windmill/commit/9eef70ea8b366c42f968308112cc41a6fdeccf8a)) +* **cli:** load the app's ESM svelte compiler, not its CJS one ([#10622](https://github.com/windmill-labs/windmill/issues/10622)) ([2748d01](https://github.com/windmill-labs/windmill/commit/2748d019f53e37c3254392c5a40776a94c1ca130)) +* **duckdb:** cast list columns in quicksearch so tables containing them can be previewed ([#10614](https://github.com/windmill-labs/windmill/issues/10614)) ([bf1b2cd](https://github.com/windmill-labs/windmill/commit/bf1b2cdcf9cd5251ee0560077db307b6003d472c)) +* **frontend:** call a dev workspace a dev workspace in the merge UI ([#10605](https://github.com/windmill-labs/windmill/issues/10605)) ([c725d62](https://github.com/windmill-labs/windmill/commit/c725d62fb07e059a377346d13ea491bbc97bd666)) +* order workspace members and invites by email ([#10604](https://github.com/windmill-labs/windmill/issues/10604)) ([85916ce](https://github.com/windmill-labs/windmill/commit/85916cedf812eeb2ab96a428939e1198fd55ceaf)) +* raw app new-app modal ignores instance-level AI settings ([#10619](https://github.com/windmill-labs/windmill/issues/10619)) ([8c65511](https://github.com/windmill-labs/windmill/commit/8c65511e814e383f6cdd9df3601e544cbc0c1b49)) +* **smtp:** explain why a test email failed instead of 'deadline has elapsed' ([#10620](https://github.com/windmill-labs/windmill/issues/10620)) ([5b0a159](https://github.com/windmill-labs/windmill/commit/5b0a159a018662ea7836e72d8ee95d3aebd30cef)) + ## [1.783.0](https://github.com/windmill-labs/windmill/compare/v1.782.0...v1.783.0) (2026-08-07) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d406dc6dcb..46ab639465 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -542,7 +542,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -873,9 +873,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -976,9 +976,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -987,9 +987,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -1926,7 +1926,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tower-service", @@ -2057,9 +2057,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -2216,7 +2216,7 @@ dependencies = [ "rand_distr", "rayon", "safetensors", - "thiserror 2.0.19", + "thiserror 2.0.20", "yoke", "zip", ] @@ -2234,7 +2234,7 @@ dependencies = [ "rayon", "safetensors", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2302,9 +2302,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -2633,9 +2633,9 @@ checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -3718,6 +3718,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deno_ast" version = "0.51.0" @@ -3755,7 +3786,7 @@ dependencies = [ "swc_sourcemap", "swc_visit", "text_lines", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-width 0.2.2", "url", ] @@ -3800,7 +3831,7 @@ dependencies = [ "smallvec", "sourcemap", "static_assertions", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", "v8", @@ -3847,7 +3878,7 @@ dependencies = [ "sha2 0.10.9", "signature", "spki", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "uuid", "x25519-dalek", @@ -3939,7 +3970,7 @@ dependencies = [ "rustls-webpki 0.102.8", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls 0.26.4", "tokio-socks", @@ -3971,7 +4002,7 @@ dependencies = [ "rand 0.8.5", "rayon", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "winapi", "windows-sys 0.59.0", ] @@ -4045,7 +4076,7 @@ dependencies = [ "serde", "sha2 0.10.9", "socket2 0.5.10", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-vsock", "url", @@ -4066,7 +4097,7 @@ dependencies = [ "strum", "strum_macros", "syn 2.0.119", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4078,7 +4109,7 @@ dependencies = [ "deno_error 0.6.1", "percent-encoding", "sys_traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -4105,7 +4136,7 @@ dependencies = [ "serde_json", "sys_traits", "temp_deno_which", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "winapi", "windows-sys 0.59.0", @@ -4146,7 +4177,7 @@ dependencies = [ "opentelemetry_sdk 0.27.1", "pin-project", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -4174,7 +4205,7 @@ dependencies = [ "rustls-tokio-stream", "rustls-webpki 0.102.8", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "webpki-roots 0.26.11", ] @@ -4217,7 +4248,7 @@ dependencies = [ "flate2", "futures", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "uuid", ] @@ -4980,9 +5011,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "fixedbitset" @@ -5674,7 +5705,7 @@ checksum = "9758a950dc61a15bc65162f72f5bec7e8efde91f102dc7dce7317f67019a6ba9" dependencies = [ "anyhow", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unic-ucd-category", ] @@ -5892,7 +5923,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "ureq", "windows-sys 0.60.2", @@ -5917,7 +5948,7 @@ dependencies = [ "once_cell", "rand 0.9.0", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -5941,7 +5972,7 @@ dependencies = [ "resolv-conf", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -6646,6 +6677,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -6658,7 +6742,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -6707,9 +6791,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -6725,7 +6809,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6738,7 +6822,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6878,7 +6962,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tower 0.5.3", @@ -6902,7 +6986,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6940,7 +7024,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7637,9 +7721,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "async-lock 3.4.2", "crossbeam-channel", @@ -7715,7 +7799,7 @@ dependencies = [ "quote", "syn 2.0.119", "termcolor", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -7740,7 +7824,7 @@ dependencies = [ "rand 0.10.2", "serde", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-native-tls", "tokio-util", @@ -7772,7 +7856,7 @@ dependencies = [ "serde_json", "sha1", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", ] @@ -7986,7 +8070,7 @@ dependencies = [ "num-format", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "typetag", "windows-sys 0.48.0", ] @@ -8250,7 +8334,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -8458,7 +8542,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -8533,7 +8617,7 @@ dependencies = [ "opentelemetry_sdk 0.30.0", "prost", "reqwest 0.12.28", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tonic 0.13.1", "tracing", @@ -8614,7 +8698,7 @@ dependencies = [ "percent-encoding", "rand 0.9.0", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -8718,7 +8802,7 @@ dependencies = [ "rc2", "sha1", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "x509-parser 0.17.0", ] @@ -9243,9 +9327,18 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "postgres-native-tls" @@ -9477,7 +9570,7 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -9629,7 +9722,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls 0.23.35", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -9652,7 +9745,7 @@ dependencies = [ "rustls 0.23.35", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -10007,7 +10100,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -10194,7 +10287,7 @@ dependencies = [ "http 1.5.0", "reqwest 0.13.4", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tower-service", ] @@ -10213,7 +10306,7 @@ dependencies = [ "reqwest 0.13.4", "reqwest-middleware", "retry-policies", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "wasmtimer", @@ -10326,7 +10419,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -10338,9 +10431,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737d947bcfd946fae6a179a4ef6487be6dcf25c930c2393856b820f1386e52a6" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" dependencies = [ "darling 0.24.0", "proc-macro2", @@ -10893,7 +10986,7 @@ dependencies = [ "quick-xml", "rand 0.9.0", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "uuid", ] @@ -11302,15 +11395,15 @@ dependencies = [ "num-bigint", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "v8", ] [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -11318,6 +11411,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -11328,9 +11422,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11538,7 +11632,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -11789,7 +11883,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -11875,7 +11969,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -11916,7 +12010,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -11942,7 +12036,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "url", "uuid", @@ -12664,7 +12758,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "typetag", "uuid", @@ -12859,11 +12953,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -12879,9 +12973,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -13585,7 +13679,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -14281,9 +14375,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -14295,9 +14389,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -14305,9 +14399,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14315,9 +14409,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -14328,18 +14422,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" dependencies = [ "async-trait", "cast", @@ -14359,9 +14453,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" dependencies = [ "proc-macro2", "quote", @@ -14370,9 +14464,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" +checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" [[package]] name = "wasm-streams" @@ -14407,7 +14501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" dependencies = [ "deno_error 0.6.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -14426,9 +14520,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -14452,7 +14546,7 @@ checksum = "974fa1e325e6cc5327de8887f189a441fcff4f8eedcd31ec87f0ef0cc5283fbc" dependencies = [ "bytes", "http 1.5.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -14570,7 +14664,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-nats", @@ -14655,7 +14749,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.783.0" +version = "1.784.0" dependencies = [ "async-stream", "async-trait", @@ -14688,7 +14782,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14701,7 +14795,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "argon2", @@ -14841,7 +14935,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14864,7 +14958,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14881,7 +14975,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14907,7 +15001,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.783.0" +version = "1.784.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14917,7 +15011,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14934,7 +15028,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14956,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14979,7 +15073,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14995,7 +15089,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15017,7 +15111,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15038,7 +15132,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15052,7 +15146,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-nats", @@ -15087,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15112,7 +15206,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "flate2", @@ -15130,7 +15224,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15152,7 +15246,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15172,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15210,7 +15304,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15238,7 +15332,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.783.0" +version = "1.784.0" dependencies = [ "lazy_static", "serde", @@ -15250,7 +15344,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.783.0" +version = "1.784.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15275,7 +15369,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15289,7 +15383,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.783.0" +version = "1.784.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15324,7 +15418,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.783.0" +version = "1.784.0" dependencies = [ "chrono", "lazy_static", @@ -15338,7 +15432,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15347,7 +15441,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "uuid", @@ -15357,7 +15451,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.783.0" +version = "1.784.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15436,7 +15530,7 @@ dependencies = [ "systemstat", "tar", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tikv-jemalloc-ctl", "tokio", "tokio-postgres", @@ -15461,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.783.0" +version = "1.784.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15480,7 +15574,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.783.0" +version = "1.784.0" dependencies = [ "regex", "serde", @@ -15495,7 +15589,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15519,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "futures", @@ -15536,7 +15630,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.783.0" +version = "1.784.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15552,7 +15646,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -15573,7 +15667,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -15604,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "arc-swap", @@ -15629,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-stream", @@ -15663,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "futures", @@ -15673,7 +15767,7 @@ dependencies = [ "serde_json", "serde_yml", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "windmill-common", @@ -15681,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.783.0" +version = "1.784.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15690,7 +15784,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15702,7 +15796,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -15714,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "gosyn", @@ -15726,7 +15820,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15738,7 +15832,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -15750,7 +15844,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "nu-parser", @@ -15761,7 +15855,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15772,7 +15866,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15784,7 +15878,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15795,7 +15889,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-recursion", @@ -15817,7 +15911,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -15829,7 +15923,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15843,7 +15937,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15860,7 +15954,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15873,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde", @@ -15885,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15903,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15919,7 +16013,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15935,7 +16029,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -15949,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-recursion", @@ -15976,7 +16070,7 @@ dependencies = [ "serde_urlencoded", "sql-builder", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "ulid", @@ -15988,7 +16082,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "const_format", @@ -16028,7 +16122,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.783.0" +version = "1.784.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16039,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-recursion", @@ -16074,7 +16168,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16098,7 +16192,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16131,7 +16225,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16143,7 +16237,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-executor-trait", "tokio-reactor-trait", @@ -16158,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16178,7 +16272,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -16191,7 +16285,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16211,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16230,7 +16324,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tonic 0.13.1", @@ -16245,7 +16339,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16268,7 +16362,7 @@ dependencies = [ "sha1", "sha2 0.10.9", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "windmill-api-auth", @@ -16281,7 +16375,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16304,7 +16398,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16316,7 +16410,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "windmill-api-auth", @@ -16328,7 +16422,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-nats", @@ -16352,7 +16446,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16372,7 +16466,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-postgres", "tokio-stream", @@ -16387,7 +16481,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16403,7 +16497,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "windmill-api-auth", @@ -16415,7 +16509,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-trait", @@ -16440,7 +16534,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16459,7 +16553,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-once-cell", @@ -16575,7 +16669,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.783.0" +version = "1.784.0" dependencies = [ "bytes", "futures", @@ -17197,7 +17291,7 @@ dependencies = [ "nom", "oid-registry 0.8.1", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9163904548..c7f5adacf5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.783.0" +version = "1.784.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.783.0" +version = "1.784.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 8bd1d5bb1b..13ae6b9fed 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.783.0" +version = "1.784.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.783.0" +version = "1.784.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.783.0" +version = "1.784.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.783.0" +version = "1.784.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 2e527cef86..e84f30d225 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.783.0" +version = "1.784.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d89ac01582..00fd6f4d69 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.783.0 + version: 1.784.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index f4b3d6b689..fe2e7cb64b 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.783.0"; +export const VERSION = "v1.784.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index ccfc78dbef..02961b09bd 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.783.0"; +export const VERSION = "1.784.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7113b74147..4c0fd72b83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.783.0", + "version": "1.784.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.783.0", + "version": "1.784.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ff080f353b..63f125aaba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.783.0", + "version": "1.784.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index b6e51b4eba..4df28770bd 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.783.0" +wmill = ">=1.784.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index dcf0b6498b..5d4cf5515a 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.783.0 + version: 1.784.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index cb45694566..8680eefde2 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.783.0' + ModuleVersion = '1.784.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 51794989e4..f3d98e83f4 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.783.0" +version = "1.784.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 162303dac5..c4aa5c81db 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.783.0", + "version": "1.784.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index b47f3dbaa0..e75b692abe 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.783.0", + "version": "1.784.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 0e9fc2c499..10d6b44939 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.783.0 +1.784.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index e1f881fb0c..1410db7da3 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.783.0", + "version": "1.784.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.783.0", + "version": "1.784.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 76e512660f..4ab3ad03f6 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.783.0", + "version": "1.784.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 06c6b8780c919e6f110bd05ffeaed8bc065ddf56 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 10 Aug 2026 22:59:21 +0200 Subject: [PATCH 020/192] fix: scope a fork's cloned app policy and custom path to its creator (#10595) * fix: scope a fork's cloned app policy and custom path to its creator Co-Authored-By: Claude Opus 5 (1M context) * fix: gate a cloned anonymous app on the parent's own deployment rule Co-Authored-By: Claude Opus 5 (1M context) * docs: state why a cloned anonymous app is gated more strictly than create_app Co-Authored-By: Claude Opus 5 (1M context) * style: wrap an over-long comment line in clone_apps Co-Authored-By: Claude Opus 5 (1M context) * fix: clone an app's execution_mode unchanged Forcing `publisher` on a cloned app was a speed bump rather than a boundary: protection rules are workspace-scoped and are not cloned, so the fork's creator can publish an anonymous app there with no rule in the way. It was also the one policy field a deploy back to the parent carries verbatim, since `update_app` recomputes the identity but writes the policy wholesale, so a fork's copy could silently close the parent's public endpoint. The identity rewrite is what closes the hole this addresses: the fork's endpoint no longer runs as whoever the parent published it as. Co-Authored-By: Claude Opus 5 * fix: ignore an app's run-as identity when comparing workspaces `compare_two_apps` hashed the whole policy, so a fork whose apps were re-pointed at their creator reported every one of them as changed. Nothing could clear those entries: the deploy offers the target's current identity, the deployer's, or a typed-in one, never the source's, so the difference survives however many times the item is deployed. `script` and `flow` already compare no identity. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- ...3ff0eb564e80be2ecb740ca56b6561a52751a.json | 14 ++ ...30629a82f8d2150be91cff20ad7e400e0135f.json | 22 ++ ...85362af4957db48382f5bc6e54a9d2e9f6db7.json | 12 + ...3435b1a929d89b07bf332d47553da7c8c0abc.json | 14 ++ ...18457ec947b75968bfceb56e289795a606d4e.json | 12 + ...ad253054ee609cc1e767d81bde35ca271259d.json | 23 ++ ...8d556534d74b06570bb16be7a2f4530451651.json | 26 +++ ...918bc042b58714ba5d66568770e7f11153ece.json | 14 ++ .../tests/fork_clone_on_behalf_of.rs | 205 +++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 82 +++++-- 10 files changed, 401 insertions(+), 23 deletions(-) create mode 100644 backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json create mode 100644 backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json create mode 100644 backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json create mode 100644 backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json create mode 100644 backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json create mode 100644 backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json create mode 100644 backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json create mode 100644 backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json diff --git a/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json b/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json new file mode 100644 index 0000000000..78094958e3 --- /dev/null +++ b/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a" +} diff --git a/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json new file mode 100644 index 0000000000..05c3ac8a7a --- /dev/null +++ b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path)\n VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path')\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f" +} diff --git a/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json b/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json new file mode 100644 index 0000000000..dd22a57595 --- /dev/null +++ b/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-cmp', 'u/test-user/identity_only', 'app', 0, 1, NULL),\n ('test-workspace', 'wm-fork-cmp', 'u/test-user/summary_too', 'app', 0, 1, NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7" +} diff --git a/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json b/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json new file mode 100644 index 0000000000..e22fb02d1b --- /dev/null +++ b/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET policy = policy || $1::jsonb\n WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/identity_only'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc" +} diff --git a/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json b/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json new file mode 100644 index 0000000000..62a0d5251c --- /dev/null +++ b/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET summary = 'edited'\n WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/summary_too'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e" +} diff --git a/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json b/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json new file mode 100644 index 0000000000..488412cd34 --- /dev/null +++ b/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions)\n VALUES ('test-workspace', $1, 'original', $2, '{}')\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d" +} diff --git a/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json new file mode 100644 index 0000000000..395d05d6da --- /dev/null +++ b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true + ] + }, + "hash": "e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651" +} diff --git a/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json new file mode 100644 index 0000000000..a2751953bd --- /dev/null +++ b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece" +} diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index 93c9f5adaf..dae02f3e5a 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -3,6 +3,203 @@ use sqlx::{Pool, Postgres}; use windmill_test_utils::*; +/// Seed an anonymous public app owned by the parent's admin, then fork as `token`. Returns the +/// cloned app's policy and custom path. +async fn fork_with_public_app( + db: &Pool, + token: &str, +) -> anyhow::Result<(serde_json::Value, Option)> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + let app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path) + VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path') + RETURNING id", + json!({ + "on_behalf_of": "u/test-user", + "on_behalf_of_email": "test@windmill.dev", + "execution_mode": "anonymous", + }) + ) + .fetch_one(db) + .await?; + // The clone re-aggregates `versions` from `app_version`, so an app without one lands in the + // fork with a NULL array. + sqlx::query!( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + VALUES ($1, '{}'::json, 'test-user') RETURNING id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + app_id + ) + .execute(db) + .await?; + + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "id": "wm-fork-app", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let cloned = + sqlx::query!("SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'") + .fetch_one(db) + .await?; + Ok((cloned.policy, cloned.custom_path)) +} + +/// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs +/// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may +/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a +/// plain member of the parent. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_repoints_app_identity_for_unprivileged_creator( + db: Pool, +) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user-2")); + assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev")); + // `execution_mode` rides along untouched — see `clone_apps`. + assert_eq!(policy["execution_mode"], json!("anonymous")); + assert_eq!(custom_path, None); + + Ok(()) +} + +/// An admin could have set any of this through the app API, so their fork keeps the policy — which +/// is also what keeps dev workspaces, always admin-created, behaving like their parent. The custom +/// path still goes: it is the instance-wide address of the parent's live public app, and two rows +/// claiming it make it resolve to either one. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_keeps_app_policy_for_admin_creator(db: Pool) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user")); + assert_eq!(policy["on_behalf_of_email"], json!("test@windmill.dev")); + assert_eq!(policy["execution_mode"], json!("anonymous")); + assert_eq!(custom_path, None); + + Ok(()) +} + +/// A cloned app's identity is re-pointed at an unprivileged fork creator, and no deploy can +/// converge that back — the deployer picks the target's own value, their own, or a typed-in one, +/// never the source's. Reporting it would leave an entry the merge UI can never clear, on every +/// app in such a fork. A summary change alongside it keeps this honest. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_ignores_app_identity(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + + for path in ["u/test-user/identity_only", "u/test-user/summary_too"] { + let app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions) + VALUES ('test-workspace', $1, 'original', $2, '{}') + RETURNING id", + path, + json!({ "on_behalf_of": "u/test-user", "on_behalf_of_email": "test@windmill.dev" }) + ) + .fetch_one(&db) + .await?; + sqlx::query!( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + VALUES ($1, '{}'::json, 'test-user') RETURNING id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + app_id + ) + .execute(&db) + .await?; + } + + let resp = client + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-cmp", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + // Forked as an admin, so both apps arrive identical. Diverge each on one axis. + sqlx::query!( + "UPDATE app SET policy = policy || $1::jsonb + WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/identity_only'", + json!({ "on_behalf_of": "u/someone-else", "on_behalf_of_email": "else@windmill.dev" }) + ) + .execute(&db) + .await?; + sqlx::query!( + "UPDATE app SET summary = 'edited' + WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/summary_too'" + ) + .execute(&db) + .await?; + + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES ('test-workspace', 'wm-fork-cmp', 'u/test-user/identity_only', 'app', 0, 1, NULL), + ('test-workspace', 'wm-fork-cmp', 'u/test-user/summary_too', 'app', 0, 1, NULL)" + ) + .execute(&db) + .await?; + // The bootstrap migration flags pre-existing workspaces as untallied, which short-circuits + // the comparison. + sqlx::query!("DELETE FROM skip_workspace_diff_tally") + .execute(&db) + .await?; + + let comparison: serde_json::Value = client + .get(format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-cmp" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + let listed: Vec<&str> = comparison["diffs"] + .as_array() + .expect("diffs array") + .iter() + .filter_map(|d| d["path"].as_str()) + .collect(); + + assert!( + !listed.contains(&"u/test-user/identity_only"), + "an identity-only difference must not be reported: {listed:?}" + ); + assert!( + listed.contains(&"u/test-user/summary_too"), + "a real change must still be reported: {listed:?}" + ); + + Ok(()) +} + /// A principal only means something in the workspace whose `usr`/`group_` rows define it, and a /// fork copies the creator and the groups but not the rest of the membership. Carrying one over /// blindly would leave a runnable naming somebody who cannot authenticate there; dropping them @@ -106,11 +303,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any .count(); assert_eq!(orphaned, 0, "a dropped principal leaves no address behind"); assert_eq!( - sqlx::query_scalar!( - "SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'" - ) - .fetch_one(&db) - .await?, + sqlx::query_scalar!("SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'") + .fetch_one(&db) + .await?, None ); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index f69c9dbd9a..e0c751ebfb 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5378,16 +5378,16 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } -// `authed_email` is the forker's email — `clone_drafts` only carries this -// user's per-user drafts (and the legacy NULL-email workspace draft, if any) -// across, since other users aren't added to the fork's `usr` table and -// their drafts would dangle as orphans. +// `authed` is the forker — `clone_drafts` only carries this user's per-user +// drafts (and the legacy NULL-email workspace draft, if any) across, since other +// users aren't added to the fork's `usr` table and their drafts would dangle as +// orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, db: &DB, source_workspace_id: &str, target_workspace_id: &str, - authed_email: &str, + authed: &ApiAuthed, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -5441,7 +5441,7 @@ async fn clone_workspace_data( clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; // Clone apps with new IDs and app scripts - let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; + let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; @@ -5452,7 +5452,7 @@ async fn clone_workspace_data( // own a `usr` row in the fork (see `clone_workspace_full`) so their // drafts would dangle and the home-page `draft_users` aggregate would // surface them as duplicate legacy entries. - clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?; // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -6393,11 +6393,35 @@ async fn clone_flow_nodes( Ok(()) } +/// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app` +/// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what +/// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives +/// any revocation in the parent. +fn repoint_cloned_app_identity(policy: &mut serde_json::Value, authed: &ApiAuthed) { + let Some(obj) = policy.as_object_mut() else { + return; + }; + obj.insert( + "on_behalf_of".to_string(), + serde_json::Value::String(username_to_permissioned_as(&authed.username)), + ); + obj.insert( + "on_behalf_of_email".to_string(), + serde_json::Value::String(authed.email.clone()), + ); +} + async fn clone_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed: &ApiAuthed, ) -> Result> { + // `execution_mode` is cloned as-is: protection rules are workspace-scoped and not cloned, so + // forcing `publisher` is only a speed bump (the creator can publish an anonymous app in the + // fork freely), and it is the one policy field a deploy back to the parent carries verbatim — + // `update_app` recomputes the identity but writes the policy wholesale. + let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed); // Get all apps from source workspace let apps = sqlx::query!( "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path @@ -6417,10 +6441,25 @@ async fn clone_apps( let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs - for app in apps { + for mut app in apps { if let Some(¤t_version) = app.versions.last() { latest_version_ids.insert(current_version); } + if !preserve_identity { + repoint_cloned_app_identity(&mut app.policy, authed); + } + // Both halves of what `create_app` demands to set a custom path: admin, and — unless + // paths are scoped per workspace — that nobody else holds it. Cloning one instance-wide + // would leave the parent's live public URL, resolved with no workspace filter and no + // ordering, answering from either row. + let scoped = *CLOUD_HOSTED + || windmill_common::apps::APP_WORKSPACED_ROUTE + .load(std::sync::atomic::Ordering::Relaxed); + let custom_path = if scoped && authed.is_admin { + app.custom_path + } else { + None + }; let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -6431,7 +6470,7 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.custom_path, + custom_path, ) .fetch_one(&mut **tx) .await?; @@ -7284,14 +7323,8 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = clone_workspace_data( - &mut tx, - &db, - &parent_workspace_id, - &forked_id, - &authed.email, - ) - .await + if let Err(e) = + clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: @@ -10988,6 +11021,19 @@ async fn compare_two_flows( }); } +/// The policy minus the identity pair. Deploying cannot converge a difference there — the +/// target recomputes the identity from the deployer's own choice, which offers its current +/// value, the deployer, or a typed-in one, never the source's — so listing an app for it +/// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either. +fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value { + let mut policy = policy.clone(); + if let Some(obj) = policy.as_object_mut() { + obj.remove("on_behalf_of"); + obj.remove("on_behalf_of_email"); + } + policy +} + async fn compare_two_apps( db: &DB, source_workspace_id: &str, @@ -11028,7 +11074,7 @@ async fn compare_two_apps( // Check metadata and content differences if let (Some(source), Some(target)) = (&source_app, &target_app) { if source.summary != target.summary - || source.policy != target.policy + || policy_without_identity(&source.policy) != policy_without_identity(&target.policy) || source.value != target.value || source.raw_app != target.raw_app { From 13b521651bdbe31bf0179898646c04391d46f20b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 10 Aug 2026 23:04:27 +0200 Subject: [PATCH 021/192] fix(python-client): return at most size bytes from S3BufferedReader.read (#10623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add unit tests for S3BufferedReader.read and improve read method implementation * feat: refactor S3BufferedReader.read method and add unit tests for its functionality * feat: implement peek() on S3BufferedReader with buffered reads * fix(python-client): keep the read(size) contract and trim the test surface Drop the duplicated `TestS3BufferedReaderRead` class from `python-client/tests/wmill_client_test.py`: CI runs `pytest tests/` from `python-client/wmill`, so that legacy manual harness never executes, and the same assertions already live in `python-client/wmill/tests/test_s3_reader.py`. Narrow that file to the four behaviours a future change could break, and make the `bytes_generator` guard actually call `bytes_generator`. Align `peek()` with `io.BufferedReader.peek`, which does at most one read on the underlying stream, rather than looping until `size` bytes are buffered. Co-Authored-By: Claude Opus 5 (1M context) * fix(python-client): hold read1 to one underlying read read1 forwarded to read, so read1(-1) drained the whole object — the same unbounded buffering this branch removes from read. Now that a buffer exists, read1 can honour its own contract: fill only when the buffer is empty, then serve from it. Also treat read(None) as read(-1), per the BufferedReader contract, and pin that read(0) does not pull from the stream: that holds only because the drain sentinel is a negative size, and widening it to any falsy size would reintroduce whole-file buffering. Co-Authored-By: Claude Opus 5 (1M context) * fix(python-client): return from read1(0) without touching the stream A zero-length read has nothing to serve, so pulling a chunk to satisfy it both wastes a round trip and advances the stream. Guard it ahead of the fill, and pin it with a chunk source that counts pulls. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Tushar Co-authored-by: Claude Opus 5 (1M context) --- python-client/wmill/tests/test_s3_reader.py | 97 +++++++++++++++++++++ python-client/wmill/wmill/s3_reader.py | 57 +++++++++--- 2 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 python-client/wmill/tests/test_s3_reader.py diff --git a/python-client/wmill/tests/test_s3_reader.py b/python-client/wmill/tests/test_s3_reader.py new file mode 100644 index 0000000000..0314791244 --- /dev/null +++ b/python-client/wmill/tests/test_s3_reader.py @@ -0,0 +1,97 @@ +"""Unit tests for S3BufferedReader: no network or env needed.""" + +from wmill.s3_reader import S3BufferedReader, bytes_generator + +CHUNKS = [b"AAAAAAAAAA", b"BBBBBBBBBB", b"CCCCCCCCCC"] + + +class _FakeStream: + """Stands in for the httpx streaming response the reader consumes.""" + + status_code = 200 + + def __init__(self, chunks): + self._chunks = chunks + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def iter_bytes(self): + return iter(self._chunks) + + +class CountingIterator: + """Chunk source that records how many times the reader pulled from it.""" + + def __init__(self, chunks): + self._chunks = chunks + self.pulls = 0 + + def __iter__(self): + for chunk in self._chunks: + self.pulls += 1 + yield chunk + + +class _FakeClient: + """Stands in for the httpx client, so construction never touches the network.""" + + def __init__(self, chunks): + self._chunks = chunks + + def stream(self, method, url, params=None, timeout=None): + return _FakeStream(self._chunks) + + +def make_reader(chunks): + reader = S3BufferedReader("ws", _FakeClient(chunks), "file.txt", None, None) + reader.__enter__() + return reader + + +def test_read_size_slices_chunks_and_keeps_the_remainder(): + reader = make_reader(CHUNKS) + # read(0) must not pull from the stream: the "drain everything" sentinel is + # a negative size, and widening it to any falsy size would reintroduce the + # whole-file buffering this reader is built to avoid. + assert reader.read(0) == b"" + assert reader.read(5) == b"AAAAA" + assert reader.read(5) == b"AAAAA" + assert reader.read(10) == b"BBBBBBBBBB" + assert reader.read(7) == b"CCCCCCC" + assert reader.read(5) == b"CCC" + assert reader.read(5) == b"" + + +def test_read_all_drains_both_the_buffer_and_the_stream(): + reader = make_reader(CHUNKS) + assert reader.read(5) == b"AAAAA" + assert reader.read(-1) == b"AAAAABBBBBBBBBBCCCCCCCCCC" + + +def test_bytes_generator_yields_50kb_slices_of_64kb_chunks(): + reader = make_reader([b"x" * 65536] * 5) + sizes = [len(chunk) for chunk in bytes_generator(reader)] + assert max(sizes) <= 50 * 1024 + assert sum(sizes) == 5 * 65536 + + +def test_peek_does_not_consume(): + reader = make_reader(CHUNKS) + assert reader.peek() == b"AAAAAAAAAA" + assert reader.read(10) == b"AAAAAAAAAA" + + +def test_read1_stops_after_one_chunk(): + counting = CountingIterator(CHUNKS) + reader = make_reader(counting) + # A zero-length read must not touch the stream at all. + assert reader.read1(0) == b"" + assert counting.pulls == 0 + # read1(-1) must not drain the stream the way read(-1) does. + assert reader.read1(-1) == b"AAAAAAAAAA" + assert reader.read1(4) == b"BBBB" + assert counting.pulls == 2 diff --git a/python-client/wmill/wmill/s3_reader.py b/python-client/wmill/wmill/s3_reader.py index 1f3616c746..2a8f3de2c8 100644 --- a/python-client/wmill/wmill/s3_reader.py +++ b/python-client/wmill/wmill/s3_reader.py @@ -28,6 +28,7 @@ class S3BufferedReader(BufferedReader): params=params, timeout=None, ) + self._buffer = bytearray() def __enter__(self): reader = self._context_manager.__enter__() @@ -46,25 +47,53 @@ class S3BufferedReader(BufferedReader): return self def peek(self, size=0): - raise Exception("Not implemented, use read() instead") + """Return buffered bytes without consuming them. + + Reads the underlying stream at most once, so the amount returned may be + more or less than `size`. + """ + if not self._buffer: + self._fill(1) + return bytes(self._buffer) + + def _fill(self, limit): + # iter_bytes() yields whole HTTP chunks (~64KB), so a caller asking for + # `limit` bytes has to accumulate until the buffer holds that many. + # A negative limit means drain the stream. + while limit < 0 or len(self._buffer) < limit: + try: + self._buffer.extend(next(self._iterator)) + except StopIteration: + break def read(self, size=-1): - read_result = [] + # BufferedReader.read(None) is documented as equivalent to read(-1). + if size is None: + size = -1 + self._fill(size) if size < 0: - for b in self._iterator: - read_result.append(b) - else: - for i in range(size): - try: - b = self._iterator.__next__() - except StopIteration: - break - read_result.append(b) - - return b"".join(read_result) + result = bytes(self._buffer) + self._buffer.clear() + return result + result = bytes(self._buffer[:size]) + del self._buffer[:size] + return result def read1(self, size=-1): - return self.read(size) + """Return up to `size` bytes, reading the underlying stream at most once. + + Unlike `read`, a negative `size` returns only what is already buffered + rather than draining the whole object. + """ + if size == 0: + return b"" + if not self._buffer: + self._fill(1) + if size is None or size < 0: + size = len(self._buffer) + result = bytes(self._buffer[:size]) + del self._buffer[:size] + return result def __exit__(self, *args): self._context_manager.__exit__(*args) From d9b9137e17d4d009167ca20413bd1a9f1c204f8b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 08:45:48 +0200 Subject: [PATCH 022/192] feat(sdk): add cancelJob to the TypeScript client (#10624) * feat(sdk): add cancelJob to the TypeScript client The Python client has had cancel_job since forever; the TypeScript one had no way to cancel a job at all. Wire the same jobs_u/queue/cancel endpoint, with a default reason when none is given, and export it from both the named and default exports of the npm package as well as the JSR one. * chore: regenerate system prompts for cancelJob check-system-prompts triggers on typescript-client/**, so the agent-facing SDK reference has to carry the new function. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Tushar Co-authored-by: Claude Opus 5 (1M context) --- cli/src/guidance/skills.gen.ts | 24 +++++++++++++++++++ system_prompts/auto-generated/prompts.ts | 8 +++++++ system_prompts/auto-generated/script.md | 8 +++++++ .../auto-generated/sdks/typescript.md | 8 +++++++ .../skills/write-script-bun/SKILL.md | 8 +++++++ .../skills/write-script-bunnative/SKILL.md | 8 +++++++ .../skills/write-script-deno/SKILL.md | 8 +++++++ typescript-client/build.jsr.sh | 2 +- typescript-client/build.sh | 4 +++- typescript-client/client.ts | 21 ++++++++++++++++ 10 files changed, 97 insertions(+), 2 deletions(-) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 71047bbec5..56cb9730cb 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -646,6 +646,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill @@ -1410,6 +1418,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill @@ -2268,6 +2284,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 285b217db6..49bf3152e6 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1220,6 +1220,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index dd1fe07ac5..762763d606 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1536,6 +1536,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 56be056d8e..7e5aea0e96 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -101,6 +101,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 75dab4b624..544eaceaf7 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -272,6 +272,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 600fed06b2..dcafc57293 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -272,6 +272,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index 2e671f139d..8263f3665b 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -274,6 +274,14 @@ async getResult(jobId: string): Promise */ async getResultMaybe(jobId: string): Promise +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +async cancelJob(jobId: string, reason: string | undefined = undefined): Promise + /** * Run a script asynchronously by its path * @param path - Script path in Windmill diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index 821c4f8a0c..91a01aafb6 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -16,6 +16,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 5a74ca37d6..874e61e070 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -46,7 +46,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -66,6 +66,7 @@ import { getState, getIdToken, denoS3LightClientSettings, + cancelJob, loadS3FileStream, loadS3File, writeS3File, @@ -155,6 +156,7 @@ const wmill = { getState, getIdToken, denoS3LightClientSettings, + cancelJob, loadS3FileStream, loadS3File, writeS3File, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 3ec4c22aae..f7be5e5a6d 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -336,6 +336,27 @@ export async function getResultMaybe(jobId: string): Promise { const workspace = getWorkspace(); return await JobService.getCompletedJobResultMaybe({ workspace, id: jobId }); } + +/** + * Cancel a queued or running job by ID. + * @param jobId - UUID of the job to cancel + * @param reason - Optional reason for cancellation + * @returns Response message from the cancel endpoint + */ +export async function cancelJob( + jobId: string, + reason: string | undefined = undefined +): Promise { + const workspace = getWorkspace(); + return await JobService.cancelQueuedJob({ + workspace, + id: jobId, + requestBody: { + reason: reason ?? "cancelled via cancelJob method", + }, + }); +} + const STRIP_COMMENTS = /(\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s*=[^,\)]*(('(?:\\'|[^'\r\n])*')|("(?:\\"|[^"\r\n])*"))|(\s*=[^,\)]*))/gm; function getParamNames(func: Function): string[] { From ede4e7781d94d860b9d2f32777312f1a3f24fc3e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 09:24:47 +0200 Subject: [PATCH 023/192] unbreak the JSR publish of the typescript client (#10627) * fix(sdk): unbreak the JSR publish of the typescript client `Sql` is `export type Sql = string`, but build.jsr.sh re-exported it as a value, so `deno publish` fails type-checking with TS1205 under isolatedModules. Every `v*` tag since has published nothing to JSR. The npm build never noticed because it lists the same symbol as `type Sql`; the two scripts keep separate copies of the export list. Record both JSR-only constraints next to the list, since neither shows up until a release tag runs publish.jsr.sh. Co-Authored-By: Claude Opus 5 (1M context) * fix(sdk): scope the slow-types note to what deno actually rejects Deno's fast check only rejects a return type it cannot trivially infer; setClient, appendToResultStream and streamResult are all exported without one and publish fine. The previous wording read as if the current list were already non-compliant. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- typescript-client/build.jsr.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index 91a01aafb6..08c06f586f 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -13,9 +13,15 @@ cp "${script_dirpath}/client.ts" "${script_dirpath}/src/" cp "${script_dirpath}/wacError.ts" "${script_dirpath}/src/" cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/" cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" +# Two JSR-only rules, enforced by `jsr publish` (which publish.jsr.sh runs +# without --allow-slow-types) and so not reachable before a release tag: +# a type must be re-exported as `type X`, or deno fails with TS1205 under +# isolatedModules; and an exported function whose return type deno cannot +# trivially infer needs an explicit annotation, or it is a "slow type". +# `./build.jsr.sh && deno publish --dry-run` checks both. echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" From 4fafe59371a836f738359f3aa438d2a219e3e934 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 09:34:27 +0200 Subject: [PATCH 024/192] fix: bump the bundled DuckDB engine to 1.5.5 (#10588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: bump the bundled DuckDB engine to 1.5.5 The 1.5.5 duckdb crate no longer hands back a 96-bit `rust_decimal`, so a DECIMAL wider than that renders instead of panicking inside an `extern "C"` frame — which, being unable to unwind, aborted the whole worker process and left the job running as a zombie. `SELECT '1234567890123456789012345678.9012345678'::DECIMAL(38, 10)` was enough. Adapting to the crate's API: `Value` is now `#[non_exhaustive]` and gained `UHugeInt` and `Geometry`, and `rust_decimal` became an optional feature that the `decimal`/`numeric` argument path still needs. Co-Authored-By: Claude Opus 5 (1M context) * fix: address review findings on the duckdb bump Run the FFI crate's own tests in CI: it is excluded from the workspace, so the `cargo test --all` in backend-test never reached them and the new guard against the worker-aborting DECIMAL would not have run. build_dev.sh now honors a caller-pinned CARGO_TARGET_DIR so the test build reuses that compile instead of building the bundled engine a second time. Also pin UHUGEINT rendering, and correct the rust_decimal rationale — `Decimal::new` is public without the feature, so the reason is that the feature reproduces the exact binding the crate used to derive, not that nothing else can. Co-Authored-By: Claude Opus 5 (1M context) * fix: address review nits on the duckdb bump Name the unsupported DuckDB type rather than dumping the value, which may be arbitrarily large or hold data that does not belong in an error message, and say which column it came from. Co-Authored-By: Claude Opus 5 (1M context) * chore: pin the ee ref to the narrowed duckdb extension allowlist Co-Authored-By: Claude Opus 5 (1M context) * chore: pin the ee ref to the verified duckdb extension allowlist Co-Authored-By: Claude Opus 5 (1M context) * chore: pin the ee ref to the allowlist regression test Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 04dd9c5c352f04995cd0470400a877261f956561 This commit updates the EE repository reference after PR #716 was merged in windmill-ee-private. Previous ee-repo-ref: fe7eb440a5bbae37774d3a96b69ab5c46c0b8936 New ee-repo-ref: 04dd9c5c352f04995cd0470400a877261f956561 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- .github/workflows/backend-test.yml | 5 +- backend/ee-repo-ref.txt | 2 +- .../windmill-duckdb-ffi-internal/Cargo.lock | 905 +----------------- .../windmill-duckdb-ffi-internal/Cargo.toml | 7 +- .../windmill-duckdb-ffi-internal/build_dev.sh | 7 +- .../windmill-duckdb-ffi-internal/src/lib.rs | 70 +- 6 files changed, 124 insertions(+), 872 deletions(-) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index e6fb389e56..49bede8a4d 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -291,5 +291,8 @@ jobs: TEST_NPM_REGISTRY: "http://localhost:4873/:_authToken=${{ env.NPM_TOKEN }}" run: | deno --version && bun -v && node --version && go version && python3 --version && php --version && ruby --version && pwsh --version && dotnet --version - cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd .. + # The FFI crate is excluded from the workspace, so the `cargo test` below + # never reaches it. Pin the target dir (matching the cache step above) so + # its own tests run off this compile rather than a second bundled build. + (cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal) DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10 diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 054722489d..8cc05b9d55 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -236115e11f074d86675aa5acdf5061dd3e64f43c +04dd9c5c352f04995cd0470400a877261f956561 diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.lock b/backend/windmill-duckdb-ffi-internal/Cargo.lock index b070ec137d..93a98d58de 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.lock +++ b/backend/windmill-duckdb-ffi-internal/Cargo.lock @@ -238,12 +238,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.0" @@ -472,22 +466,11 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "duckdb" -version = "1.10502.0" +version = "1.10505.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fdc796383b176dd5a45353fbb5e64583c0ee4da12cb62c9e510b785324b2488" +checksum = "970e05eedd3f55c435194d9104f90a9b4a79a80d6e73251bc9ff43e178130c4e" dependencies = [ "arrow", "cast", @@ -563,70 +546,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -634,10 +559,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -647,11 +570,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -715,96 +636,12 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -829,108 +666,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -941,22 +676,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "itoa" version = "1.0.17" @@ -1048,17 +767,17 @@ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libduckdb-sys" -version = "1.10502.0" +version = "1.10505.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7401630ae2abcff642f7156294289e50f2d222e061c026ad797b01bf20c215" +checksum = "6cb514dab5e271e849235c1cb98bd65a2ae107fbd619a6740219319c54a71d95" dependencies = [ "cc", "flate2", "pkg-config", - "reqwest", "serde", "serde_json", "tar", + "ureq", "vcpkg", "zip", ] @@ -1092,12 +811,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "lock_api" version = "0.4.14" @@ -1113,12 +826,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "memchr" version = "2.8.0" @@ -1135,17 +842,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -1219,33 +915,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1293,61 +968,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.44" @@ -1376,18 +996,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -1397,17 +1007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1419,15 +1019,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1484,46 +1075,6 @@ dependencies = [ "bytecheck", ] -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - [[package]] name = "ring" version = "0.17.14" @@ -1577,18 +1128,12 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand", "rkyv", "serde", "serde_json", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustix" version = "0.38.44" @@ -1621,6 +1166,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1635,7 +1181,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -1718,18 +1263,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1748,34 +1281,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strum" version = "0.26.3" @@ -1844,26 +1355,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tap" version = "1.0.1" @@ -1881,26 +1372,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -1910,16 +1381,6 @@ dependencies = [ "crunchy", ] -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tinyvec" version = "1.10.0" @@ -1935,30 +1396,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1989,76 +1426,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2078,22 +1445,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "url" -version = "2.5.8" +name = "ureq" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "form_urlencoded", - "idna", + "base64", + "log", "percent-encoding", - "serde", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", ] [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "ureq-proto" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "uuid" @@ -2117,15 +1500,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2154,20 +1528,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.113" @@ -2200,26 +1560,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-sys" -version = "0.3.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "1.0.6" @@ -2328,16 +1668,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2355,31 +1686,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2388,96 +1702,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" @@ -2493,12 +1759,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - [[package]] name = "wyz" version = "0.5.1" @@ -2518,29 +1778,6 @@ dependencies = [ "rustix 1.1.4", ] -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.39" @@ -2561,66 +1798,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zip" version = "6.0.0" diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.toml b/backend/windmill-duckdb-ffi-internal/Cargo.toml index 59209287c8..ba8b74fac0 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.toml +++ b/backend/windmill-duckdb-ffi-internal/Cargo.toml @@ -5,7 +5,12 @@ edition = "2024" [dependencies] chrono = "0.4.41" -duckdb = { version = "1.10502.0", features = ["bundled"] } +# `rust_decimal` is optional upstream. It stays on because its +# `From` derives the same width/scale/mantissa the crate +# used to derive itself, so a `decimal`/`numeric` job argument still binds to the +# identical DECIMAL. Building one by hand would mean reimplementing that +# f64 → (mantissa, scale) inference. +duckdb = { version = "1.10505.0", features = ["bundled", "rust_decimal"] } regex = "1" rust_decimal = "1.37.2" serde = { version = "1.0", features = ["derive"] } diff --git a/backend/windmill-duckdb-ffi-internal/build_dev.sh b/backend/windmill-duckdb-ffi-internal/build_dev.sh index f3ce520c68..b445cc29f3 100755 --- a/backend/windmill-duckdb-ffi-internal/build_dev.sh +++ b/backend/windmill-duckdb-ffi-internal/build_dev.sh @@ -15,7 +15,12 @@ cd "$(dirname "$0")" src_dirty="$(git status --porcelain -- src Cargo.toml Cargo.lock build.rs 2>/dev/null || true)" -if [ -n "$src_dirty" ]; then +if [ -n "${CARGO_TARGET_DIR:-}" ]; then + # A caller that pinned the output dir wins over both: CI pins it so its cache + # step finds the artifacts and a following `cargo test` reuses this compile + # instead of building the bundled DuckDB a second time. + echo "duckdb-ffi: caller-pinned target $CARGO_TARGET_DIR" +elif [ -n "$src_dirty" ]; then export CARGO_TARGET_DIR="$PWD/target" echo "duckdb-ffi: local crate changes detected -> isolated target $CARGO_TARGET_DIR" else diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index a7543620b6..7577d98b94 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -621,7 +621,8 @@ fn row_to_value( for (i, key) in column_names.iter().enumerate() { let value: duckdb::types::Value = row.get(i).map_err(|e| e.to_string())?; let type_alias = &type_aliases[i]; - let json_value = duckdb_value_to_json_value(value, type_alias)?; + let json_value = duckdb_value_to_json_value(value, type_alias) + .map_err(|e| format!("column \"{key}\": {e}"))?; obj.insert(key.clone(), json_value); } serde_json::value::to_raw_value(&obj).map_err(|e| e.to_string()) @@ -639,6 +640,7 @@ fn duckdb_value_to_json_value( duckdb::types::Value::Int(i) => serde_json::Value::Number(i.into()), duckdb::types::Value::BigInt(i) => serde_json::Value::Number(i.into()), duckdb::types::Value::HugeInt(i) => serde_json::Value::String(i.to_string()), + duckdb::types::Value::UHugeInt(u) => serde_json::Value::String(u.to_string()), duckdb::types::Value::UTinyInt(u) => serde_json::Value::Number(u.into()), duckdb::types::Value::USmallInt(u) => serde_json::Value::Number(u.into()), duckdb::types::Value::UInt(u) => serde_json::Value::Number(u.into()), @@ -660,11 +662,14 @@ fn duckdb_value_to_json_value( .map_err(|e| format!("Error parsing JSON text: {}", e.to_string()))? } duckdb::types::Value::Text(s) => serde_json::Value::String(s), - duckdb::types::Value::Blob(b) => serde_json::Value::Array( - b.into_iter() - .map(|byte| serde_json::Value::Number(byte.into())) - .collect(), - ), + // GEOMETRY surfaces as WKB bytes; render it like any other byte string. + duckdb::types::Value::Blob(b) | duckdb::types::Value::Geometry(b) => { + serde_json::Value::Array( + b.into_iter() + .map(|byte| serde_json::Value::Number(byte.into())) + .collect(), + ) + } duckdb::types::Value::Date32(d) => { match chrono::DateTime::from_timestamp(i64::from(d) * 86_400, 0) { Some(dt) => serde_json::Value::String(dt.date_naive().to_string()), @@ -710,6 +715,18 @@ fn duckdb_value_to_json_value( .collect::, _>>()?, ), duckdb::types::Value::Union(value) => serde_json::Value::String(format!("{:?}", *value)), + // `Value` is `#[non_exhaustive]`: a newer engine can hand back a variant this + // build has never seen. Name its type instead of emitting a plausible-looking + // rendering that silently misrepresents the column. The type, never the value: + // a row that reaches here is already unprintable, and it may be arbitrarily + // large or hold data that does not belong in a job's error message. + other => { + return Err(format!( + "DuckDB type {:?} is not supported by this worker's engine bindings; \ + cast the column to a supported type (e.g. VARCHAR) to return it", + other.data_type() + )) + } }; Ok(json_value) } @@ -812,6 +829,44 @@ mod temporal_json_tests { assert_eq!(json_of(4), serde_json::json!("10:30:00")); } + // Numbers too wide for a JSON number are rendered as strings. DECIMAL runs to + // 38 digits and UHUGEINT to 2^128-1; rendering either through a type that + // cannot hold it aborts the whole worker rather than erroring, because the + // panic escapes an `extern "C"` frame and those cannot unwind. + #[test] + fn wide_numbers_render_as_strings_without_losing_precision() { + let conn = duckdb::Connection::open_in_memory().unwrap(); + let mut stmt = conn + .prepare( + "SELECT (-1.05)::DECIMAL(4, 2) AS neg, + (1.50)::DECIMAL(4, 2) AS trailing_zero, + '1234567890123456789012345678.9012345678'::DECIMAL(38, 10) AS wide, + '-9999999999999999999999999999.9999999999'::DECIMAL(38, 10) AS wide_neg, + '340282366920938463463374607431768211455'::UHUGEINT AS uhuge", + ) + .unwrap(); + let mut rows = stmt.query([]).unwrap(); + let row = rows.next().unwrap().unwrap(); + let json_of = |i: usize| { + let v: duckdb::types::Value = row.get(i).unwrap(); + duckdb_value_to_json_value(v, &None).unwrap() + }; + assert_eq!(json_of(0), serde_json::json!("-1.05")); + assert_eq!(json_of(1), serde_json::json!("1.50")); + assert_eq!( + json_of(2), + serde_json::json!("1234567890123456789012345678.9012345678") + ); + assert_eq!( + json_of(3), + serde_json::json!("-9999999999999999999999999999.9999999999") + ); + assert_eq!( + json_of(4), + serde_json::json!("340282366920938463463374607431768211455") + ); + } + // The data-test sample probe shape emitted by // `windmill-parser::sql_materialize::build_data_test_checks`: one scan // yielding the violating-row count plus a bounded `to_json` sample of the @@ -1174,7 +1229,8 @@ fn json_value_to_duckdb_value( "double" | "float8" => duckdb::types::Value::Double(v), "decimal" | "numeric" => duckdb::types::Value::Decimal( Decimal::from_f64(v) - .ok_or_else(|| "Could not convert f64 to Decimal".to_string())?, + .ok_or_else(|| "Could not convert f64 to Decimal".to_string())? + .into(), ), _ => duckdb::types::Value::Double(v), // default fallback } From 5125467de4ed54e9a0ba7f408f9e5c74bc8136fb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 11 Aug 2026 10:28:43 +0200 Subject: [PATCH 025/192] fix: accept a bodyless request that advertises a JSON content type (#10628) Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api/src/args.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index dc8b36fbaf..0b3c661d07 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -479,7 +479,10 @@ where let bytes = Bytes::from_request(request, _state) .await .map_err(IntoResponse::into_response)?; - if no_content_type && bytes.is_empty() { + // A request carries a body only when it signals one with Content-Length or + // Transfer-Encoding (RFC 9112 §6), yet it may advertise a Content-Type + // regardless. An empty body is therefore no args, not a malformed document. + if bytes.is_empty() { Ok(RawWebhookArgs { body: RawBody::Empty, metadata }) } else { let str = String::from_utf8(bytes.to_vec()) @@ -711,6 +714,26 @@ mod tests { use super::*; + #[tokio::test] + async fn test_bodyless_request_with_json_content_type() { + let request = Request::builder() + .method(http::Method::GET) + .uri("/api/r/customer/test") + .header(CONTENT_TYPE, "application/json") + .body(axum::body::Body::empty()) + .unwrap(); + + let args = try_from_request_body(request, &(), true) + .await + .unwrap_or_else(|_| panic!("bodyless GET should be accepted")); + + assert!( + matches!(args.body, RawBody::Empty), + "bodyless GET should carry no args, got {:?}", + args.body + ); + } + #[tokio::test] async fn test_cloudevents_json_payload() { let r1 = r#" From ec99108cf6a62951f0aebecbe4b14d7b80a14215 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 11:07:12 +0200 Subject: [PATCH 026/192] feat(triggers): nested filter groups and dotted paths (#10625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(triggers): nested any_of / all_of filter groups A trigger filter entry can now be a group — `{"any_of": [...]}` or `{"all_of": [...]}` — nesting further entries, so criteria like `A AND B AND (C OR D)` are expressible. Existing flat `{key, value}` lists keep their meaning, combined by the trigger's `filter_logic` as before. Filters are compiled once per connection: the set of top-level keys the whole tree references is collected up front, so a message is parsed in a single streaming pass that captures only those keys, instead of one full pass per leaf filter as before. Filters that fail to parse are now logged rather than dropped silently, since a nested group is easier to mistype than a flat entry. The editor gains "Add group", rendering groups recursively with their own AND/OR selector; Kafka and WebSocket triggers share it. Fixes WIN-2345 Co-Authored-By: Claude Opus 5 (1M context) * fix(triggers): drop empty filter groups instead of evaluating them A group with no criterion cannot evaluate to a constant: true makes an `or` filter list accept every message, false mutes an `and` list. Two clicks in the editor ("Add group", save) produced one. Drop it when compiling so its siblings stay in force, and reject at save time the filters the listener would otherwise drop silently. Also restore the item shape of `$ref`-typed arrays in the generated agent schemas: the extractor only resolved refs at the property level, so moving `filters.items` to a shared schema flattened it to a bare object. Resolving them inside `items` too also recovers the shapes `initial_messages` and the MQTT `topics` had already lost. Co-Authored-By: Claude Opus 5 (1M context) * fix(triggers): name the offending entry when a nested filter is invalid Serde's untagged error only reports that the outermost entry matched no variant, whatever depth is actually wrong, which defeats the point of validating a group at save time. Walk the tree instead and report the path. Normalize the WebSocket editor's filters to [] on load, as the Kafka editor does, so the list component can rely on an array. Co-Authored-By: Claude Opus 5 (1M context) * fix(triggers): key filter rows by node so deletion keeps values aligned The value editor seeds itself from `code` once, so an index-keyed row reused for a different filter kept showing the deleted row's value. Co-Authored-By: Claude Opus 5 (1M context) * chore: bump ee-repo-ref after merging main The merge pulled OSS code that needs EE symbols newer than the companion branch's base, so the companion was merged with EE main too. Co-Authored-By: Claude Opus 5 (1M context) * perf(triggers): keep filter short-circuiting from materializing unread fields The single-pass scan deserialized every referenced key before the boolean tree ran, so an AND whose first leaf rejects the message still allocated the large objects the later leaves name — the shape this feature exists for. Borrow the wanted keys as raw slices during the scan and parse a field only when evaluation actually reaches it. Co-Authored-By: Claude Opus 5 (1M context) * feat(triggers): none_of filter group Negation of a nested group, so a trigger can exclude what it must not react to without inverting every other criterion. A key the message does not carry satisfies it: there is nothing there to match. Only groups can negate — the root's operator is the trigger's filter_logic column, which has no value for it. Co-Authored-By: Claude Opus 5 (1M context) * feat(triggers): address a nested field with a dotted path `{path: "a.b.c", value: v}` alongside the existing `{key, value}`, so the common case reads the way people write it instead of nesting the shape into the value. A separate field rather than dots in `key`, which already means the top-level field spelled that way — overloading it would resettle what existing triggers over flattened payloads match. Paths address objects only for now: a path through an array does not match rather than guessing an element, and array containment stays on the value side. Co-Authored-By: Claude Opus 5 (1M context) * docs(triggers): mention none_of in the filter_logic description Plus a test for the empty-path-segment rejection, which had none. Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 78859aab0c6e78283ec8d2b37e8c410963afdc83 This commit updates the EE repository reference after PR #722 was merged in windmill-ee-private. Previous ee-repo-ref: 0e42ba72ccc38a6b0a380f58afe0db36d284f4c9 New ee-repo-ref: 78859aab0c6e78283ec8d2b37e8c410963afdc83 Automated by sync-ee-ref workflow. * fix(triggers): reject a criterion naming both key and path The untagged enum takes such an entry as a `key` criterion and drops the `path`, which is the silent-ignore the save-time validation exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) * refactor(triggers): drop the label next to the key/path toggle The toggle already shows which one is selected. Co-Authored-By: Claude Opus 5 (1M context) * fix(triggers): reject an entry that combines a criterion with a group Generalizes the key+path fix: the untagged enum settles a half-and-half entry on the first variant that fits and ignores the rest, so a criterion carrying a group key lost the whole subtree without a word. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 125 ++-- .../windmill-trigger-websocket/src/handler.rs | 4 +- .../src/listener.rs | 21 +- backend/windmill-trigger/src/filter.rs | 674 +++++++++++++++--- cli/src/guidance/skills.gen.ts | 157 +++- .../copilot/chat/workspaceToolsZod.gen.ts | 30 +- .../triggers/TriggerFilterList.svelte | 161 +++++ .../components/triggers/TriggerFilters.svelte | 88 +-- .../src/lib/components/triggers/filters.ts | 58 ++ .../kafka/KafkaTriggerEditorInner.svelte | 11 +- .../WebsocketTriggerEditorInner.svelte | 12 +- .../schemas/kafka_trigger.schema.yaml | 58 +- .../schemas/mqtt_trigger.schema.yaml | 12 + .../schemas/websocket_trigger.schema.yaml | 87 ++- system_prompts/generate.py | 12 +- system_prompts/utils.py | 61 +- 17 files changed, 1280 insertions(+), 293 deletions(-) create mode 100644 frontend/src/lib/components/triggers/TriggerFilterList.svelte create mode 100644 frontend/src/lib/components/triggers/filters.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8cc05b9d55..838ffcea87 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -04dd9c5c352f04995cd0470400a877261f956561 +78859aab0c6e78283ec8d2b37e8c410963afdc83 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 00fd6f4d69..a0efeaaa76 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -29112,6 +29112,56 @@ components: - interval_secs - message + TriggerFilter: + description: > + Either a leaf filter, matching a field of the message (parsed as JSON) against a + value by equality (or superset, when the value is an object or array) — addressed + by `key` for a top-level field or `path` for a dotted path into nested objects — + or a group nesting sub-filters under a boolean operator (`none_of` matches when + none of its sub-filters do). + oneOf: + - type: object + properties: + key: + type: string + value: {} + required: + - key + - value + - type: object + properties: + path: + type: string + description: Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays. + value: {} + required: + - path + - value + - type: object + properties: + any_of: + type: array + items: + $ref: "#/components/schemas/TriggerFilter" + required: + - any_of + - type: object + properties: + all_of: + type: array + items: + $ref: "#/components/schemas/TriggerFilter" + required: + - all_of + - type: object + properties: + none_of: + type: array + items: + $ref: "#/components/schemas/TriggerFilter" + required: + - none_of + WebsocketTrigger: allOf: - $ref: "#/components/schemas/TriggerExtraProperty" @@ -29132,23 +29182,16 @@ components: description: Last error message if the trigger failed filters: type: array - description: Array of key-value filters to match incoming messages (only matching messages trigger the script) + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." initial_messages: type: array nullable: true @@ -29204,23 +29247,16 @@ components: $ref: "#/components/schemas/TriggerMode" filters: type: array - description: Array of key-value filters to match incoming messages (only matching messages trigger the script) + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." initial_messages: type: array nullable: true @@ -29287,23 +29323,16 @@ components: description: True if script_path points to a flow, false if it points to a script filters: type: array - description: Array of key-value filters to match incoming messages (only matching messages trigger the script) + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." initial_messages: type: array nullable: true @@ -30559,22 +30588,16 @@ components: description: Array of Kafka topic names to subscribe to filters: type: array + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." auto_offset_reset: type: string enum: @@ -30637,22 +30660,16 @@ components: description: Array of Kafka topic names to subscribe to filters: type: array + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." auto_offset_reset: type: string enum: @@ -30711,22 +30728,16 @@ components: description: Array of Kafka topic names to subscribe to filters: type: array + description: "Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`." items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value + $ref: "#/components/schemas/TriggerFilter" filter_logic: type: string enum: - and - or default: and - description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match." + description: "Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic." auto_offset_reset: type: string enum: diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index c2ca37c500..a428bc7f87 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -12,7 +12,7 @@ use windmill_common::{ worker::to_raw_value, }; use windmill_git_sync::DeployedObject; -use windmill_trigger::{Trigger, TriggerCrud, TriggerData}; +use windmill_trigger::{filter::CompiledFilters, Trigger, TriggerCrud, TriggerData}; use super::{ get_url_from_runnable_value, listener::InitialMessage, proxy::connect_async_with_proxy, @@ -106,6 +106,8 @@ impl TriggerCrud for WebsocketTrigger { } } + CompiledFilters::validate(&config.filters)?; + if let Some(ref hb) = config.heartbeat { if hb.interval_secs < 1 { return Err(Error::BadRequest( diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index a38f2434a4..91bbc4bf23 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -21,7 +21,7 @@ use windmill_common::{ DB, }; use windmill_queue::PushArgsOwned; -use windmill_trigger::filter::{check_filters, Filter}; +use windmill_trigger::filter::CompiledFilters; use windmill_trigger::listener::{update_rw_lock, ListeningTrigger}; use windmill_trigger::trigger_helpers::{ trigger_runnable, trigger_runnable_and_wait_for_raw_result, @@ -362,15 +362,14 @@ impl Listener for WebsocketTrigger { } => {}, // Message reader _ = async { - let filters: Vec = if listening_trigger.trigger_mode { - listening_trigger - .trigger_config - .filters - .iter() - .filter_map(|m| serde_json::from_str(m.get()).ok()) - .collect_vec() + let filters = if listening_trigger.trigger_mode { + CompiledFilters::parse( + listening_trigger.trigger_config.filters.iter().map(|m| m.get()), + listening_trigger.trigger_config.filter_logic == "or", + &listening_trigger.path, + ) } else { - vec![] + CompiledFilters::default() }; loop { if let Some(msg) = reader.next().await { @@ -391,9 +390,7 @@ impl Listener for WebsocketTrigger { } } - let use_or = listening_trigger.trigger_config.filter_logic == "or"; - let should_handle = check_filters(&text, &filters, use_or); - if should_handle { + if filters.matches(&text) { let trigger_info = HashMap::from([ ("url".to_string(), to_raw_value(&listening_trigger.trigger_config.url)), ]); diff --git a/backend/windmill-trigger/src/filter.rs b/backend/windmill-trigger/src/filter.rs index 3c9c857058..3c1ab7db76 100644 --- a/backend/windmill-trigger/src/filter.rs +++ b/backend/windmill-trigger/src/filter.rs @@ -2,52 +2,301 @@ use serde::{ de::{self, MapAccess, Visitor}, Deserialize, Deserializer, }; -use serde_json::Value; -use std::fmt; +use serde_json::{value::RawValue, Value}; +use std::{collections::HashMap, fmt}; -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct JsonFilter { pub key: String, pub value: Value, } -#[derive(Deserialize)] +/// Same comparison as [`JsonFilter`], but the field is addressed by a dotted path into +/// nested objects. A separate field rather than dots in `key`, because a `key` containing +/// a dot already means the top-level field spelled that way. +#[derive(Debug, Deserialize)] +pub struct PathFilter { + pub path: String, + pub value: Value, +} + +/// Boolean group of nested filters, externally tagged (`{"any_of": [...]}`) so it is +/// unambiguous against a leaf filter, which is `{"key": ..., "value": ...}`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilterGroup { + AnyOf(Vec), + AllOf(Vec), + NoneOf(Vec), +} + +#[derive(Debug, Deserialize)] #[serde(untagged)] pub enum Filter { JsonFilter(JsonFilter), + PathFilter(PathFilter), + Group(FilterGroup), } -struct SupersetVisitor<'a> { - key: &'a str, - value_to_check: &'a Value, +/// The scanned top-level key, and whatever is left to walk inside its value. +fn split_path(path: &str) -> (&str, &str) { + path.split_once('.').unwrap_or((path, "")) } -impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> { - type Value = bool; +/// Objects only: `Value::get` yields nothing for a string index into an array, so a path +/// through one simply does not match rather than guessing an element. +fn resolve<'v>(root: &'v Value, rest: &str) -> Option<&'v Value> { + let mut current = root; + if !rest.is_empty() { + for segment in rest.split('.') { + current = current.get(segment)?; + } + } + Some(current) +} + +/// Filters prepared for repeated evaluation against a stream of messages. The set of +/// top-level keys the whole tree references is computed once, so each message is scanned +/// in a single pass instead of once per leaf filter. +#[derive(Debug, Default)] +pub struct CompiledFilters { + filters: Vec, + use_or_logic: bool, + keys: Vec, +} + +impl CompiledFilters { + pub fn new(filters: Vec, use_or_logic: bool) -> Self { + let filters = drop_empty_groups(filters); + let mut keys = Vec::new(); + collect_keys(&filters, &mut keys); + Self { filters, use_or_logic, keys } + } + + /// Build from the raw JSON of each filter as stored in the trigger config. An entry + /// that fails to parse is skipped rather than dropping the other filters, but it + /// widens what the trigger accepts, so it is reported. + pub fn parse<'a>( + raw_filters: impl IntoIterator, + use_or_logic: bool, + trigger_path: &str, + ) -> Self { + let filters = raw_filters + .into_iter() + .filter_map(|raw| match serde_json::from_str::(raw) { + Ok(filter) => Some(filter), + Err(err) => { + tracing::error!( + "Ignoring unparseable filter of trigger {}: {} ({})", + trigger_path, + raw, + err + ); + None + } + }) + .collect(); + Self::new(filters, use_or_logic) + } + + pub fn is_empty(&self) -> bool { + self.filters.is_empty() + } + + /// Reject at save time what [`Self::parse`] would drop at listen time. A group nests + /// arbitrarily many criteria, so one mistyped entry silently widens the trigger by the + /// whole subtree it belongs to. + pub fn validate(filters: &[Value]) -> windmill_common::error::Result<()> { + for (index, filter) in filters.iter().enumerate() { + validate_filter(filter, &format!("filter #{}", index + 1))?; + } + Ok(()) + } + + /// Whether `text`, parsed as a JSON object, satisfies the filters. + pub fn matches(&self, text: &str) -> bool { + if self.filters.is_empty() { + return true; + } + + let mut deserializer = serde_json::Deserializer::from_str(text); + let values = + Deserializer::deserialize_map(&mut deserializer, KeysVisitor { keys: &self.keys }) + .unwrap_or_default(); + + eval_all(&self.filters, self.use_or_logic, &values) + } +} + +/// Groups are descended into by hand so a bad entry is named on its own: serde's untagged +/// error only reports that the outermost entry matched no variant, whatever depth is wrong. +fn validate_filter(filter: &Value, path: &str) -> windmill_common::error::Result<()> { + const GROUP_KEYS: [&str; 3] = ["any_of", "all_of", "none_of"]; + + let group = filter + .as_object() + .filter(|object| object.len() == 1) + .and_then(|object| { + GROUP_KEYS + .into_iter() + .find_map(|key| object.get(key).map(|nested| (key, nested))) + }); + + if let Some((key, nested)) = group { + let nested = nested.as_array().ok_or_else(|| { + windmill_common::error::Error::BadRequest(format!( + "{}: {} must be an array of filters", + path, key + )) + })?; + for (index, child) in nested.iter().enumerate() { + validate_filter(child, &format!("{} -> {}[{}]", path, key, index))?; + } + return Ok(()); + } + + // Everything below is meant to be a leaf. The untagged enum resolves a half-and-half + // entry by taking the first variant that fits and ignoring the rest of it, so a + // criterion carrying a group key would silently lose the whole subtree. + if let Some(group_key) = GROUP_KEYS.iter().find(|key| filter.get(*key).is_some()) { + return Err(windmill_common::error::Error::BadRequest(format!( + "{} combines a criterion with a {} group; an entry is one or the other", + path, group_key + ))); + } + + if filter.get("key").is_some() && filter.get("path").is_some() { + return Err(windmill_common::error::Error::BadRequest(format!( + "{} names its field with both key and path; use one or the other", + path + ))); + } + + let parsed = serde_json::from_value::(filter.clone()).map_err(|err| { + windmill_common::error::Error::BadRequest(format!( + "{} is neither a {{key, value}} / {{path, value}} criterion nor an any_of/all_of/none_of group: {}", + path, err + )) + })?; + + // An empty segment addresses no field, so the filter could only ever reject everything + if let Filter::PathFilter(PathFilter { path: dotted, .. }) = &parsed { + if dotted.split('.').any(|segment| segment.is_empty()) { + return Err(windmill_common::error::Error::BadRequest(format!( + "{}: path {:?} has an empty segment", + path, dotted + ))); + } + } + + Ok(()) +} + +/// A group with no criterion cannot evaluate to a constant: `true` makes an `or` list +/// accept every message, `false` mutes an `and` list. Dropping it instead leaves its +/// siblings in force, which is what a group left empty in the editor should mean. +fn drop_empty_groups(filters: Vec) -> Vec { + filters + .into_iter() + .filter_map(|filter| { + let (rebuild, nested): (fn(Vec) -> FilterGroup, _) = match filter { + Filter::Group(FilterGroup::AnyOf(nested)) => (FilterGroup::AnyOf, nested), + Filter::Group(FilterGroup::AllOf(nested)) => (FilterGroup::AllOf, nested), + Filter::Group(FilterGroup::NoneOf(nested)) => (FilterGroup::NoneOf, nested), + leaf => return Some(leaf), + }; + let nested = drop_empty_groups(nested); + (!nested.is_empty()).then(|| Filter::Group(rebuild(nested))) + }) + .collect() +} + +fn push_key(keys: &mut Vec, key: &str) { + if !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } +} + +fn collect_keys(filters: &[Filter], keys: &mut Vec) { + for filter in filters { + match filter { + Filter::JsonFilter(JsonFilter { key, .. }) => push_key(keys, key), + Filter::PathFilter(PathFilter { path, .. }) => push_key(keys, split_path(path).0), + Filter::Group( + FilterGroup::AnyOf(nested) + | FilterGroup::AllOf(nested) + | FilterGroup::NoneOf(nested), + ) => collect_keys(nested, keys), + } + } +} + +/// `filters` is never empty: the top level is short-circuited by [`CompiledFilters::matches`], +/// and [`drop_empty_groups`] removes empty groups. +fn eval_all(filters: &[Filter], use_or_logic: bool, values: &HashMap<&str, &RawValue>) -> bool { + let eval = |filter: &Filter| match filter { + // Parsed here rather than during the scan so that `any`/`all` short-circuiting keeps + // a large field the verdict never depends on from being materialized at all. + Filter::JsonFilter(JsonFilter { key, value }) => values + .get(key.as_str()) + .and_then(|raw| serde_json::from_str::(raw.get()).ok()) + .map_or(false, |found| is_superset(&found, value)), + Filter::PathFilter(PathFilter { path, value }) => { + let (root, rest) = split_path(path); + values + .get(root) + .and_then(|raw| serde_json::from_str::(raw.get()).ok()) + .and_then(|found| resolve(&found, rest).map(|at| is_superset(at, value))) + .unwrap_or(false) + } + Filter::Group(FilterGroup::AnyOf(nested)) => eval_all(nested, true, values), + Filter::Group(FilterGroup::AllOf(nested)) => eval_all(nested, false, values), + // A key the message does not carry satisfies a negation: nothing there can match. + Filter::Group(FilterGroup::NoneOf(nested)) => !eval_all(nested, true, values), + }; + + if use_or_logic { + filters.iter().any(eval) + } else { + filters.iter().all(eval) + } +} + +/// Locates the requested top-level keys in a single pass, skipping every other value. The +/// ones it wants are borrowed as raw slices of the message rather than deserialized, so a +/// key the boolean evaluation never reaches costs nothing beyond the scan. +struct KeysVisitor<'k> { + keys: &'k [String], +} + +impl<'de, 'k> Visitor<'de> for KeysVisitor<'k> { + type Value = HashMap<&'k str, &'de RawValue>; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a JSON object with a specific key at the top level") + formatter.write_str("a JSON object") } fn visit_map(self, mut map: V) -> std::result::Result where V: MapAccess<'de>, { - let mut result = false; - let mut found = false; + let mut found = HashMap::with_capacity(self.keys.len()); // Must consume entire map to satisfy deserializer contract while let Some(key) = map.next_key::()? { - if !found && key == self.key { - let json_value: Value = map.next_value()?; - result = is_superset(&json_value, self.value_to_check); - found = true; - } else { - // Skip values we don't need (cheaper than full deserialization) - let _ = map.next_value::()?; + match self.keys.iter().find(|k| k.as_str() == key) { + // On a duplicated key the first occurrence wins + Some(k) if !found.contains_key(k.as_str()) => { + found.insert(k.as_str(), map.next_value::<&'de RawValue>()?); + } + _ => { + // Skip values we don't need (cheaper than full deserialization) + let _ = map.next_value::()?; + } } } - Ok(result) + + Ok(found) } } @@ -69,96 +318,359 @@ pub fn is_superset(json_value: &Value, value_to_check: &Value) -> bool { } } -pub fn is_value_superset<'a, 'de, D>( - deserializer: D, - key: &'a str, - value_to_check: &'a Value, -) -> std::result::Result -where - D: Deserializer<'de>, -{ - deserializer.deserialize_map(SupersetVisitor { key, value_to_check }) -} - -pub fn check_filters(text: &str, filters: &[Filter], use_or_logic: bool) -> bool { - if filters.is_empty() { - return true; - } - - let check = |filter: &Filter| -> bool { - match filter { - Filter::JsonFilter(JsonFilter { key, value }) => { - let mut deserializer = serde_json::Deserializer::from_str(text); - is_value_superset(&mut deserializer, key, value).unwrap_or(false) - } - } - }; - - if use_or_logic { - filters.iter().any(check) - } else { - filters.iter().all(check) - } -} - #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn matches(payload: &str, filters: serde_json::Value, use_or_logic: bool) -> bool { + let filters: Vec = serde_json::from_value(filters).unwrap(); + CompiledFilters::new(filters, use_or_logic).matches(payload) + } + #[test] fn test_filter_with_other_top_level_keys() { let payload = r#"{"event_type": "test", "other": "data"}"#; - let key = "event_type"; - let value = json!("test"); - - let mut deserializer = serde_json::Deserializer::from_str(payload); - let result = is_value_superset(&mut deserializer, key, &value).unwrap(); - assert!(result, "Should match when key exists with correct value"); + let filters = json!([{"key": "event_type", "value": "test"}]); + assert!( + matches(payload, filters, false), + "Should match when key exists with correct value" + ); } #[test] fn test_filter_with_key_not_first() { let payload = r#"{"other": "data", "event_type": "test"}"#; - let key = "event_type"; - let value = json!("test"); - - let mut deserializer = serde_json::Deserializer::from_str(payload); - let result = is_value_superset(&mut deserializer, key, &value).unwrap(); - assert!(result, "Should match even when key is not first"); + let filters = json!([{"key": "event_type", "value": "test"}]); + assert!( + matches(payload, filters, false), + "Should match even when key is not first" + ); } #[test] fn test_filter_with_nested_object() { let payload = r#"{"data": {"status": "active", "count": 5}, "other": "value"}"#; - let key = "data"; - let value = json!({"status": "active"}); - - let mut deserializer = serde_json::Deserializer::from_str(payload); - let result = is_value_superset(&mut deserializer, key, &value).unwrap(); - assert!(result, "Should match when nested object is superset"); + let filters = json!([{"key": "data", "value": {"status": "active"}}]); + assert!( + matches(payload, filters, false), + "Should match when nested object is superset" + ); } #[test] fn test_filter_no_match() { let payload = r#"{"event_type": "other", "data": "value"}"#; - let key = "event_type"; - let value = json!("test"); - - let mut deserializer = serde_json::Deserializer::from_str(payload); - let result = is_value_superset(&mut deserializer, key, &value).unwrap(); - assert!(!result, "Should not match when value differs"); + let filters = json!([{"key": "event_type", "value": "test"}]); + assert!( + !matches(payload, filters, false), + "Should not match when value differs" + ); } #[test] fn test_filter_key_not_found() { let payload = r#"{"other": "data"}"#; - let key = "event_type"; - let value = json!("test"); + let filters = json!([{"key": "event_type", "value": "test"}]); + assert!( + !matches(payload, filters, false), + "Should not match when key doesn't exist" + ); + } - let mut deserializer = serde_json::Deserializer::from_str(payload); - let result = is_value_superset(&mut deserializer, key, &value).unwrap(); - assert!(!result, "Should not match when key doesn't exist"); + #[test] + fn test_no_filters_matches_everything() { + assert!(matches(r#"{"a": 1}"#, json!([]), false)); + assert!(matches("not even json", json!([]), true)); + } + + #[test] + fn test_non_object_payload_never_matches() { + let filters = json!([{"key": "a", "value": 1}]); + assert!(!matches("[1, 2]", filters.clone(), false)); + assert!(!matches("nope", filters, true)); + } + + #[test] + fn test_top_level_and_or_logic() { + let payload = r#"{"a": 1, "b": 2}"#; + let filters = json!([{"key": "a", "value": 1}, {"key": "b", "value": 99}]); + assert!(!matches(payload, filters.clone(), false)); + assert!(matches(payload, filters, true)); + } + + // --- nested groups --- + + #[test] + fn test_any_of_group_nested_in_and() { + let payload = + r#"{"event": "message_created", "previous_message": {"sent_by": "reminder"}}"#; + let filters = json!([ + {"key": "event", "value": "message_created"}, + {"any_of": [ + {"key": "in_reply_to", "value": {"sent_by": "reminder"}}, + {"key": "previous_message", "value": {"sent_by": "reminder"}} + ]} + ]); + assert!(matches(payload, filters, false)); + } + + #[test] + fn test_any_of_group_all_branches_fail() { + let payload = r#"{"event": "message_created", "previous_message": {"sent_by": "someone"}}"#; + let filters = json!([ + {"key": "event", "value": "message_created"}, + {"any_of": [ + {"key": "in_reply_to", "value": {"sent_by": "reminder"}}, + {"key": "previous_message", "value": {"sent_by": "reminder"}} + ]} + ]); + assert!(!matches(payload, filters, false)); + } + + #[test] + fn test_all_of_group_nested_in_or() { + let payload = r#"{"a": 1, "b": 2}"#; + let filters = json!([ + {"key": "missing", "value": true}, + {"all_of": [{"key": "a", "value": 1}, {"key": "b", "value": 2}]} + ]); + assert!(matches(payload, filters.clone(), true)); + assert!(!matches(payload, filters, false)); + } + + /// `1e400` overflows `Value`'s f64 and only fails to parse if something reads it, so a + /// match here means the short-circuit really did skip that field rather than + /// materializing every referenced key up front. + #[test] + fn test_unreached_branch_is_never_materialized() { + let payload = r#"{"gate": "match", "huge": 1e400}"#; + let filters = json!([{"key": "gate", "value": "match"}, {"key": "huge", "value": 1}]); + assert!(matches(payload, filters.clone(), true)); + assert!(!matches(payload, filters, false)); + } + + #[test] + fn test_deeply_nested_groups() { + let payload = r#"{"a": 1, "b": 2, "c": 3}"#; + let filters = json!([ + {"any_of": [ + {"key": "a", "value": 99}, + {"all_of": [ + {"key": "b", "value": 2}, + {"any_of": [{"key": "c", "value": 3}, {"key": "c", "value": 4}]} + ]} + ]} + ]); + assert!(matches(payload, filters, false)); + } + + #[test] + fn test_path_reaches_a_nested_field() { + let payload = r#"{"in_reply_to_message": {"content_attributes": {"sent_by": "reminder"}}}"#; + let path = "in_reply_to_message.content_attributes.sent_by"; + assert!(matches( + payload, + json!([{"path": path, "value": "reminder"}]), + false + )); + assert!(!matches( + payload, + json!([{"path": path, "value": "other"}]), + false + )); + // a missing intermediate segment is a miss, not an error + assert!(!matches( + payload, + json!([{"path": "in_reply_to_message.nope.sent_by", "value": "reminder"}]), + false + )); + } + + #[test] + fn test_path_and_key_keep_their_own_meaning() { + // `key` still addresses the top-level field spelled with dots, `path` traverses + assert!(matches( + r#"{"a.b": 1}"#, + json!([{"key": "a.b", "value": 1}]), + false + )); + assert!(!matches( + r#"{"a.b": 1}"#, + json!([{"path": "a.b", "value": 1}]), + false + )); + assert!(matches( + r#"{"a": {"b": 1}}"#, + json!([{"path": "a.b", "value": 1}]), + false + )); + assert!(!matches( + r#"{"a": {"b": 1}}"#, + json!([{"key": "a.b", "value": 1}]), + false + )); + } + + #[test] + fn test_path_does_not_traverse_arrays() { + // Deliberately unsupported for now: an element index is not implied + assert!(!matches( + r#"{"items": [{"id": 1}]}"#, + json!([{"path": "items.id", "value": 1}]), + false + )); + } + + #[test] + fn test_path_without_dots_is_a_top_level_field() { + assert!(matches( + r#"{"a": 1}"#, + json!([{"path": "a", "value": 1}]), + false + )); + } + + #[test] + fn test_none_of_excludes_matching_messages() { + let filters = json!([ + {"key": "event", "value": "message_created"}, + {"none_of": [{"key": "sender", "value": "bot"}, {"key": "kind", "value": "draft"}]} + ]); + assert!(matches( + r#"{"event": "message_created", "sender": "human"}"#, + filters.clone(), + false + )); + assert!(!matches( + r#"{"event": "message_created", "sender": "bot"}"#, + filters.clone(), + false + )); + // any one branch matching is enough to exclude + assert!(!matches( + r#"{"event": "message_created", "sender": "human", "kind": "draft"}"#, + filters, + false + )); + } + + #[test] + fn test_none_of_is_satisfied_by_a_missing_key() { + // Nothing is there to match, so the negation holds — the alternative would make + // every negative filter also require the field to be present. + assert!(matches( + r#"{"event": "message_created"}"#, + json!([{"none_of": [{"key": "sender", "value": "bot"}]}]), + false + )); + } + + #[test] + fn test_empty_group_is_dropped_not_constant() { + let payload = r#"{"a": 1}"#; + // On its own it leaves the trigger unfiltered, like an empty filter list + assert!(matches(payload, json!([{"any_of": []}]), false)); + assert!(matches( + payload, + json!([{"all_of": []}, {"any_of": [{"all_of": []}]}]), + true + )); + // Alongside a real criterion it must not decide the outcome either way + let with_failing_leaf = json!([{"any_of": []}, {"key": "a", "value": 99}]); + assert!(!matches(payload, with_failing_leaf.clone(), true)); + assert!(!matches(payload, with_failing_leaf, false)); + } + + #[test] + fn test_parses_legacy_and_group_entries_side_by_side() { + let filters = CompiledFilters::parse( + [ + r#"{"key": "event", "value": "created"}"#, + r#"{"any_of": [{"key": "a", "value": 1}, {"key": "b", "value": 2}]}"#, + ], + false, + "u/admin/trigger", + ); + assert!(filters.matches(r#"{"event": "created", "b": 2}"#)); + assert!(!filters.matches(r#"{"event": "created", "b": 3}"#)); + assert!(!filters.matches(r#"{"event": "other", "a": 1}"#)); + } + + #[test] + fn test_duplicated_payload_key_resolves_to_first_occurrence() { + let filters = json!([{"key": "a", "value": 1}]); + assert!(matches(r#"{"a": 1, "a": 2}"#, filters.clone(), false)); + assert!(!matches(r#"{"a": 2, "a": 1}"#, filters, false)); + } + + #[test] + fn test_validate_rejects_entries_the_listener_would_drop() { + assert!(CompiledFilters::validate(&[ + json!({"key": "a", "value": 1}), + json!({"all_of": []}) + ]) + .is_ok()); + assert!( + CompiledFilters::validate(&[json!({"anyOf": [{"key": "a", "value": 1}]})]).is_err() + ); + assert!(CompiledFilters::validate(&[json!({"key": "a"})]).is_err()); + } + + #[test] + fn test_validate_rejects_a_leaf_that_also_carries_a_group() { + // Untagged would settle each of these on one variant and drop the rest of the entry + for mixed in [ + json!({"key": "a", "value": 1, "none_of": [{"key": "b", "value": 2}]}), + json!({"path": "a.b", "value": 1, "any_of": [{"key": "b", "value": 2}]}), + json!({"any_of": [{"key": "a", "value": 1}], "all_of": [{"key": "b", "value": 2}]}), + ] { + let err = CompiledFilters::validate(&[mixed.clone()]) + .unwrap_err() + .to_string(); + assert!( + err.contains("combines a criterion with"), + "{} should be rejected, got: {}", + mixed, + err + ); + } + } + + #[test] + fn test_validate_rejects_a_leaf_naming_both_key_and_path() { + // Untagged would take it as a `key` criterion and drop the `path` without a word + let err = CompiledFilters::validate(&[json!({"key": "a", "path": "b.c", "value": 1})]) + .unwrap_err() + .to_string(); + assert!(err.contains("both key and path"), "got: {}", err); + } + + #[test] + fn test_validate_rejects_an_empty_path_segment() { + assert!(CompiledFilters::validate(&[json!({"path": "a.b", "value": 1})]).is_ok()); + for dead in ["", "a.", ".a", "a..b"] { + assert!( + CompiledFilters::validate(&[json!({"path": dead, "value": 1})]).is_err(), + "path {:?} addresses no field and should be rejected", + dead + ); + } + } + + #[test] + fn test_validate_names_the_offending_nested_entry() { + let err = CompiledFilters::validate(&[ + json!({"key": "a", "value": 1}), + json!({"all_of": [{"key": "b", "value": 2}, {"any_of": [{"key": "c"}]}]}), + ]) + .unwrap_err() + .to_string(); + assert!( + err.contains("filter #2 -> all_of[1] -> any_of[0]"), + "error should point at the entry that is wrong, got: {}", + err + ); } // --- is_superset unit tests --- diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 56cb9730cb..eb386b3ec8 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8287,18 +8287,62 @@ properties: filters: type: array items: - type: object - properties: - key: - type: string - value: {} + oneOf: + - type: object + properties: + key: + type: string + value: {} + required: + - key + - value + - type: object + properties: + path: + type: string + description: Dotted path into nested objects, e.g. \`a.b.c\`. Does not traverse + arrays. + value: {} + required: + - path + - value + - type: object + properties: + any_of: + type: array + items: + type: object + required: + - any_of + - type: object + properties: + all_of: + type: array + items: + type: object + required: + - all_of + - type: object + properties: + none_of: + type: array + items: + type: object + required: + - none_of + description: 'Filters to match incoming messages (only matching messages trigger + the script). Each entry is either a leaf \`{key, value}\` (top-level field) or + \`{path, value}\` (dotted path into nested objects), or a group \`{any_of: [...]}\` + / \`{all_of: [...]}\` / \`{none_of: [...]}\` nesting more entries. Entries at the + top level are combined with \`filter_logic\`.' filter_logic: type: string enum: - and - or - description: Logic to apply when evaluating filters. 'and' requires all filters - to match, 'or' requires any filter to match. + description: Logic to apply when evaluating the top-level filters. 'and' requires + all of them to match, 'or' requires any of them to match. Nested \`any_of\`/\`all_of\`/\`none_of\` + groups carry their own logic. auto_offset_reset: type: string enum: @@ -8399,6 +8443,18 @@ properties: type: array items: type: object + properties: + qos: + type: string + enum: + - qos0 + - qos1 + - qos2 + topic: + type: string + required: + - qos + - topic description: Array of MQTT topics to subscribe to, each with topic name and QoS level v3_config: @@ -8943,24 +8999,91 @@ properties: filters: type: array items: - type: object - properties: - key: - type: string - value: {} - description: Array of key-value filters to match incoming messages (only matching - messages trigger the script) + oneOf: + - type: object + properties: + key: + type: string + value: {} + required: + - key + - value + - type: object + properties: + path: + type: string + description: Dotted path into nested objects, e.g. \`a.b.c\`. Does not traverse + arrays. + value: {} + required: + - path + - value + - type: object + properties: + any_of: + type: array + items: + type: object + required: + - any_of + - type: object + properties: + all_of: + type: array + items: + type: object + required: + - all_of + - type: object + properties: + none_of: + type: array + items: + type: object + required: + - none_of + description: 'Filters to match incoming messages (only matching messages trigger + the script). Each entry is either a leaf \`{key, value}\` (top-level field) or + \`{path, value}\` (dotted path into nested objects), or a group \`{any_of: [...]}\` + / \`{all_of: [...]}\` / \`{none_of: [...]}\` nesting more entries. Entries at the + top level are combined with \`filter_logic\`.' filter_logic: type: string enum: - and - or - description: Logic to apply when evaluating filters. 'and' requires all filters - to match, 'or' requires any filter to match. + description: Logic to apply when evaluating the top-level filters. 'and' requires + all of them to match, 'or' requires any of them to match. Nested \`any_of\`/\`all_of\`/\`none_of\` + groups carry their own logic. initial_messages: type: array items: - type: object + oneOf: + - type: object + properties: + raw_message: + type: string + required: + - raw_message + - type: object + properties: + runnable_result: + type: object + properties: + path: + type: string + args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + is_flow: + type: boolean + required: + - path + - args + - is_flow + required: + - runnable_result description: Messages to send immediately after connecting (can be raw strings or computed by runnables) url_runnable_args: diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts index 0518fde90b..d22dceffa6 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts @@ -97,11 +97,20 @@ export const websocketTriggerRequestSchema = z.object({ "is_flow": z.boolean().describe("True if script_path points to a flow, false if it points to a script"), "url": z.string().describe("The WebSocket URL to connect to (can be a static URL or computed by a runnable)"), "mode": z.enum(["enabled", "disabled", "suspended"]).describe("job trigger mode").optional(), - "filters": z.array(z.object({ + "filters": z.array(z.union([z.object({ "key": z.string(), "value": z.any() - })).describe("Array of key-value filters to match incoming messages (only matching messages trigger the script)"), - "filter_logic": z.enum(["and", "or"]).describe("Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match.").default("and").optional(), + }), z.object({ + "path": z.string().describe("Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays."), + "value": z.any() + }), z.object({ + "any_of": z.array(z.record(z.string(), z.any())) + }), z.object({ + "all_of": z.array(z.record(z.string(), z.any())) + }), z.object({ + "none_of": z.array(z.record(z.string(), z.any())) + })]).describe("Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) \u2014 addressed by `key` for a top-level field or `path` for a dotted path into nested objects \u2014 or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\n")).describe("Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`."), + "filter_logic": z.enum(["and", "or"]).describe("Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.").default("and").optional(), "initial_messages": z.array(z.union([z.object({ "raw_message": z.string() }), z.object({ @@ -148,11 +157,20 @@ export const kafkaTriggerRequestSchema = z.object({ "kafka_resource_path": z.string().describe("Path to the Kafka resource containing connection configuration"), "group_id": z.string().describe("Kafka consumer group ID for this trigger"), "topics": z.array(z.string()).describe("Array of Kafka topic names to subscribe to"), - "filters": z.array(z.object({ + "filters": z.array(z.union([z.object({ "key": z.string(), "value": z.any() - })), - "filter_logic": z.enum(["and", "or"]).describe("Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match.").default("and").optional(), + }), z.object({ + "path": z.string().describe("Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays."), + "value": z.any() + }), z.object({ + "any_of": z.array(z.record(z.string(), z.any())) + }), z.object({ + "all_of": z.array(z.record(z.string(), z.any())) + }), z.object({ + "none_of": z.array(z.record(z.string(), z.any())) + })]).describe("Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) \u2014 addressed by `key` for a top-level field or `path` for a dotted path into nested objects \u2014 or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\n")).describe("Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`."), + "filter_logic": z.enum(["and", "or"]).describe("Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.").default("and").optional(), "auto_offset_reset": z.enum(["latest", "earliest"]).describe("Initial offset behavior when consumer group has no committed offset.").default("latest").optional(), "auto_commit": z.boolean().describe("When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint.").default(true).optional(), "mode": z.enum(["enabled", "disabled", "suspended"]).describe("job trigger mode").optional(), diff --git a/frontend/src/lib/components/triggers/TriggerFilterList.svelte b/frontend/src/lib/components/triggers/TriggerFilterList.svelte new file mode 100644 index 0000000000..d1d0522a65 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerFilterList.svelte @@ -0,0 +1,161 @@ + + +
+ {#if depth > 0 || filters.length > 0} +
+ + { + // Only move on once the field holds something: while the browser's + // credential dropdown is open, Enter belongs to the dropdown + if (e.key === 'Enter' && !e.isComposing && !e.repeat && e.currentTarget.value) { + e.preventDefault() + passwordField?.focus() + } + } + }} + />
-
{#if smtpConfigured} diff --git a/frontend/src/lib/components/Password.svelte b/frontend/src/lib/components/Password.svelte index 12c374bf70..72e7e43024 100644 --- a/frontend/src/lib/components/Password.svelte +++ b/frontend/src/lib/components/Password.svelte @@ -4,6 +4,7 @@ import Button from './common/button/Button.svelte' import TextInput from './text_input/TextInput.svelte' import { Eye, EyeClosed } from 'lucide-svelte' + import type { HTMLInputAttributes } from 'svelte/elements' const bubble = createBubbler() interface Props { @@ -14,6 +15,9 @@ small?: boolean minRows?: number id?: string + autocomplete?: HTMLInputAttributes['autocomplete'] + /** Off for login-style fields: keeps Enter free to submit. Overrides `minRows`. */ + allowMultiline?: boolean onKeyDown?: (event: KeyboardEvent) => void onBlur?: (event: FocusEvent) => void } @@ -26,6 +30,8 @@ small = false, minRows, id, + autocomplete = 'new-password', + allowMultiline = true, onKeyDown, onBlur }: Props = $props() @@ -34,10 +40,22 @@ let hideValue = $state(true) let forceMultiline = $state(false) let isMultiline = $derived( - forceMultiline || (minRows != null && minRows > 1) || (password?.includes('\n') ?? false) + allowMultiline && + (forceMultiline || (minRows != null && minRows > 1) || (password?.includes('\n') ?? false)) ) let textareaRef: TextInput<'textarea'> | undefined = $state() + let inputRef: TextInput<'input'> | undefined = $state() + + export function focus() { + ;(isMultiline ? textareaRef : inputRef)?.focus() + } + + // Revealing swaps the input to type="text". Auth forms conceal again before submitting, + // so the browser sees a password field when it decides whether to save the credential. + export function conceal() { + hideValue = true + } function insertAndSwitchToMultiline(input: HTMLInputElement, text: string) { const start = input.selectionStart @@ -54,16 +72,6 @@
-
-
{#if isMultiline} onBlur?.(e), onkeydown: (e) => { onKeyDown?.(e) @@ -89,6 +97,7 @@ /> {:else} onBlur?.(e), onkeydown: (e) => { - if (e.key === 'Enter') { + if (allowMultiline && e.key === 'Enter') { e.preventDefault() insertAndSwitchToMultiline(e.currentTarget as HTMLInputElement, '\n') return @@ -109,7 +118,7 @@ }, onpaste: (e) => { const text = e.clipboardData?.getData('text') - if (text?.includes('\n')) { + if (allowMultiline && text?.includes('\n')) { e.preventDefault() insertAndSwitchToMultiline(e.currentTarget as HTMLInputElement, text) } @@ -119,6 +128,18 @@ class="pr-8" /> {/if} + +
+
{#if red}
This field is required
diff --git a/frontend/src/routes/user/reset-password/+page.svelte b/frontend/src/routes/user/reset-password/+page.svelte index 3f0eb3b47a..51745d30ca 100644 --- a/frontend/src/routes/user/reset-password/+page.svelte +++ b/frontend/src/routes/user/reset-password/+page.svelte @@ -1,5 +1,6 @@ + + + + +
+
...
+
...
+
...
+
+ + +``` + +## Header + +Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates. + +## Candidate card + +The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony. + +Each candidate is one `
`: + +- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline"). +- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`). +- **Files** — monospaced list, `font-mono text-sm`. +- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below. +- **Problem** — one sentence. What hurts. +- **Solution** — one sentence. What changes. +- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers". + +No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram. + +## Diagram patterns + +Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point. + +### Mermaid graph (the workhorse for dependencies / call flow) + +Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1." + +```html +
+
+    flowchart LR
+      A[OrderHandler] --> B[OrderValidator]
+      B --> C[OrderRepo]
+      C -.leak.-> D[PricingClient]
+      classDef leak stroke:#dc2626,stroke-width:2px;
+      class C,D leak
+  
+
+``` + +### Hand-built boxes-and-arrows (when Mermaid's layout fights you) + +Modules as `
`s with borders and labels. Arrows as inline SVG `` or `` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight. + +### Cross-section (good for layered shallowness) + +Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility. + +### Mass diagram (good for "interface as wide as implementation") + +Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep). + +### Call-graph collapse + +Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it. + +## Style guidance + +- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate). +- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings. +- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling. +- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI. +- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering. + +## Top recommendation section + +One larger card. Candidate name, one sentence on why, anchor link to its card. That's it. + +## Tone + +Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift. + +**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality. + +**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module). + +**Phrasings that fit the style:** + +- "Order intake module is shallow — interface nearly matches the implementation." +- "Pricing leaks across the seam." +- "Deepen: one interface, one place to test." +- "Two adapters justify the seam: HTTP in prod, in-memory in tests." + +**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place. + +No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 0000000000..488c850990 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,68 @@ +--- +name: improve-codebase-architecture +description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick. +disable-model-invocation: true +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +This command is _informed_ by the project's domain model and built on a shared design vocabulary: + +- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary." +- The domain language in `CONTEXT.md` gives names to good seams. + +## Process + +### 1. Explore + +**Scope before you scan — YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look: + +- If the user named a direction — a module, a subsystem, a pain point — take it, and skip the inference below. +- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots — the files and areas that keep coming up — and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net. + +Read the project's domain glossary (`CONTEXT.md`) first. + +Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates as an HTML report + +Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `/architecture-review-.html` so each run gets a fresh file. Open it for the user — `xdg-open ` on Linux, `open ` on macOS, `start ` on Windows — and tell them the absolute path. + +The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual. + +For each candidate, render a card with: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and how tests would improve +- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening +- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge + +End the report with a **Top recommendation** section: which candidate you'd tackle first and why. + +**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +See `.agents/skills/improve-codebase-architecture/HTML-REPORT.md` (path from the repo root) for the full HTML scaffold, diagram patterns, and styling guidance. + +Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, run the `/grilling` skill to walk the decision tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern. diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index bd40b6c472..6a72949c96 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -62,7 +62,7 @@ If `git diff main...HEAD --name-only` matches `^frontend/`, the PR body **must** screenshots of the affected UI. Skip only when there is no visible UI effect (types, tests, build config) — and say so in the body. -1. Verify the change in the browser (AGENTS.md → "Verifying Frontend Changes"). +1. Verify the change in the browser (frontend/AGENTS.md → "Verifying Frontend Changes"). 2. Screenshot each affected page with `mcp__playwright__browser_take_screenshot` (save to a file). 3. Host each image and get its Markdown embed by pushing to the public `windmill-labs/agent-screenshots-internal` repo. **Pipe base64 through stdin** — @@ -130,6 +130,10 @@ and continue once they confirm it's done. ## Review rounds (draft → ready) A PR leaves draft **only after a clean CI review round**. Never run `gh pr ready` before that. +This is the rule in every mode, autonomous included. A clean round is necessary but not always +sufficient — see "Flip, or ask first" below. The one standing exception is an explicit request to +leave that PR in draft (usually so it can be tested first) — honour it for that PR, and don't +carry it over to the next one. 1. **Trigger a round and wait for it**: launch the waiter as a background Bash task (a round takes 10–30 min; you are woken when it exits — do not stop the session or poll in the foreground while it runs): @@ -162,6 +166,77 @@ A PR leaves draft **only after a clean CI review round**. Never run `gh pr ready If any P0/P1 finding is unaddressed or the head moved for reasons other than nit fixes, do **not** post the marker or flip — run another round instead. +### A round that never starts is usually a conflict + +The review workflows don't run on a PR that cannot merge, so a round that produces no verdict is +more often a conflict with `main` than a CI outage. Check before assuming anything is broken: + +```bash +gh pr view --json mergeable,mergeStateStatus +``` + +Resolve by **merging, not rebasing** — a rebase rewrites the head SHA that round verdicts and the +clean-round marker are keyed to, invalidating work you have already paid for: + +```bash +git fetch origin main +git merge origin/main +``` + +**If that merge changed `backend/ee-repo-ref.txt`, move the EE worktree to match.** The file pins +the EE commit CE builds against, so a merge that advances it leaves the EE checkout behind what CE +now expects, and `cargo check --features private` compiles a tree neither you nor CI intends: + +```bash +git -C merge "$(tr -d '[:space:]' < backend/ee-repo-ref.txt)" +``` + +Push both, then start a fresh round — the head moved, so the earlier verdicts no longer apply. + +### Flip, or ask first + +A clean round earns the flip; it does not always earn it *unattended*. Judge the blast radius from +the diff first — `git diff --name-only main...HEAD` answers most of these. + +**Ask before flipping** when the change: + +- touches `*_ee.rs` (it spans the EE repo through symlinks and has a companion PR) +- adds a migration under `backend/migrations/` +- changes `openapi.yaml`, `openflow.openapi.yaml`, or the generated client +- touches auth, permission, or token paths +- changes shared worker infrastructure — the job poller, `handle_child`, an executor +- trips `REVIEW.md`'s "Checklist for new public surfaces" + +**Flip without asking** when it is self-contained: a single-file fix, test-only, docs-only, one +call site, no new public surface. + +Unattended (webmux oneshot) there is nobody to ask, so the judgement holds and the action +degrades: flip the self-contained ones, and leave the rest at a clean draft with a line in the PR +description saying why — `left in draft: adds a migration, wants a human look before ready`. +Don't flip a wide-blast-radius change just because the round came back clean, and don't ask a +question nobody will read. + +`AGENTS.local.md` (gitignored, so it may not exist) carries a "PR ready calibration" section +recording how past ambiguous calls went. Read it before deciding; when a call is still genuinely +ambiguous, ask, then append the answer there so the next one is less ambiguous. + +### When rounds stop converging + +Three or more rounds without a clean verdict usually means the change's shape is wrong, not that +there is an endless supply of independent bugs. The tells: + +- findings keep landing in the same files round after round +- fixing one finding creates the next +- the findings are about coupling, duplication, or state threaded through many places, rather + than logic errors + +When that pattern holds, stop running rounds — each one costs a CI cycle and is not going to +converge. Say plainly that the remaining findings look structural rather than incidental, and +name the module or seam they cluster around. With a user present, suggest they run +`/improve-codebase-architecture` over that area: it is slash-only so you cannot invoke it +yourself, and reshaping the code is a scope change they should choose. Unattended, put the +diagnosis in the PR description and stop there rather than grinding out more rounds. + ## EE Companion PR (when `*_ee.rs` files were modified) The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. diff --git a/.agents/skills/refine/SKILL.md b/.agents/skills/refine/SKILL.md index aaf747cd29..51b29564c6 100644 --- a/.agents/skills/refine/SKILL.md +++ b/.agents/skills/refine/SKILL.md @@ -17,7 +17,6 @@ Reflect on the current session and update documentation with lessons learned. 2. **Read current docs**: Read the docs that were relevant to this session: - `docs/validation.md` - `docs/enterprise.md` - - `docs/autonomous-mode.md` - Any skills that were invoked 3. **Propose updates**: For each piece of friction, decide if it warrants a doc update: diff --git a/.agents/skills/rust-backend/SKILL.md b/.agents/skills/rust-backend/SKILL.md index f0c52002bc..2c6f077f0f 100644 --- a/.agents/skills/rust-backend/SKILL.md +++ b/.agents/skills/rust-backend/SKILL.md @@ -94,6 +94,13 @@ Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in as Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths. +## Feature Telemetry + +`FEATURE_USAGE_KINDS` in `windmill-api-workspaces/src/workspaces.rs` is an allowlist: a +`(feature, kind)` pair missing from it is dropped by `valid_feature_usage_event` with a bare +`continue` — no error, and the route still returns 204. Adding a counter on the frontend without +registering it here records nothing. See `docs/feature-telemetry.md`. + ## Axum Handlers Destructure extractors directly in function signatures: diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md index b0c4b39939..46bf970091 100644 --- a/.agents/skills/svelte-frontend/SKILL.md +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -7,9 +7,47 @@ description: Svelte coding guidelines for the Windmill frontend. MUST use when w Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server. +## Before writing any UI (MUST) + +Do both of these before the first line of markup — not after, and not only when something +looks unfamiliar. + +**1. Find the component that already exists.** `frontend/src/lib/components/common/index.ts` +is the design-system barrel — 28 lines, read it in full. It exports far more than the three +documented below: `Alert`, `Badge`, `Breadcrumb`, `Drawer`/`DrawerContent`, `Menu`/`MenuItem`, +`Tabs`/`Tab`/`TabContent`, `Skeleton`, `FileInput`, `RadioCard`, `Section`, `Kbd`, `ActionRow`, +`ClearableInput`, `CopyButton`, `SecondsInput`, `UndoRedo`, `Url`. + +The barrel is not the full picture either: `common/` has 34 subdirectories and only 23 exports, +so `modal/`, `popup/`, `stepper/`, `tooltip/`, `checkbox/`, `table/`, `contextmenu/`, +`confirmationModal/`, `calendarPicker/`, `fileUpload/`, `toggleButton-v2/` and more exist but +must be imported by path. Selects, text inputs and melt-based primitives sit next to `common/` +in `components/select/`, `components/text_input/`, `components/meltComponents/`. + +The tree holds 1,600+ components — grep `frontend/src/lib/components` for the thing you're about +to build; it almost certainly exists. Building a new one is the last resort, not the first move. + +**2. Read the guideline for what you're building.** `frontend/brand-guidelines.md` is the +authority on how it should look and read. Don't load all 34k chars — jump to the section: + +| Building | Section to read | +|---|---| +| Any new screen or component | `# Components` (Core Rules, Quick Reference) | +| Buttons, CTAs | `## Buttons` — hierarchy matters, only one Accent per view | +| Colors, surfaces, borders | `# Color system` (Quick Reference, Do's and Don'ts) | +| Text, labels, headings | `# Typography` — note `## Text Casing`, sentence case throughout | +| Spacing, grids, page structure | `# Spacing & Layout`; `# Layout` → `## Form` for forms | +| Shadows, overlays, depth | `# Elevation` | +| Icons | `# Iconography` | +| Wording of any UI copy | `# Voice & Communication`, `# Tone of Voice` | + +Get the line range with `grep -n '^#' frontend/brand-guidelines.md`, then read just that span. + ## Windmill UI Components (MUST use) -Always use Windmill's design-system components. Never use raw HTML elements. +Always use Windmill's design-system components. Never use raw HTML elements. The three below +are the ones you'll reach for most often — they are examples, not the catalog. For anything +else, go back to the barrel and grep. ### Buttons — `
diff --git a/frontend/src/lib/components/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index 37616aa83a..2764b130e2 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -11,6 +11,7 @@ - + clearPageDrawerAnchor(VARIABLES_PATH)}> {#snippet actions()} + {#if edit && curWs} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 1faa9c003b..806b963262 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -120,7 +120,8 @@ import { type ChatCommandItem, type SessionPromptContext, getSessionContextPromptSection, - type GlobalToolHelpers + type GlobalToolHelpers, + type GlobalActivePreviewContext } from './global/core' import { formatChatJobCompletion } from './datatableTools' import { isGlobalAiEnabled } from './global/gate' @@ -584,6 +585,10 @@ export class AIChatManager { // sessions modules — and re-read on every system-message rebuild; the send // path rebuilds after beforeSend, so a fork committed there is picked up. sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined + // The page the side panel shows, stamped on each user message. Same seam as above: + // a page tab is an iframe in its own realm, so the tab model is the only place the + // chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those. + activePreviewResolver: (() => GlobalActivePreviewContext | undefined) | undefined = undefined // Resolves the workspace this chat operates on. Session chats set it to their // own (possibly forked) workspace so the chat targets it WITHOUT switching the // global workspaceStore. Undefined for the global side-panel chat, which @@ -2329,7 +2334,10 @@ export class AIChatManager { return prepareGlobalUserMessage( pendingPrompt, this.contextManager.getSelectedContext(), - { workspace: this.operatingWorkspace } + { + workspace: this.operatingWorkspace, + activePreview: this.activePreviewResolver?.() + } ) } return undefined @@ -2936,6 +2944,7 @@ export class AIChatManager { case AIMode.GLOBAL: userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, { workspace: this.operatingWorkspace, + activePreview: this.activePreviewResolver?.(), images: sentImages, files: files }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index e0619b3832..18851e8766 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -5052,6 +5052,18 @@ describe('session-only preview tools gating', () => { } }) + // Only a session chat can ever receive an ACTIVE PREVIEW section, so the rule + // explaining it is dead weight (~100 prompt tokens per request) anywhere else. + it('carries the ACTIVE PREVIEW rule only in a chat that has a side panel', () => { + const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string + const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string + expect(off).not.toContain('ACTIVE PREVIEW') + expect(on).toContain('ACTIVE PREVIEW') + // The ACTIVE EDITOR rule is unconditional — live editors exist in both. + expect(off).toContain('ACTIVE EDITOR') + expect(on).toContain('ACTIVE EDITOR') + }) + it('mentions open_preview / get_app_runtime_logs / list_app_runs in the system prompt only when preview tools are enabled', () => { const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string @@ -5256,6 +5268,21 @@ describe('prepareGlobalUserMessage', () => { expect(message.content).not.toContain('content') }) + it('injects the previewed page and the row its drawer has open', () => { + const message = prepareGlobalUserMessage('Disable it', [], { + activePreview: { + label: 'Schedules', + location: '/schedules', + open: 'u/me/daily_report' + } + }) + + expect(message.content).toContain('## ACTIVE PREVIEW') + expect(message.content).toContain('page: Schedules') + expect(message.content).toContain('location: /schedules') + expect(message.content).toContain('open: u/me/daily_report') + }) + it('includes selected workspace item references without contents', () => { const message = prepareGlobalUserMessage('Update these items', [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e34e08de6e..23ca167395 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -254,9 +254,26 @@ export type GlobalActiveEditorContext = { isLiveDraft: true } +/** The page the session's side panel is showing, when it isn't one of the live + * editors ACTIVE EDITOR already covers. A page tab is an iframe in its own realm, + * so the chat can only learn about it from the tab model the session owns. */ +export type GlobalActivePreviewContext = { + /** Page name as the tab strip shows it, e.g. "Schedules". */ + label: string + /** Base-stripped page path plus the request params the page declares — values kept + * only for the ones addressing a workspace object, and percent-encoded. Never a raw + * location: a tab can host a legacy app whose hash is app state, and a filter value + * can be free text the user typed. Build it with `previewLocationContext`. */ + location: string + /** The row whose drawer is open on that page. The list pages drop the anchor when + * their drawer closes, so its absence means no row is open. */ + open?: string +} + export type GlobalUserMessageOptions = { workspace?: string activeEditor?: GlobalActiveEditorContext + activePreview?: GlobalActivePreviewContext /** Images attached to this message; delivered as image_url content parts. */ images?: AttachedImage[] /** Text files attached to this message; listed by reference below — the model @@ -1197,6 +1214,12 @@ const buildGlobalSystemPrompt = ( const pipelineAlphaNote = previewTools ? ' Data pipeline support in this chat is in ALPHA: the first time the user asks for a data pipeline in this session, briefly tell them it is an alpha feature before you start building.' : '' + // Gated on `previewTools` (constant per chat), never on whether a preview is open + // right now: the system prompt is the cached prefix, so a line appearing and + // disappearing between turns costs more cache than the tool call it saves. + const activePreviewRule = previewTools + ? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the row the page is anchored at, whose drawer the user opened) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.' + : '' const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on \` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.${pipelineAlphaNote}` return `You are Windmill's global workspace assistant. @@ -1214,7 +1237,7 @@ Path conventions: Rules: - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule} - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path. - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs. @@ -7341,6 +7364,16 @@ export function prepareGlobalUserMessage( content += `isLiveDraft: true\n\n` } + if (options.activePreview) { + content += '## ACTIVE PREVIEW\n' + content += `page: ${options.activePreview.label}\n` + content += `location: ${options.activePreview.location}\n` + if (options.activePreview.open) { + content += `open: ${options.activePreview.open}\n` + } + content += '\n' + } + if (selectedWorkspaceItems.length > 0) { content += '## SELECTED CONTEXT\n' for (const context of selectedWorkspaceItems) { diff --git a/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts b/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts index 7219c509b6..6556799833 100644 --- a/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts +++ b/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts @@ -1,9 +1,8 @@ import { buildFilterUrl } from '$lib/navigation' -import { buildRunsFilterSearchbarSchema } from '$lib/components/runs/runsFilter' -import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter' import { COMPARE_PAGE, TRIGGER_PAGES, + pageRequestParams, type TriggerKind } from '$lib/components/sessions/previewRouter' import { @@ -11,16 +10,17 @@ import { serializeItemsMaskParam } from '$lib/components/sessions/modifiedItemsMask' -// In-app paths for the deep-linkable preview pages the AI chat can open. -export const RUNS_PATH = '/runs' -export const SCHEDULES_PATH = '/schedules' -export const VARIABLES_PATH = '/variables' -export const RESOURCES_PATH = '/resources' -export const ASSETS_PATH = '/assets' -export const AUDIT_LOGS_PATH = '/audit_logs' -export const WORKSPACE_SETTINGS_PATH = '/workspace_settings' -export const FOLDERS_PATH = '/folders' -export const GROUPS_PATH = '/groups' +import { + RUNS_PATH, + SCHEDULES_PATH, + VARIABLES_PATH, + RESOURCES_PATH, + ASSETS_PATH, + AUDIT_LOGS_PATH, + WORKSPACE_SETTINGS_PATH, + FOLDERS_PATH, + GROUPS_PATH +} from '$lib/components/sessions/previewPaths' // Selectable tabs on the Workspace settings page (the `?tab=` query param). Mirrors the // union in routes/(root)/(logged)/workspace_settings/+page.svelte. @@ -49,27 +49,17 @@ export const WORKSPACE_SETTINGS_TABS = [ 'shared_ui' ] as const -// Valid query-param keys are derived from the real filter schemas (option arrays are -// irrelevant to the key set), so a renamed filter key propagates here for free. The -// permission flags are on so the key set is complete: gating `all_workspaces` is the -// caller's job, and the Runs page ignores it for anyone whose own schema lacks the key. -const RUNS_FILTER_KEYS = Object.keys( - buildRunsFilterSearchbarSchema({ - paths: [], - usernames: [], - folders: [], - jobTriggerKinds: [], - isSuperAdminOrDevops: true, - isAdminsWorkspace: true - }) -) -const SCHEDULES_FILTER_KEYS = Object.keys( - buildSchedulesFilterSchema({ paths: [], scriptPaths: [] }) -) +// Every builder below allows exactly the params `previewRouter` records as +// request-settable for that page, so the URLs this emits and the preview's reading of +// them stay one set. Wherever the page declares a filter schema that set is its full +// key list, so a renamed or added filter propagates here for free — including the keys +// only some viewers see: gating `all_workspaces` is the caller's job, and the Runs page +// ignores it for anyone whose own schema lacks it. What the chat may actually pass is +// narrower and lives in the open_page tool schema, not here. /** Deep-link to the Runs page with the given filters (keys must match `runsFilter`). */ export function buildRunsUrl(filters: Record): string { - return buildFilterUrl(RUNS_PATH, filters, { validKeys: RUNS_FILTER_KEYS }) + return buildFilterUrl(RUNS_PATH, filters, { validKeys: pageRequestParams(RUNS_PATH) }) } /** @@ -84,15 +74,11 @@ export function buildSchedulesUrl({ filters?: Record }): string { return buildFilterUrl(SCHEDULES_PATH, filters ?? {}, { - validKeys: SCHEDULES_FILTER_KEYS, + validKeys: pageRequestParams(SCHEDULES_PATH), hash: open }) } -// The remaining pages expose a curated subset of each page's real query params (not the -// full filter schema), so the allow-list is the exact set of keys the builder emits — -// these names match the query params the pages read (variablesFilter/resourcesFilter/ -// assetsFilter and audit_logs/+page.svelte). /** When `open` is set, the variable at that exact path is opened in the edit * drawer via the `#` hash the page already handles. */ export function buildVariablesUrl({ @@ -103,7 +89,7 @@ export function buildVariablesUrl({ filters?: Record }): string { return buildFilterUrl(VARIABLES_PATH, filters ?? {}, { - validKeys: ['path', 'owner'], + validKeys: pageRequestParams(VARIABLES_PATH), hash: open }) } @@ -118,24 +104,26 @@ export function buildResourcesUrl({ filters?: Record }): string { return buildFilterUrl(RESOURCES_PATH, filters ?? {}, { - validKeys: ['path', 'resource_type', 'owner'], + validKeys: pageRequestParams(RESOURCES_PATH), hash: open ? `/resource/${open}` : undefined }) } export function buildAssetsUrl(filters: Record): string { - return buildFilterUrl(ASSETS_PATH, filters, { validKeys: ['path'] }) + return buildFilterUrl(ASSETS_PATH, filters, { validKeys: pageRequestParams(ASSETS_PATH) }) } export function buildAuditLogsUrl(filters: Record): string { return buildFilterUrl(AUDIT_LOGS_PATH, filters, { - validKeys: ['username', 'operation', 'resource'] + validKeys: pageRequestParams(AUDIT_LOGS_PATH) }) } /** Deep-link to the Workspace settings page, optionally on a specific `?tab=`. */ export function buildWorkspaceSettingsUrl({ tab }: { tab?: string }): string { - return buildFilterUrl(WORKSPACE_SETTINGS_PATH, tab ? { tab } : {}) + return buildFilterUrl(WORKSPACE_SETTINGS_PATH, tab ? { tab } : {}, { + validKeys: pageRequestParams(WORKSPACE_SETTINGS_PATH) + }) } /** Folders and Groups list pages have no query filters — just open them. */ @@ -173,7 +161,7 @@ export function buildCompareUrl({ mode, [COMPARE_ITEMS_PARAM]: items ? serializeItemsMaskParam(items) : undefined }, - { validKeys: ['workspace_id', 'mode', COMPARE_ITEMS_PARAM] } + { validKeys: pageRequestParams(COMPARE_PAGE.path) } ) } diff --git a/frontend/src/lib/components/pendingEditorFlush.test.ts b/frontend/src/lib/components/pendingEditorFlush.test.ts new file mode 100644 index 0000000000..b55732734e --- /dev/null +++ b/frontend/src/lib/components/pendingEditorFlush.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { + anyEditorUnparseable, + setEditorUnparseable, + registerPendingEditor, + flushAllPendingEditorChanges +} from './pendingEditorFlush' + +describe('pendingEditorFlush', () => { + it('reports unparseable text until the editor clears it', () => { + const editor = {} + expect(anyEditorUnparseable()).toBe(false) + setEditorUnparseable(editor, true) + expect(anyEditorUnparseable()).toBe(true) + setEditorUnparseable(editor, false) + expect(anyEditorUnparseable()).toBe(false) + }) + + it('flushes registered editors, and stops once they unmount', () => { + let flushed = 0 + const deregister = registerPendingEditor({ flushPendingChanges: () => flushed++ }) + flushAllPendingEditorChanges() + deregister() + flushAllPendingEditorChanges() + expect(flushed).toBe(1) + }) +}) diff --git a/frontend/src/lib/components/pendingEditorFlush.ts b/frontend/src/lib/components/pendingEditorFlush.ts new file mode 100644 index 0000000000..20ddde67a5 --- /dev/null +++ b/frontend/src/lib/components/pendingEditorFlush.ts @@ -0,0 +1,36 @@ +// Every mounted `SimpleEditor`, so a caller can materialise what the user typed without +// knowing which editors a page contains — a drawer nests them through SchemaForm and +// ArgInput, so enumerating them from the container does not scale. Plain module rather +// than the editor component: importing that pulls Monaco's side-effect imports into every +// graph that reaches this, and the components around it defer Monaco deliberately. +const liveEditors = new Set<{ flushPendingChanges: () => void }>() + +/** Register a mounted editor; the returned function deregisters it. */ +export function registerPendingEditor(editor: { flushPendingChanges: () => void }): () => void { + liveEditors.add(editor) + return () => liveEditors.delete(editor) +} + +/** Drain every mounted editor's debounced buffer. For code that must act on what is on + * screen before leaving it — persisting a draft before routing to a session. */ +export function flushAllPendingEditorChanges(): void { + for (const editor of liveEditors) editor.flushPendingChanges() +} + +// Editors whose current text does not parse. Their value never reaches the bound field, so +// a caller persisting "what is on screen" would save the last value that did parse and +// leave without it. Registered by the editors that parse, not by the ones that only hold text. +const unparseable = new Set() + +/** Mark or clear this editor as holding text that does not parse. */ +export function setEditorUnparseable(key: object, invalid: boolean): void { + if (invalid) unparseable.add(key) + else unparseable.delete(key) +} + +/** Whether any editor on screen holds text that cannot be persisted as written. Registry- + * wide rather than per-item: the editors that parse are nested arbitrarily deep and none + * of them knows which draft it belongs to. */ +export function anyEditorUnparseable(): boolean { + return unparseable.size > 0 +} diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 72d319ec9e..22f1167455 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -4,14 +4,26 @@ // What an editor hands over for "Open in AI session": the session target it // maps to, the workspace it lives in, and a persist hook run before routing // so the session preview opens the item exactly as currently edited. - export type OpenInSessionSource = { - target: SessionTarget + type OpenInSessionCommon = { workspaceId?: string beforeOpen?: () => void | Promise /** Where inside the item the preview should open (a flow's `selected` * step). Steers the editor only — tab identity is (kind, path). */ previewParams?: Record } + + // A destination is either an editable item or a page, never both and never + // neither — the union is what makes that a compile error rather than a button + // that silently does nothing. + export type OpenInSessionSource = OpenInSessionCommon & + ( + | { target: SessionTarget; page?: never } + /** Base-prefixed href of a workspace page the preview opens as a tab (Runs, + * a trigger list). Resolved on click, not at render: a page whose filters + * live in shallow-routed query params never reflects them in `page.url`, so + * only `window.location` read at that moment matches what the user sees. */ + | { page: () => string | undefined; target?: never } + ) {#if !allowDraft} {@render extra?.()} + {#if edit} + {#if !trigger?.draftConfig}
import { untrack } from 'svelte' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import { Alert } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -159,6 +164,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.amqp.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -392,7 +398,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.amqp.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -131,6 +136,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.azure.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -206,11 +212,11 @@ const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any loadTriggerConfig(deployedTrigger) return { - noDeployed: !!(s as any)?.no_deployed, - overlay: draftFromBackend - ? ({ ...deployedTrigger, ...draftFromBackend } as Record) - : undefined - } + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } } catch (error) { sendUserToast(`Could not load Azure trigger: ${error.body}`, true) return { overlay: undefined, noDeployed: false } @@ -348,7 +354,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.azure.path)} + > import { Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -128,6 +133,7 @@ }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.email.path, ePath) initialPath = ePath path = ePath itemKind = isFlow ? 'flow' : 'script' @@ -461,6 +467,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(TRIGGER_PAGES.email.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -136,6 +141,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.gcp.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -255,13 +261,7 @@ if (!cfg) { return } - const isSaved = await saveGcpTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveGcpTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getGcpConfig()) onUpdate?.(cfg.path) @@ -368,7 +368,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.gcp.path)} + > import { Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -224,6 +229,7 @@ }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.http.path, ePath) initialPath = ePath path = ePath itemKind = isFlow ? 'flow' : 'script' @@ -362,8 +368,8 @@ return { noDeployed: !!(s as any)?.no_deployed, overlay: draftFromBackend - ? ({ ...deployedTrigger, ...draftFromBackend } as Record) - : undefined + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } @@ -985,6 +991,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(TRIGGER_PAGES.http.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -159,6 +164,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.kafka.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -412,7 +418,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.kafka.path)} + > import { untrack } from 'svelte' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import { Alert, Button } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -154,6 +159,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.mqtt.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -317,13 +323,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() - const isSaved = await saveMqttTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveMqttTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -392,7 +392,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.mqtt.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -142,6 +147,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.nats.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -296,13 +302,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = natsConfig - const isSaved = await saveNatsTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveNatsTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -389,7 +389,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.nats.path)} + > import { Alert, Button, TabContent } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -243,6 +248,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.postgres.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -567,7 +573,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.postgres.path)} + > import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -165,6 +170,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(SCHEDULES_PATH, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' path = defaultCfg?.path ?? ePath @@ -729,6 +735,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(SCHEDULES_PATH)}> import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -137,6 +142,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.sqs.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -306,13 +312,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() - const isSaved = await saveSqsTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveSqsTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -371,7 +371,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.sqs.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import TextInput from '$lib/components/text_input/TextInput.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -180,6 +185,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.websocket.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -453,7 +459,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.websocket.path)} + > Edit + {#if showEditButton} + + + {/if} {/if} {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} +
diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index d775c1d212..03b410d232 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -48,6 +48,7 @@ import { twMerge } from 'tailwind-merge' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' + import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils' import BatchReRunOptionsPane, { type BatchReRunOptions @@ -934,35 +935,16 @@
{#if batchProgress} -
- Loading jobs: {batchProgress.loaded} of {batchProgress.total}... -
-
-
- {#if currentBatchSize != null} - Batch size: - { - const v = parseInt(e.currentTarget.value) - if (v >= 1 && v <= 1000) { - jobsLoader.restreamWithBatchSize(v) - } - }} - /> - {/if} - +
+ jobsLoader.restreamWithBatchSize(v)} + onStop={() => jobsLoader.stopBatchLoading()} + />
{/if} diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index c367bce01c..6bf71c65fd 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -21,24 +21,21 @@ import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte' import { type AuditLog, - AuditService, ResourceService, UserService, ScriptService, FlowService, - AppService, - CancelError + AppService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, onMount, untrack } from 'svelte' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' import Select from '../select/Select.svelte' import { usePromise } from '$lib/svelte5Utils.svelte' import { safeSelectItems } from '../select/utils.svelte' - import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils' import { sendUserToast } from '$lib/toast' let usernames: string[] | undefined = $state() @@ -48,7 +45,6 @@ logs?: AuditLog[] username?: string pageIndex?: number | undefined - hasMore?: boolean before?: string | undefined after?: string | undefined perPage?: number | undefined @@ -57,13 +53,13 @@ actionKind?: ActionKind | 'all' scope?: undefined | 'all_workspaces' | 'instance' loading?: boolean + onRefresh?: () => void } let { - logs = $bindable(undefined), + logs = undefined, username = $bindable('all'), pageIndex = $bindable(1), - hasMore = $bindable(false), before = $bindable(undefined), after = $bindable(undefined), perPage = $bindable(100), @@ -71,13 +67,11 @@ resource = $bindable() as string | undefined, actionKind = $bindable(undefined), scope = $bindable(undefined), - loading = $bindable(false) + loading = false, + onRefresh }: Props = $props() $effect.pre(() => { - if (logs == undefined) { - logs = [] - } if (operation == undefined) { operation = 'all' } @@ -89,47 +83,6 @@ } }) - function loadLogs() { - loading = true - - let username_ = username == 'all' ? undefined : username - let operation_ = operation == 'all' || operation == '' ? undefined : operation - let actionKind_ = actionKind == 'all' ? undefined : actionKind - let resource_ = resource == 'all' || resource == '' ? undefined : resource - - let _promise = AuditService.listAuditLogs({ - workspace: scope === 'instance' ? 'global' : $workspaceStore!, - page: pageIndex, - perPage, - before, - after, - username: username_, - operation: operation_, - resource: resource_, - actionKind: actionKind_, - allWorkspaces: scope === 'all_workspaces' - }) - let promise = CancelablePromiseUtils.map(_promise, (value) => { - logs = value - hasMore = !logs || (logs.length > 0 && logs.length === perPage) - loading = false - }) - promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => { - sendUserToast( - 'Loading audit logs is taking longer than expected...', - 'warning', - perPage > 25 - ? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }] - : [] - ) - }) - promise = CancelablePromiseUtils.catchErr(promise, (e) => { - if (e instanceof CancelError) return CancelablePromiseUtils.pure(undefined) - return CancelablePromiseUtils.err(e) - }) - return promise - } - async function loadUsers() { usernames = $userStore?.is_admin || $userStore?.is_super_admin @@ -277,9 +230,6 @@ WORKSPACES_DELETE: 'workspaces.delete' } - let refresh = $state(0) - let lastRefresh = $state(-1) - function downloadAuditLogsAsJson() { if (!logs || logs.length === 0) { sendUserToast('No audit logs to download', true) @@ -302,19 +252,15 @@ URL.revokeObjectURL(url) } - // observe all the variables that should trigger an update + onMount(() => { + loadUsers() + resources.refresh() + }) + + // observe all the variables that should be reflected in the url $effect(() => { - ;[refresh, username, perPage, before, after, operation, resource, actionKind, scope, pageIndex] - return untrack(() => { - if (refresh !== lastRefresh) { - loadUsers() - resources.refresh() - lastRefresh = refresh - } - updateQueryParams() - let promise = loadLogs() - return () => promise?.cancel() - }) + ;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex] + untrack(() => updateQueryParams()) }) @@ -476,7 +422,9 @@
+ {#if batchProgress} +
+ onBatchSizeChange?.(size)} + onStop={() => onStopLoading?.()} + /> +
+ {/if}
Per page: - - +
{#if status === 'idle'} - + {#if noOAuth} +
{server.name} did not advertise OAuth support.
+ {/if} + {:else if status === 'discovering'} -
Discovering OAuth settings...
+
Checking what {server.name} supports...
{:else if status === 'discovered' && discoveryResult} -
- ✓ OAuth supported - {#if discoveryResult.supports_dynamic_registration} - (Dynamic Client Registration available) - {/if} -
- {#if discoveryResult.scopes_supported && discoveryResult.scopes_supported.length > 0} -
+ {#if resources} + {@const nTruncated = resources.filter((r) => r.truncated).length} + {#if nTruncated > 0} + +
+ {nTruncated} of those resources {nTruncated === 1 ? 'is' : 'are'} too large to search in full + — only {nTruncated === 1 ? 'its' : 'their'} beginning is matched. +
+ {/if} + {/if}
@@ -269,6 +285,15 @@ on:close > {#snippet actions()} + {#if item.truncated} + + Truncated + {#snippet text()} + This resource is too large to search in full: only its beginning is matched + and shown. + {/snippet} + + {/if} From 64d78b4db1d7d939c598c86d1998218d52fbcc21 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 10:45:25 +0200 Subject: [PATCH 108/192] fix: fall back to polling when a proxy mutes the job SSE stream (#10716) * fix: fall back to polling when a proxy mutes the job SSE stream * fix: do not charge deliberate no-logs sse restarts to the retry budget --- frontend/src/lib/components/JobLoader.svelte | 48 +++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index ec9e4b693c..71bd2fbb51 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -87,6 +87,7 @@ let finished: string[] = [] let ITERATIONS_BEFORE_SLOW_REFRESH = 10 let ITERATIONS_BEFORE_SUPER_SLOW_REFRESH = 100 + const MAX_SSE_ATTEMPTS = 3 let lastStartedAt: number = Date.now() let currentId: string | undefined = $state(undefined) @@ -179,7 +180,7 @@ lastCompletedJobId = undefined clearCurrentJob() lastCallbacks = callbacks - noPingTimeout = undefined + clearNoPingTimeout() const startedAt = Date.now() const testId = await fn() @@ -669,16 +670,30 @@ } } - function setNoPingTimeout(id: string, attempt: number, callbacks?: Callbacks) { + function clearNoPingTimeout() { if (noPingTimeout) { clearTimeout(noPingTimeout) + noPingTimeout = undefined } + } + + function setNoPingTimeout(id: string, attempt: number, callbacks?: Callbacks) { + clearNoPingTimeout() if (isCurrentJob(id)) { noPingTimeout = setTimeout(() => { + noPingTimeout = undefined if (isCurrentJob(id)) { currentEventSource?.close() currentEventSource = undefined - loadTestJobWithSSE(id, attempt + 1, callbacks) + // A proxy that buffers the response rather than cutting it keeps the + // connection open and error-free, so this watchdog is the only signal that + // no event is getting through. It has to share the retry budget: otherwise + // it reopens an equally mute stream forever and polling is never reached. + if (attempt < MAX_SSE_ATTEMPTS) { + loadTestJobWithSSE(id, attempt + 1, callbacks) + } else { + syncer(id, callbacks) + } } }, 10000) } @@ -841,10 +856,7 @@ if (previewJobUpdates.completed) { currentEventSource?.close() currentEventSource = undefined - if (noPingTimeout) { - clearTimeout(noPingTimeout) - noPingTimeout = undefined - } + clearNoPingTimeout() isCompleted = true if (onlyResult) { callbacks?.doneResult?.({ @@ -869,16 +881,26 @@ console.warn('SSE error:', error) currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() let delay = 1000 let isNoLogsChange = error.type == noLogsChangeRestartEvent if (isNoLogsChange) { delay = 0 } - if (attempt < 3 || isNoLogsChange) { + if (attempt < MAX_SSE_ATTEMPTS || isNoLogsChange) { if (!isNoLogsChange) { - console.log(`SSE error (1), retrying ... attempt: ${attempt + 1}/3`) + console.log( + `SSE error (1), retrying ... attempt: ${attempt + 1}/${MAX_SSE_ATTEMPTS}` + ) } - setTimeout(() => loadTestJobWithSSE(id, attempt + 1, callbacks), delay) + // A no-logs restart is deliberate (the caller wants a stream with different + // query args), not a failure, so it must not consume the retry budget: + // toggling the flow graph tab would otherwise exhaust it in a few clicks + // and strand a healthy stream on polling. + setTimeout( + () => loadTestJobWithSSE(id, isNoLogsChange ? attempt : attempt + 1, callbacks), + delay + ) } else { // Fall back to polling on error setTimeout(() => syncer(id, callbacks), 1000) @@ -901,9 +923,10 @@ // Fall back to polling on error currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() - if (attempt < 3) { - console.log(`SSE error (2), retrying ... attempt: ${attempt}/3`) + if (attempt < MAX_SSE_ATTEMPTS) { + console.log(`SSE error (2), retrying ... attempt: ${attempt}/${MAX_SSE_ATTEMPTS}`) attempt++ loadTestJobWithSSE(id, attempt, callbacks) } else { @@ -942,6 +965,7 @@ clearCurrentId() currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() replayTimeouts.forEach(clearTimeout) replayTimeouts = [] }) From 010d67e07f2f036e808507e394a9a9fd2ee720ce Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 11:08:47 +0200 Subject: [PATCH 109/192] chore(main): release 1.790.1 (#10712) * chore(main): release 1.790.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 ++ backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 20 files changed, 136 insertions(+), 123 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c1824f4c07..deefa1024a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.790.0" + ".": "1.790.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 40089082af..fae181899c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.790.1](https://github.com/windmill-labs/windmill/compare/v1.790.0...v1.790.1) (2026-08-17) + + +### Bug Fixes + +* fall back to polling when a proxy mutes the job SSE stream ([#10716](https://github.com/windmill-labs/windmill/issues/10716)) ([64d78b4](https://github.com/windmill-labs/windmill/commit/64d78b4db1d7d939c598c86d1998218d52fbcc21)) + + +### Performance Improvements + +* cap resource content sent to the search modal ([#10714](https://github.com/windmill-labs/windmill/issues/10714)) ([529e960](https://github.com/windmill-labs/windmill/commit/529e9606297ee0b41456a66222f31409d7bc7669)) +* unblock workers before the API router is built ([#10711](https://github.com/windmill-labs/windmill/issues/10711)) ([0258f3f](https://github.com/windmill-labs/windmill/commit/0258f3f81b96bb8d4e343ba8aeba614f9c836579)) + ## [1.790.0](https://github.com/windmill-labs/windmill/compare/v1.789.0...v1.790.0) (2026-08-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index eec483d9e1..dd9885761d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14665,7 +14665,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -14750,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.790.0" +version = "1.790.1" dependencies = [ "async-stream", "async-trait", @@ -14783,7 +14783,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14796,7 +14796,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "argon2", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14976,7 +14976,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15002,7 +15002,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.790.0" +version = "1.790.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15029,7 +15029,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15051,7 +15051,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15207,7 +15207,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15235,7 +15235,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15277,7 +15277,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15315,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.790.0" +version = "1.790.1" dependencies = [ "lazy_static", "serde", @@ -15355,7 +15355,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.790.0" +version = "1.790.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15393,7 +15393,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.790.0" +version = "1.790.1" dependencies = [ "chrono", "lazy_static", @@ -15442,7 +15442,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15461,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.790.0" +version = "1.790.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15565,7 +15565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.790.0" +version = "1.790.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -15584,7 +15584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.790.0" +version = "1.790.1" dependencies = [ "regex", "serde", @@ -15599,7 +15599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15623,7 +15623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "futures", @@ -15640,7 +15640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.790.0" +version = "1.790.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15656,7 +15656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -15677,7 +15677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -15708,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "arc-swap", @@ -15733,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-stream", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "futures", @@ -15785,7 +15785,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.790.0" +version = "1.790.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15806,7 +15806,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15818,7 +15818,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "gosyn", @@ -15830,7 +15830,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15842,7 +15842,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "nu-parser", @@ -15865,7 +15865,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15876,7 +15876,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15888,7 +15888,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15899,7 +15899,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15933,7 +15933,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15947,7 +15947,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15964,7 +15964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15977,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde", @@ -15989,7 +15989,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -16007,7 +16007,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16023,7 +16023,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16039,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -16053,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -16092,7 +16092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "const_format", @@ -16132,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.790.0" +version = "1.790.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16143,7 +16143,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -16178,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16202,7 +16202,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16235,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16262,7 +16262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16295,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16315,7 +16315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16349,7 +16349,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16408,7 +16408,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16432,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -16456,7 +16456,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16519,7 +16519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16544,7 +16544,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16563,7 +16563,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-once-cell", @@ -16679,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.790.0" +version = "1.790.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f19861b16e..9905d35af5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.790.0" +version = "1.790.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.790.0" +version = "1.790.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 9866121d20..5eb3df3fcc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.790.0" +version = "1.790.1" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.790.0" +version = "1.790.1" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.790.0" +version = "1.790.1" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 10cace3b33..2e76d34920 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.790.0" +version = "1.790.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ec6f722cdf..e7cad13be4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.790.0 + version: 1.790.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 0bd4c7b7cf..8319d03899 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.790.0"; +export const VERSION = "v1.790.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 1c69b06149..784c6f95a7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.790.0"; +export const VERSION = "1.790.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 89ac3d10a6..6eecdc3d5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 924ad3b285..0a8873c35a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 61a941b2d4..e9b02f2495 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.790.0" +wmill = ">=1.790.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index f34ccf89c4..3608240c2c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.790.0 + version: 1.790.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 12bedba8ab..60e8ea7b54 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.790.0' + ModuleVersion = '1.790.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index c8049b1786..998c454d0e 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.790.0" +version = "1.790.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 9374384495..7e0927e66d 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.790.0", + "version": "1.790.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index ac6ce61c9f..1f40861c21 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.790.0", + "version": "1.790.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 9d16d96806..4bd8559201 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.790.0 +1.790.1 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 110f8fe51d..2f1abcdff9 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 4b8f77485c..123dbf2980 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From ab3c0206d7e9b32676d99ed0cd8c9d8939122584 Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:30:25 +0200 Subject: [PATCH 110/192] fix: support @typechecked decorator in Python relative imports (#8495) WindmillFinder's ModuleSpec lacked origin, so __file__ was never set on loaded modules. inspect.getfile() then raised "is a built-in module", breaking typeguard's @typechecked and anything else that introspects module source. Use spec_from_file_location() which sets origin correctly. Co-authored-by: Claude Opus 4.6 Co-authored-by: hugocasa --- backend/tests/fixtures/typechecked_python.sql | 20 +++++++++ backend/tests/python_jobs.rs | 44 +++++++++++++++++++ backend/windmill-worker/loader.py | 5 ++- 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 backend/tests/fixtures/typechecked_python.sql diff --git a/backend/tests/fixtures/typechecked_python.sql b/backend/tests/fixtures/typechecked_python.sql new file mode 100644 index 0000000000..e6350c22f3 --- /dev/null +++ b/backend/tests/fixtures/typechecked_python.sql @@ -0,0 +1,20 @@ +INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( +'test-workspace', +'test-user', +' +import inspect +import sys + +def greet(name: str) -> str: + # Verify that __file__ is set on this module (same check typeguard does) + mod = sys.modules[__name__] + source_file = inspect.getfile(mod) + return f"Hello, {name}! from {source_file}" + +def main(): + return greet("World") +', +'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', +'', +'', +'f/system/typechecked_helper', 12349, 'python3', ''); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index eae163d675..b7aa671b21 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1241,6 +1241,50 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res Ok(()) } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base", "typechecked_python"))] +async fn test_typechecked_decorator_python(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +from f.system.typechecked_helper import greet + +def main(): + return greet("World") +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: Some("f/system/test_typechecked".to_string()), + language: ScriptLang::Python3, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + let result_str = result.as_str().unwrap(); + assert!( + result_str.starts_with("Hello, World! from "), + "unexpected result: {result_str}" + ); + Ok(()) +} + /// End-to-end comparison between the legacy `step()` suspend-and-replay path /// and the new SDK inline-persist fast path, toggled per-job via the /// `WM_WAC_INLINE_FAST_PATH` env var which the Python script sets on its own diff --git a/backend/windmill-worker/loader.py b/backend/windmill-worker/loader.py index d3dc7b8a66..ab10c36b86 100644 --- a/backend/windmill-worker/loader.py +++ b/backend/windmill-worker/loader.py @@ -2,6 +2,7 @@ import sys import os from importlib.abc import MetaPathFinder, Loader from importlib.machinery import ModuleSpec, SourceFileLoader +from importlib.util import spec_from_file_location import time # Injected by backend: maps script path -> temp storage hash so preview jobs @@ -38,7 +39,7 @@ class WindmillFinder(MetaPathFinder): fullpath = folder + "/" + splitted[-1] + ".py" if os.path.exists(fullpath): - return ModuleSpec(name, SourceFileLoader(name, fullpath)) + return spec_from_file_location(name, fullpath) import urllib.parse @@ -70,7 +71,7 @@ class WindmillFinder(MetaPathFinder): return ModuleSpec(name, WindmillLoader(name)) with open(fullpath, "w+") as f: f.write(r) - return ModuleSpec(name, SourceFileLoader(name, fullpath)) + return spec_from_file_location(name, fullpath) except urllib.error.HTTPError as e: duration = time.time() - req_start if e.code != 404: From 66e3790da433eda955699caf20d988cb20d7ee7f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 18:27:37 +0200 Subject: [PATCH 111/192] docs: announce we are not seeking outside contribution (#10724) * docs: announce we are not seeking outside contribution Co-Authored-By: Claude Opus 5 (1M context) * docs: point big ideas at the feature request template Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 14 +++++++++++++ .github/workflows/sign-cla.yml | 8 +++++++- CONTRIBUTING.md | 34 ++++++++++++++++++++++++++++++++ README.md | 9 ++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..0e022ec801 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ + + +## What does this PR do? + +## Related issue diff --git a/.github/workflows/sign-cla.yml b/.github/workflows/sign-cla.yml index 67822542a9..52329b6119 100644 --- a/.github/workflows/sign-cla.yml +++ b/.github/workflows/sign-cla.yml @@ -21,9 +21,15 @@ jobs: PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} with: path-to-signatures: "signatures/cla.json" - path-to-document: "https://github.com/windmill-labs/windmill/blob/master/CLA.md" + path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md" branch: "signatures" allowlist: rubenfiszel,bot* + custom-notsigned-prcomment: | + Thank you for taking the time to open this PR. + + Please note that **we are not seeking outside contribution at this time**. Small, trivially-verified PRs that fix a problem are still accepted, but low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines will be closed. If you have a bigger idea, please open a [feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) instead. See [CONTRIBUTING.md](https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md) for the full policy. + + If your PR falls within that scope, we ask that you sign our [Contributor License Agreement](https://github.com/windmill-labs/windmill/blob/main/CLA.md) before we can accept it. You can sign the CLA by just posting a Pull Request Comment same as the below format. #below are the optional inputs - If the optional inputs are not given, then default values will be taken #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..e3bc91fd77 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to Windmill + +At this time, we are not seeking outside contribution. + +AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it, +making sure quality stays high, and keeping the product coherent. In that light, unfortunately, +external code contributions are "donating" the easy part of the job, while creating more of the +hard work. + +With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However, +we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a +dozen or so lines. Such PRs will be closed with a reference to this guideline. + +If you have a big idea you'd like us to consider, feel free to open a +[feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) +about it. + +This policy may change in the future as the project matures. Until then, thank you for your +understanding. + +## What is still very welcome + +- [Bug reports](https://github.com/windmill-labs/windmill/issues/new?template=bug_report.yml), with + clear reproduction steps. +- [Feature requests](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md), + including for ideas too big to be a PR. +- Questions and feedback on [Discord](https://discord.gg/V7PM2YHsPB). +- Contributions to the [Windmill Hub](https://hub.windmill.dev), where scripts, flows and apps are + shared with the community. + +## If you do open a PR + +Small, self-contained fixes are still accepted. They require signing the +[CLA](./CLA.md), which the CLA bot will prompt for on your first PR. diff --git a/README.md b/README.md index d075becce1..71d4d39a19 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together

- Try it - Website - Docs - Discord - Hub - Contributor's guide + Try it - Website - Docs - Discord - Hub - Contributing

# Windmill - Developer platform for APIs, background jobs, workflows and UIs @@ -62,6 +62,7 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4 - [Run a local dev setup](#run-a-local-dev-setup) - [Frontend only](#frontend-only) - [Backend + Frontend](#backend--frontend) + - [Contributing](#contributing) - [Contributors](#contributors) - [Copyright](#copyright) @@ -329,6 +330,12 @@ running options. 2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor. 7. Windmill should be available at `http://localhost:3000` +## Contributing + +At this time, we are not seeking outside contribution. Bug reports and feature requests remain very +welcome, and small, trivially-verified PRs that fix a problem are still accepted. See +[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy. + ## Contributors From 05eba6c9ab078cdedc87f197549dbdbc4b360fe3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 21:11:19 +0200 Subject: [PATCH 112/192] fix: include delete_after_secs in script deploy payload (#10731) --- frontend/src/lib/components/ScriptBuilder.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 9ce8c17022..5c6cb3e5a8 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -700,6 +700,7 @@ ws_error_handler_muted: script.ws_error_handler_muted, priority: script.priority, restart_unless_cancelled: script.restart_unless_cancelled, + delete_after_secs: script.delete_after_secs, timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, From 66bffaa60d48f992e56e459efb813b24e3942610 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 17 Aug 2026 21:14:11 +0200 Subject: [PATCH 113/192] feat: add empty state cards to list pages (#10726) * feat: add empty state cards to list pages Co-Authored-By: Claude Opus 5 (1M context) * fix: animate trigger drawers on first open Co-Authored-By: Claude Opus 5 (1M context) * fix: distinguish filtered-empty schedules, reuse the rAF helper Co-Authored-By: Claude Opus 5 (1M context) * fix: hide the header create button while the empty state offers it Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: hide the header create button while the empty state offers it" This reverts commit 98c57eede34710c7d8f4342831ff650dbc0665f1. Co-Authored-By: Claude Opus 5 (1M context) * fix: use the default variant for the empty state button Co-Authored-By: Claude Opus 5 (1M context) * refactor: share hasActiveFilters from the filter searchbar module Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../src/lib/components/FilterSearchbar.svelte | 11 + .../common/emptyState/EmptyState.svelte | 49 ++ frontend/src/lib/components/common/index.ts | 2 + .../lib/components/common/tabs/TabFade.svelte | 26 + .../chat/CreatedResourceActionDrawers.svelte | 15 +- .../triggers/amqp/AmqpTriggerEditor.svelte | 6 +- .../triggers/azure/AzureTriggerEditor.svelte | 6 +- .../triggers/email/EmailTriggerEditor.svelte | 6 +- .../triggers/gcp/GcpTriggerEditor.svelte | 6 +- .../triggers/http/RouteEditor.svelte | 6 +- .../triggers/kafka/KafkaTriggerEditor.svelte | 6 +- .../triggers/mqtt/MqttTriggerEditor.svelte | 6 +- .../triggers/nats/NatsTriggerEditor.svelte | 6 +- .../postgres/PostgresTriggerEditor.svelte | 6 +- .../triggers/schedules/ScheduleEditor.svelte | 6 +- .../triggers/sqs/SqsTriggerEditor.svelte | 6 +- .../triggers/webhook/WebhookEditor.svelte | 5 +- .../websocket/WebsocketTriggerEditor.svelte | 6 +- frontend/src/lib/utils/paint.ts | 32 + .../(logged)/amqp_triggers/+page.svelte | 15 +- .../(logged)/azure_triggers/+page.svelte | 15 +- .../(logged)/email_triggers/+page.svelte | 17 +- .../(root)/(logged)/folders/+page.svelte | 313 +++---- .../(root)/(logged)/gcp_triggers/+page.svelte | 15 +- .../(logged)/kafka_triggers/+page.svelte | 15 +- .../(logged)/mqtt_triggers/+page.svelte | 15 +- .../[service_name]/+page.svelte | 26 +- .../(logged)/nats_triggers/+page.svelte | 15 +- .../(logged)/postgres_triggers/+page.svelte | 15 +- .../(root)/(logged)/resources/+page.svelte | 781 ++++++++++-------- .../(root)/(logged)/routes/+page.svelte | 15 +- .../(root)/(logged)/schedules/+page.svelte | 28 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 15 +- .../(root)/(logged)/variables/+page.svelte | 615 +++++++------- .../(logged)/websocket_triggers/+page.svelte | 15 +- 35 files changed, 1261 insertions(+), 881 deletions(-) create mode 100644 frontend/src/lib/components/common/emptyState/EmptyState.svelte create mode 100644 frontend/src/lib/components/common/tabs/TabFade.svelte create mode 100644 frontend/src/lib/utils/paint.ts diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 537e76ceff..5078e0a83b 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -54,6 +54,17 @@ ? T['options'][number]['value'] | `!${T['options'][number]['value']}` : T['options'][number]['value'] + /** + * Whether any filter currently narrows the result set — for pages that fetch + * server-side and so can't tell an empty workspace from an over-narrow filter. + * + * `false` does not count: a boolean filter that is off narrows nothing, and + * treating it as active makes an empty workspace look filtered. + */ + export function hasActiveFilters(val: Record): boolean { + return Object.values(val).some((v) => v !== undefined && v !== null && v !== '' && v !== false) + } + /** * Converts a FilterSchemaRec to a Zod schema for validation */ diff --git a/frontend/src/lib/components/common/emptyState/EmptyState.svelte b/frontend/src/lib/components/common/emptyState/EmptyState.svelte new file mode 100644 index 0000000000..8715065ad0 --- /dev/null +++ b/frontend/src/lib/components/common/emptyState/EmptyState.svelte @@ -0,0 +1,49 @@ + + +
+
+ +
+
+
{title}
+ {#if description} +
{description}
+ {/if} +
+ {#if action} + + + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/common/index.ts b/frontend/src/lib/components/common/index.ts index 4f390cfe57..531036c3de 100644 --- a/frontend/src/lib/components/common/index.ts +++ b/frontend/src/lib/components/common/index.ts @@ -7,6 +7,7 @@ export { default as UndoRedo } from './button/UndoRedo.svelte' export { default as NameIdTooltip } from './tooltip/NameIdTooltip.svelte' export { default as ClearableInput } from './clearableInput/ClearableInput.svelte' export { default as Drawer } from './drawer/Drawer.svelte' +export { default as EmptyState } from './emptyState/EmptyState.svelte' export { default as DrawerContent } from './drawer/DrawerContent.svelte' export { default as Kbd } from './kbd/Kbd.svelte' export { default as Menu } from './menu/Menu.svelte' @@ -15,6 +16,7 @@ export { default as SecondsInput } from './seconds/SecondsInput.svelte' export { default as Skeleton } from './skeleton/Skeleton.svelte' export { default as Tab } from './tabs/Tab.svelte' export { default as TabContent } from './tabs/TabContent.svelte' +export { default as TabFade } from './tabs/TabFade.svelte' export { default as Tabs } from './tabs/Tabs.svelte' export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte' export { default as FileInput } from './fileInput/FileInput.svelte' diff --git a/frontend/src/lib/components/common/tabs/TabFade.svelte b/frontend/src/lib/components/common/tabs/TabFade.svelte new file mode 100644 index 0000000000..a1d8b1a621 --- /dev/null +++ b/frontend/src/lib/components/common/tabs/TabFade.svelte @@ -0,0 +1,26 @@ + + + +
*]:col-start-1 [&>*]:row-start-1', clazz)}> + {#key key} + +
+ {@render children()} +
+ {/key} +
diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte index e25df2cc40..efd37403e9 100644 --- a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte +++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte @@ -1,6 +1,7 @@ +{#snippet newFolderPopover( + label: string, + placement: 'bottom' | 'bottom-end', + variant: 'accent' | 'default' +)} + + {#snippet trigger()} + + {/snippet} + {#snippet content({ close })} + handleKeyUp(e, () => close())} + placeholder="New folder name" + bind:value={newFolderName} + /> + +
+ +
+ {/snippet} +
+{/snippet} + @@ -131,164 +168,136 @@ New folder {:else} - - {#snippet trigger()} - - {/snippet} - {#snippet content({ close })} - handleKeyUp(e, () => close())} - placeholder="New folder name" - bind:value={newFolderName} - /> - -
- -
- {/snippet} -
+ {@render newFolderPopover('New folder', 'bottom-end', 'accent')} {/if}
- - - - Name - Labels - Scripts - Flows - Apps - Schedules - Variables - Resources - Participants - - - - - {#if folders === undefined} - {#each new Array(4) as _} - - - - - - {/each} - {:else} - {#if folders.length === 0} - - -
- No folders yet, create one -
-
- - {/if} + {#if folders?.length === 0} + + {#if !restricted} + {@render newFolderPopover('Add a folder', 'bottom', 'default')} + {/if} + + {:else} + + + + Name + Labels + Scripts + Flows + Apps + Schedules + Variables + Resources + Participants + + + + + {#if folders === undefined} + {#each new Array(4) as _} + + + + + + {/each} + {:else} + {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} + { + editFolderName = name + folderDrawer?.openDrawer() + }} + > + + {name} + {#if summary} +
+ {summary} + {/if} +
+ + {#if labels?.length} +
+ {#each labels.slice(0, 3) as label} + {label} + {/each} + {#if labels.length > 3} + 'Label: ' + l) + .join('\n')}>+{labels.length - 3} + {/if} +
+ {/if} +
+ - {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} - { - editFolderName = name - folderDrawer?.openDrawer() - }} - > - - {name} - {#if summary} -
- {summary} - {/if} -
- - {#if labels?.length} -
- {#each labels.slice(0, 3) as label} - {label} - {/each} - {#if labels.length > 3} - 'Label: ' + l) - .join('\n')}>+{labels.length - 3} - {/if} -
- {/if} -
- - - - - { - editFolderName = name - folderDrawer?.openDrawer() - } - }, - { - displayName: 'Publish to Hub', - icon: UploadCloud, - disabled: !($userStore?.is_admin || $userStore?.is_super_admin), - action: () => { - publishFolderName = name - hubDrawer?.openDrawer() - } - }, - { - displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`, - icon: Trash, - type: 'delete', - disabled: !canWrite, - action: async () => { - try { - await FolderService.deleteFolder({ - workspace: $workspaceStore ?? '', - name - }) - folders = folders?.filter((f) => f.name !== name) - } catch (e) { - sendUserToast(e.body, true) - loadFolders() + + + { + editFolderName = name + folderDrawer?.openDrawer() + } + }, + { + displayName: 'Publish to Hub', + icon: UploadCloud, + disabled: !($userStore?.is_admin || $userStore?.is_super_admin), + action: () => { + publishFolderName = name + hubDrawer?.openDrawer() + } + }, + { + displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`, + icon: Trash, + type: 'delete', + disabled: !canWrite, + action: async () => { + try { + await FolderService.deleteFolder({ + workspace: $workspaceStore ?? '', + name + }) + folders = folders?.filter((f) => f.name !== name) + } catch (e) { + sendUserToast(e.body, true) + loadFolders() + } } } - } - ]} - /> - -
- {/each} - {/if} - -
+ ]} + /> +
+ + {/each} + {/if} + +
+ {/if}
{/if} diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 21442cfdba..22234e0f04 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -383,7 +383,18 @@ {/each} {:else if !triggers?.length} -
No GCP Pub/Sub triggers
+ gcpTriggerEditor?.openNew(false), + aiId: 'gcp-triggers-empty-add', + aiDescription: 'Add GCP Pub/Sub trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 44cea41b51..6ee790818a 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -21,7 +21,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -348,7 +348,18 @@ {/each} {:else if !triggers?.length} -
No Kafka triggers
+ kafkaTriggerEditor?.openNew(false), + aiId: 'kafka-triggers-empty-add', + aiDescription: 'Add Kafka trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 905d5b914f..8a6d84fe90 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -338,7 +338,18 @@ {/each} {:else if !triggers?.length} -
No MQTT triggers
+ mqttTriggerEditor?.openNew(false), + aiId: 'mqtt-triggers-empty-add', + aiDescription: 'Add MQTT trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte index fce11df6b9..f5e9a30dc5 100644 --- a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte @@ -18,8 +18,9 @@ import { userStore, workspaceStore, userWorkspaces, usedTriggerKinds } from '$lib/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' - import { Button, Alert, Skeleton } from '$lib/components/common' - import { LoaderCircle, Plus } from 'lucide-svelte' + import { Button, Alert, EmptyState, Skeleton } from '$lib/components/common' + import { LoaderCircle, Plus, Webhook } from 'lucide-svelte' + import { GithubIcon, GoogleIcon, NextcloudIcon } from '$lib/components/icons' import SearchItems from '$lib/components/SearchItems.svelte' import NoItemFound from '$lib/components/home/NoItemFound.svelte' import { page } from '$app/state' @@ -30,6 +31,11 @@ const serviceName = $derived(page.params.service_name as NativeServiceName) const serviceConfig = $derived(getServiceConfig(serviceName)) + const serviceIcons: Partial> = { + nextcloud: NextcloudIcon, + google: GoogleIcon, + github: GithubIcon + } let triggers: TriggerW[] = $state([]) let loading = $state(true) @@ -264,9 +270,19 @@ {/each} {:else if !triggers?.length} -
- No {serviceConfig?.serviceDisplayName || serviceName} triggers -
+ editor?.openNew(), + aiId: 'native-triggers-empty-add', + aiDescription: 'Add native trigger' + }} + /> {:else if items?.length} {/each} {:else if !triggers?.length} -
No NATS triggers
+ natsTriggerEditor?.openNew(false), + aiId: 'nats-triggers-empty-add', + aiDescription: 'Add NATS trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index 193a3cb2ac..d1c07a7e75 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -414,7 +414,18 @@ {/each} {:else if !triggers?.length} -
No postgres triggers
+ postgresTriggerEditor?.openNew(false), + aiId: 'postgres-triggers-empty-add', + aiDescription: 'Add Postgres trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 27fdd286b5..b4cf764cb7 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/state' import AppConnect from '$lib/components/AppConnectDrawer.svelte' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton, Tab } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton, Tab, TabFade } from '$lib/components/common' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -17,6 +17,7 @@ import { resourceTypesStore } from '$lib/components/resourceTypesStore' import SchemaViewer from '$lib/components/SchemaViewer.svelte' import FilterSearchbar, { + hasActiveFilters, useUrlSyncedFilterInstance, type FilterInstanceRec } from '$lib/components/FilterSearchbar.svelte' @@ -55,14 +56,18 @@ Braces, Building, Circle, + Database, FileUp, Link, + Palette, Pen, Plus, RotateCw, Save, + SearchX, Shield, - Trash + Trash, + Zap } from 'lucide-svelte' import { onMount, untrack } from 'svelte' import autosize from '$lib/autosize' @@ -627,6 +632,36 @@ let showTable = $derived( tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme' ) + + let activeFilters = $derived(hasActiveFilters(filters.val)) + + const emptyStates: Record = { + workspace: { + icon: Boxes, + title: 'No resources yet', + description: + 'Resources hold the connection settings and credentials your scripts, flows and apps use to reach external systems.' + }, + states: { + icon: Database, + title: 'No states yet', + description: + 'States appear here once a script stores data to keep it persistent between runs of the same trigger.' + }, + cache: { + icon: Zap, + title: 'No cached results yet', + description: + 'Cached results appear here once a flow step with caching enabled has run at least once.' + }, + theme: { + icon: Palette, + title: 'No themes yet', + description: + 'Themes are CSS for the legacy low-code app editor only. Add one from the CSS panel of an app — they cannot be created here.' + } + } + let emptyState = $derived(emptyStates[tab] ?? emptyStates.workspace) {#snippet extra()} - Theme are actually resources (but excluded from the Workspace tab for clarity). Theme - are used by the apps to customize their look and feel. + Themes are actually resources (but excluded from the Workspace tab for clarity). They + are CSS for the legacy low-code app editor only, and are added from the CSS panel of + an app rather than from this page. {/snippet} @@ -989,384 +1025,403 @@ {/if}
- {#if showTable} -
- {#if loading.resources} - - {#each new Array(6) as _} - - {/each} - {:else if filteredItems?.length == 0} -
-
No resources found
-
- Try changing the filters or creating a new resource -
-
- {:else} - - - - - Path - Resource type - Description - - - - - - {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} - - - - - -
+ + {#if showTable} +
+ {#if loading.resources} + + {#each new Array(6) as _} + + {/each} + {:else if filteredItems?.length == 0} + {#if activeFilters} + + {:else} + appConnect?.open?.(), + aiId: 'resources-empty-add-resource', + aiDescription: 'Add resource' + } + : undefined} + /> + {/if} + {:else} + + + + + Path + Resource type + Description + + + + + + {#if filteredItems} + {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} + + + + + +
+ { + handledHash = `#/resource/${path}` + resourceEditor?.initEdit?.(path) + }} + >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} + + {#if labels?.length} +
+ {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} +
+ {/if} + +
+ + { - handledHash = `#/resource/${path}` - resourceEditor?.initEdit?.(path) - }} - >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} - - {#if labels?.length} -
- {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} -
- {/if} - -
- - - { - const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) - if (linkedRt) { - resourceTypeViewerObj = { - rt: linkedRt.name, - //@ts-ignore - schema: linkedRt.schema, - description: linkedRt.description ?? '', - formatExtension: linkedRt.format_extension, - isFileset: linkedRt.is_fileset ?? false + const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) + if (linkedRt) { + resourceTypeViewerObj = { + rt: linkedRt.name, + //@ts-ignore + schema: linkedRt.schema, + description: linkedRt.description ?? '', + formatExtension: linkedRt.format_extension, + isFileset: linkedRt.is_fileset ?? false + } + resourceTypeViewer?.openDrawer?.() + } else { + sendUserToast( + `Resource type ${resource_type} not found in workspace.`, + true + ) } - resourceTypeViewer?.openDrawer?.() - } else { - sendUserToast( - `Resource type ${resource_type} not found in workspace.`, - true - ) - } - }} - > - - - - - - {removeMarkdown(truncate(description ?? '', 30))} - - - -
-
- {#if is_linked} - - - {#snippet text()} -
- This resource is linked with a variable of the same path. They are - deleted and renamed together. -
- {/snippet} -
- {/if} -
-
- {#if is_refreshed} - - - {#snippet text()} -
- The OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
- {/snippet} -
- {/if} -
- - {#if is_oauth} -
- {#if refresh_error} + }} + > + + + + + + {removeMarkdown(truncate(description ?? '', 30))} + + + +
+
+ {#if is_linked} - + {#snippet text()}
- Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
- {/snippet} -
- {:else if is_expired} - - - - {#snippet text()} -
- The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
- {/snippet} -
- {:else} - - - {#snippet text()} -
- The resource was connected through OAuth and the token is not - expired. + This resource is linked with a variable of the same path. They + are deleted and renamed together.
{/snippet}
{/if}
- {/if} -
-
- -
- {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} - - {/if} - { - shareModal?.openDrawer?.(path, 'resource') - } - }, - { - displayName: 'Edit', - icon: Pen, - disabled: !canWrite || !showCreateButtons, - action: () => { - resourceEditor?.initEdit?.(path) - } - }, - ...(!ws_specific && isDeployable('resource', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'resource') +
+ {#if is_refreshed} + + + {#snippet text()} +
+ The OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
+ {/snippet} +
+ {/if} +
+ + {#if is_oauth} +
+ {#if refresh_error} + + + {#snippet text()} +
+ Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
+ {/snippet} +
+ {:else if is_expired} + + + + {#snippet text()} +
+ The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
+ {/snippet} +
+ {:else} + + + {#snippet text()} +
+ The resource was connected through OAuth and the token is not + expired. +
+ {/snippet} +
+ {/if} +
+ {/if} +
+
+ +
+ {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} + + {/if} + { + shareModal?.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Edit', + icon: Pen, + disabled: !canWrite || !showCreateButtons, + action: () => { + resourceEditor?.initEdit?.(path) + } + }, + ...(!ws_specific && isDeployable('resource', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'resource') + } } - } - ] - : []), - { - displayName: 'Delete', - disabled: !canWrite || !showCreateButtons, - icon: Trash, - type: 'delete', - action: (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - deleteResource(path, account) - } else { - deleteIsLinked = is_linked ?? false - deletePath = path - deleteConfirmedCallback = () => { + ] + : []), + { + displayName: 'Delete', + disabled: !canWrite || !showCreateButtons, + icon: Trash, + type: 'delete', + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { deleteResource(path, account) + } else { + deleteIsLinked = is_linked ?? false + deletePath = path + deleteConfirmedCallback = () => { + deleteResource(path, account) + } } } - } - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RotateCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadResources() + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RotateCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $workspaceStore ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadResources() + } } - } - ] - : []) - ]} - /> -
- - {/each} - {/if} - - - {/if} -
- {:else if tab == 'types'} - {#if loading.types} - - {#each new Array(6) as _} - - {/each} - {:else if filteredResourceTypes?.length == 0} -
-
No resource types found
-
- Try changing the filters or creating a new resource type -
-
- {:else} -
- - - - Name - Description - - - - - {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} - - - { - resourceTypeViewerObj = { - rt: name, - //@ts-ignore - schema: schema, - description: description ?? '', - formatExtension: format_extension, - isFileset: is_fileset ?? false - } - - resourceTypeViewer?.openDrawer?.() - }} + ] + : []) + ]} + /> +
- - - - - - {removeMarkdown(truncate(description ?? '', 200))} - - - - {#if !canWrite} - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} -
- - -
- {:else} - - Non Editable - - Since resource types are shared with the whole workspace, only admins - can edit/delete them - - - {/if} -
- - {/each} - {/if} - - + + {/each} + {/if} + + + {/if}
+ {:else if tab == 'types'} + {#if loading.types} + + {#each new Array(6) as _} + + {/each} + {:else if filteredResourceTypes?.length == 0} +
+ +
+ {:else} +
+ + + + Name + Description + + + + + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + + + { + resourceTypeViewerObj = { + rt: name, + //@ts-ignore + schema: schema, + description: description ?? '', + formatExtension: format_extension, + isFileset: is_fileset ?? false + } + + resourceTypeViewer?.openDrawer?.() + }} + > + + + + + + {removeMarkdown(truncate(description ?? '', 200))} + + + + {#if !canWrite} + + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + + {:else if $userStore?.is_admin || $userStore?.is_super_admin} +
+ + +
+ {:else} + + Non Editable + + Since resource types are shared with the whole workspace, only admins + can edit/delete them + + + {/if} +
+
+ {/each} + {/if} + +
+
+ {/if} {/if} - {/if} + {/if} diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 5f2a657480..53de5457d1 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Button, Skeleton } from '$lib/components/common' + import { Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -361,7 +361,18 @@ {/each} {:else if !triggers?.length} -
No routes
+ routeEditor?.openNew(false), + aiId: 'routes-empty-add', + aiDescription: 'Add route' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index 7cbfffba7c..e403c981de 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -10,7 +10,7 @@ import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Badge, Button, Skeleton } from '$lib/components/common' + import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import Popover from '$lib/components/Popover.svelte' @@ -21,6 +21,7 @@ import Toggle from '$lib/components/Toggle.svelte' import { userStore, workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores' import { + Calendar, Circle, Copy, Eye, @@ -30,12 +31,14 @@ Pen, Play, Plus, + SearchX, Shield, Trash } from 'lucide-svelte' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import FilterSearchbar, { + hasActiveFilters, useUrlSyncedFilterInstance } from '$lib/components/FilterSearchbar.svelte' import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter' @@ -245,6 +248,8 @@ }) ) let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema)) + + let activeFilters = $derived(hasActiveFilters(filters.val)) let allFolders = $derived( Array.from( new Set( @@ -373,7 +378,26 @@ {/each} {:else if !schedules?.length} -
No schedules
+ {#if activeFilters} + + {:else} + scheduleEditor?.openNew(false), + aiId: 'schedules-empty-add', + aiDescription: 'Add schedule' + }} + /> + {/if} {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index 41aa60d31b..1226fc2c0f 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -21,7 +21,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -332,7 +332,18 @@ {/each} {:else if !triggers?.length} -
No sqs triggers
+ sqsTriggerEditor?.openNew(false), + aiId: 'sqs-triggers-empty-add', + aiDescription: 'Add SQS trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 787353bdf8..0a23656514 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -1,7 +1,16 @@ -{#if assets.value && assets.value.length > 0} +{#if assets.status === 'idle' || assets.status === 'loading'} + +{:else if assets.value && assets.value.assets.length > 0}
    - {#each assets.value ?? [] as asset} -
  • + {#each assets.value.assets as asset} +
  • {asset.path} @@ -90,17 +139,26 @@ })}
    - + {#if asset.access_type} + {formatAssetAccessType(asset.access_type)} + {/if} +
  • {/each}
+ {#if assets.value.truncated} +
+ This run touched more assets than are listed here. +
+ {/if} {:else} -
No assets found
+
+ No assets found + + Assets detected while a run executes are recorded asynchronously, and only the most recent + runs that touched an asset keep that record. + +
{/if} From 6783a396b144948fa60324eae888bc4a83917bc8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 18 Aug 2026 10:32:26 +0200 Subject: [PATCH 124/192] fix(api): document cache_ignore_s3_path on the Script read schema (#10742) Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api/openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f8579f9aa4..392229e05b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -26421,6 +26421,8 @@ components: type: integer cache_ttl: type: number + cache_ignore_s3_path: + type: boolean dedicated_worker: type: boolean ws_error_handler_muted: From 8492b4b4ba53b9061c479609e4c2cfe8d0e32427 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 18 Aug 2026 12:24:53 +0200 Subject: [PATCH 125/192] chore: prove scratch file ops per command segment (#10744) * fix(agents): prove scratch file ops per command segment Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the checkout root in the scratch guidance Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): close two auto-allow holes in the scratch guards Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): keep redirects and chained writes off the allow path Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): never prove a command carrying a substitution or relative cd Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): treat sibling checkouts as separate roots Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): prove where a directory-form cp or mv actually lands Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): leave directory-form cp and mv unproved Co-Authored-By: Claude Opus 5 (1M context) * docs: state the one-write-per-line rule in the scratch guidance Co-Authored-By: Claude Opus 5 (1M context) * docs: prefer Edit/Write over shell edits in agent guidance Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): stop a failed cd from hiding the directory form Co-Authored-By: Claude Opus 5 (1M context) * refactor(agents): state the glob and cd rationale once Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 308 ++++++++++++++++++-------- .claude/hooks/guard-rm-outside-tmp.sh | 189 +++++++++------- .claude/hooks/lib-guarded-verb.sh | 191 ++++++++++++---- .claude/hooks/test-hooks.sh | 80 ++++++- AGENTS.md | 17 +- 5 files changed, 567 insertions(+), 218 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 665c23cf0a..87ce6541aa 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,17 +1,31 @@ #!/usr/bin/env bash -# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line -# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand -# resolves under /tmp. Anything else makes no decision (exit 0) and falls back to the normal -# permission flow, except for `mv` and `chmod`: those get an explicit `ask`, the only prompt -# they get (see lib-guarded-verb.sh). +# PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` / +# `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes — +# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp. +# Anything else makes no decision (exit 0) and falls back to the normal permission flow, except +# for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see +# lib-guarded-verb.sh). +# +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. A +# decision covers the whole command line, so `allow` is emitted only when every segment is one +# of these verbs proved here or a `cd` that resolved, AND exactly one of them writes (see the +# gate at the foot of this file — an earlier write can change what a later operand means). A +# line that mixes a proven op with some other command makes no decision instead and leaves that +# line to the normal permission flow, rather than waving an unexamined command through with it. # # This is a hook rather than an allow rule because permission rules match a command prefix, so # they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, # and requiring every operand is the point. # -# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a -# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy -# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows. +# One operation may not straddle two roots, sources included, and a sibling checkout is a +# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout +# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)` +# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one +# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every +# operand of one operation inside a single root closes both without restating those rules here. +# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a +# file there has never prompted, and moving or chmod-ing one is not the graver act. # # Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token # must consist only of alphanumerics and `. _ / -`. That set contains none of the characters @@ -19,12 +33,19 @@ # any glob character, so all of those forms fail by construction. `realpath -m` then resolves # `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught. # +# `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because +# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names, +# not a file named P, and resolving it as a path would put an option in a root and allow it. +# The other five take relative operands, resolved against the working directory that `cd` +# tracking maintains, since for those a bare word really is a path (a GNU option starts with +# `-`, and the option allowlist below rejects the ones that would change symlink handling). +# # `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE # (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it. # Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's # refusal to extract `..` and absolute member paths — defer rather than needing enumeration. -# Extraction additionally requires an explicit destination under /tmp, or a cwd already under -# /tmp, since otherwise members land in the project checkout. +# Extraction additionally requires an explicit destination under /tmp, or a working directory +# already under /tmp, since otherwise members land in the project checkout. # # Residual risk accepted: an archive whose members include a symlink pointing out of /tmp # followed by a write through it can still escape, because tar applies member symlinks as it @@ -52,25 +73,51 @@ defer() { exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" +# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash +# expands it only after the hook has decided, so realpath sees the unexpanded pattern — +# `/tmp/link*` canonicalizes to itself and passes, then expands onto a symlink whose target is +# outside, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target. +# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following +# it.) The charset holds none of the characters bash uses for quoting, expansion or separation. +literal_path() { + case "$1" in *[*?[]*) return 1 ;; esac + [ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ] +} -# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. +# Prints the root class of a path token, then the path it resolved to on a second line, +# resolving a relative one against the tracked working directory. Fails, printing nothing, +# when the token is unsafe to reason about or lands outside every root. +operand_class() { + local t="$1" canon alt cls alt_cls="" + literal_path "$t" || return 1 + case "$t" in + /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + *) # A `cd` may fail at runtime and leave the command where it started, so a relative + # operand has to land in the same root either way. + [ -n "$seg_cwd" ] || return 1 + canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + if [ -n "$alt_cwd" ]; then + alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null) + [ -n "$alt" ] || return 1 + alt_cls=$(path_class "$alt") || return 1 + fi + ;; + esac + [ -n "$canon" ] || return 1 + cls=$(path_class "$canon") || return 1 + [ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1 + # Class and resolved path together: a caller runs this in a command substitution, so a global + # set here would be set in that subshell and lost. + printf '%s\n%s' "$cls" "$canon" +} + +# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive +# parser's stricter check; everything else goes through operand_class. under_tmp() { local t="$1" canon - # Globs never auto-allow. Bash expands them only after this hook has decided, so realpath - # sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then - # expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line - # symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs - # because `rm` unlinks the symlink itself rather than following it. - case "$t" in *[*?[]*) return 1 ;; esac - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 - # Absolute only. Resolving a relative operand against the cwd makes any bare word look like - # a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option: - # `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a - # dereferencing recursive copy, not a file named -RL. + literal_path "$t" || return 1 case "$t" in /*) ;; *) return 1 ;; esac canon=$(realpath -m -- "$t" 2>/dev/null) [ -n "$canon" ] || return 1 @@ -79,29 +126,16 @@ under_tmp() { return 1 } -# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer. -# Options are an allowlist per command, so anything that changes how symlinks are followed -# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while -# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch -# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate -# such a symlink as a symlink instead, so no outside content is materialized. -case "${toks[0]:-}" in - mkdir) takes_mode=0; ok_opts='pv' ;; - cp) takes_mode=0; ok_opts='rRvfnpa' ;; - mv) takes_mode=0; ok_opts='vfn' ;; - touch) takes_mode=0; ok_opts='acmv' ;; - chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path - tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; - unzip) ok_flags='oqnljvd'; val_flags='d' ;; - *) defer "not the leading command word" ;; -esac - -# ---------------------------------------------------------------- tar / unzip -if [ -n "${ok_flags:-}" ]; then - saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 - i=1 - while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" +# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS. +check_archive_segment() { + local verb="$1" ok_flags val_flags t flags val + local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1 + case "$verb" in + tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; + unzip) ok_flags='oqnljvd'; val_flags='d' ;; + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" i=$((i + 1)) if [ "$end_opts" = 0 ]; then [ "$t" = "--" ] && { end_opts=1; continue; } @@ -112,13 +146,13 @@ if [ -n "${ok_flags:-}" ]; then # leave a residue here and defer rather than being enumerated as denials. [ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`" case "$flags" in *x*) extracting=1 ;; esac - case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac + case "$verb$flags" in unzip*[lv]*) listing=1 ;; esac # A flag consuming the next token must be alone in its bundle's final position # (`-xzf a.tar`), else the token it eats is ambiguous. case "${flags%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac case "${flags: -1}" in [$val_flags]) - val="${toks[$i]:-}" + val="${SEG_TOKS[$i]:-}" i=$((i + 1)) [ -n "$val" ] || defer "option \`$t\` has no value" under_tmp "$val" || defer "\`$val\` is outside /tmp" @@ -136,54 +170,152 @@ if [ -n "${ok_flags:-}" ]; then # first is the archive. Requiring every one under /tmp is conservative for member names, # which are not filesystem paths — those defer rather than being wrongly allowed. under_tmp "$t" || defer "\`$t\` is outside /tmp" - [ "${toks[0]}" = "unzip" ] && saw_archive=1 + [ "$verb" = "unzip" ] && saw_archive=1 done # tar without -f reads a tape/stdin; unzip needs an archive [ "$saw_archive" = 1 ] || defer "no archive operand" # Writes land relative to the working directory unless a destination was given. `unzip -l` # and `-v` only list, so they need no destination. - if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then - [ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || defer "extraction target is outside /tmp" + if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then + # An extraction with no destination lands in the working directory. Word splitting cannot + # tell a `cd` inside a quoted string from one the shell runs, and believing a false one + # would put an archive's members in the checkout, so once any `cd` is in the line only an + # explicit destination will do. + [ "$saw_dest" = 1 ] \ + || { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \ + || defer "extraction target is outside /tmp" fi - decide allow "archive paths and extraction target are under /tmp" -fi +} -# ------------------------------------------- mkdir / cp / mv / touch / chmod -path_operand=0 -seen_mode=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) +# Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens +# are in SEG_TOKS. +check_fileops_segment() { + local verb="$1" takes_mode ok_opts t cls resolved seen_class="" + local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0 + local -a ops=() + # Options are an allowlist per command, so anything that changes how symlinks are followed + # defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while + # recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch + # dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate + # such a symlink as a symlink instead, so no outside content is materialized. + case "$verb" in + mkdir) takes_mode=0; ok_opts='pv' ;; + cp) takes_mode=0; ok_opts='rRvfnpa' ;; + mv) takes_mode=0; ok_opts='vfn' ;; + touch) takes_mode=0; ok_opts='acmv' ;; + chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Checked at any position, not just before the first operand: GNU utils permute, so - # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. - case "$t" in - -?*) - # Allowlist: long options and the dereferencing flags leave a residue and defer. - [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" - continue - ;; - esac - fi + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Checked at any position, not just before the first operand: GNU utils permute, so + # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. + case "$t" in + -?*) + # Allowlist: long options and the dereferencing flags leave a residue and defer. + [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" + continue + ;; + esac + fi - # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. - if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then - case "$t" in - [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; - *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; - esac - seen_mode=1 - continue - fi + # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. + if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then + case "$t" in + [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; + *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; + esac + seen_mode=1 + continue + fi - under_tmp "$t" || defer "\`$t\` is outside /tmp" - path_operand=1 + resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME" + cls="${resolved%%$'\n'*}" + # Every operand of one operation stays in one root: see the exfiltration note above. + [ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots" + seen_class="$cls" + ops+=("${resolved#*$'\n'}") + case "$t" in /*) ;; *) rel_operand=1 ;; esac + path_operand=1 + done + + [ "$path_operand" = 1 ] || defer "no path operand" + + # In directory form the command writes a path it does not name: `cp x dir` writes `dir/x`, + # and `cp` follows that child when it is a symlink — this checkout is full of them, every + # `*_ee.rs` pointing into the sibling EE repo. Deriving that child would mean reproducing + # which name the tool picks (the operand as written, not as resolved — a symlinked source + # keeps its own name) and how deep `-r` recurses. The form is left unproved instead. + case "$verb" in + cp | mv) + [ "${#ops[@]}" -ge 2 ] || return 0 + # Whether the destination is an existing directory is itself a question about which of + # the two candidate working directories the command ran in, and only one of them is in + # `ops`. A `cd` that fails at runtime would otherwise let the form through: the + # destination resolved against the directory the command never reached is some path that + # does not exist, while the one it actually ran in is a directory full of symlinks. + [ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \ + && defer "a relative operand after a \`cd\` lands in one of two directories" + [ -d "${ops[-1]}" ] \ + && defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name" + ;; + esac +} + +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +alt_cwd="" # where a `cd` that failed would have left the command +saw_cd=0 # a `cd` moved the working directory somewhere +proved=0 # how many ops came out inside a single root +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + mkdir | cp | mv | touch | chmod) + check_fileops_segment "${SEG_TOKS[0]}" + proved=$((proved + 1)) + continue + ;; + tar | unzip) + check_archive_segment "${SEG_TOKS[0]}" + proved=$((proved + 1)) + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative + # operand points, to one of the two candidates `apply_cd` describes. + if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then + alt_cwd="$seg_cwd" + seg_cwd="$new_cwd" + else + # Not the harmless segment an allow assumes: whatever this guard could not account for + # may be a redirect, and a redirect writes. Leave the line to the normal flow. + seg_cwd="" alt_cwd="" + only_ours=0 + fi + saw_cd=1 + continue + ;; + esac + # Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind + # a wrapper, an env prefix or a path — this hook cannot say what it writes to. + for verb in mv chmod; do + segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`" + done + only_ours=0 done -[ "$path_operand" = 1 ] || defer "no path operand" -decide allow "every path operand is under /tmp" +# Exactly one write per line. Each segment is proved against the filesystem as it stands now, +# and an earlier write can change what a later operand means: `cp -r /tmp/tree /tmp/live` that +# recreates a symlink out of /tmp turns `/tmp/live/link` — a path under /tmp when this ran — +# into a write through that symlink. Deletes compose safely and guard-rm-outside-tmp.sh allows +# several, because `rm` unlinks a symlink rather than following it. +[ "$proved" -ge 1 ] || exit 0 +[ "$only_ours" = 1 ] && [ "$proved" = 1 ] && decide allow "every path operand is inside a single root" +exit 0 diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 5aff497d89..4d253ffc62 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash -# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every -# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME -# (a version-controlled project dir). Any other command that runs `rm` gets an explicit `ask`, -# which is the ordinary permission prompt and the only one `rm` gets (see lib-guarded-verb.sh); -# a command that runs no `rm` at all makes no decision (exit 0). +# PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target — +# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir). +# Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission +# prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at +# all makes no decision (exit 0). # -# The git-tree allowance trades on "this is a project under version control" being lower-stakes -# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git, -# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history -# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands. +# A decision covers the whole command line, so `allow` is emitted only when every segment is +# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with +# some other command makes no decision instead and leaves that line to the normal permission +# flow: the delete is not what needed a prompt, and waving the rest of the line through with +# it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything. # # Deny-by-default: every token must consist only of a safe character set (alphanumerics, # `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for @@ -17,12 +20,12 @@ # and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a # non-final path segment is refused because it can expand through a symlink realpath can't see. # -# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's -# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in -# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`, -# `.claude` or `.env` path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion +# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in +# lib-guarded-verb.sh. Globs auto-allow only under /tmp — elsewhere their expansion # could reach `.git` or a dotfile the literal checks never see. Relative operands resolve -# against the command's cwd (from the hook input). +# against the working directory the command runs from, which a `cd` in an earlier segment +# moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a +# relative operand can no longer be proved. # # Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. set -uo pipefail @@ -35,87 +38,103 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) # Every bail-out below goes through `defer`, so the forms this guard refuses to reason about — -# compound, quoted, wrapped — still reach the user as a prompt whenever an `rm` runs among them. +# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them. runs_verb rm "$cmd" && guarded=1 || guarded=0 defer() { [ "$guarded" = 1 ] && decide ask "$1" exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" -# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer. -[ "${toks[0]:-}" = "rm" ] || defer "rm is not the leading command word" - -# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly -# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at -# ~ can't make all of $HOME deletable, and top-level ~ files stay protected. -allowed_target() { - local canon="$1" d root="" - case "$canon" in /tmp/?*) return 0 ;; esac - [ -n "${HOME:-}" ] || return 1 - case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac - # Never auto-allow: history, and the two kinds of path the "it's under version control" - # premise doesn't hold for — the agent's own guards and settings (deleting them is what - # removes the prompt on everything else), and gitignored `.env` files. - case "$canon" in - *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; - *"/.env" | *"/.env."*) return 1 ;; - esac - d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do - [ -e "$d/.git" ] && { root="$d"; break; } - d=$(dirname "$d") +# Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative +# operands against $seg_cwd. Returns only once every operand is an auto-allowable target; +# anything it cannot prove defers instead. +check_rm_segment() { + local i=1 t canon candidates had_operand=0 end_opts=0 + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) + # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` + # can't slip past): any character outside the safe set makes it unsafe to reason about. + [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" + # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` + # into an operand — never a real option, so defer. + case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Skip real options only before the first operand. A bare `-` is a filename, and under + # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` + # is a filename too — validate it rather than skipping it. + if [ "$had_operand" = 0 ]; then + case "$t" in -?*) continue ;; esac + fi + fi + had_operand=1 + # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink + # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. + case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac + # A relative operand has as many candidate paths as the command has candidate working + # directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime + # leaves the delete running in the directory it started in. + case "$t" in + /*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;; + *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down" + candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + [ -n "$alt_cwd" ] && candidates="$candidates +$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)" + ;; + esac + while IFS= read -r canon; do + [ -n "$canon" ] || defer "cannot resolve \`$t\`" + # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its + # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the + # literal-path checks never see — so require literal operands in git repos. + case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac + path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + done <<< "$candidates" done - [ -n "$root" ] || return 1 # not inside a git working tree under $HOME - if [ "$canon" = "$root" ]; then - # Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is - # a file/pointer so the history lives in the main repo and survives. A primary checkout's - # `.git` is a directory holding the history, so deleting it is unrecoverable — defer. - [ -f "$root/.git" ] && return 0 - return 1 - fi - return 0 + [ "$had_operand" = 1 ] || defer "no operand" } -had_operand=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) - # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` - # can't slip past): any character outside the safe set makes it unsafe to reason about. - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" - # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` - # into an operand — never a real option, so defer. - case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Skip real options only before the first operand. A bare `-` is a filename, and under - # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` - # is a filename too — validate it rather than skipping it. - if [ "$had_operand" = 0 ]; then - case "$t" in -?*) continue ;; esac - fi - fi - had_operand=1 - # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink - # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. - case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac - case "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; - *) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;; +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +alt_cwd="" # where a `cd` that failed would have left the command +saw_cd=0 +proved=0 # at least one `rm` segment came out auto-allowable +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + rm) + check_rm_segment + proved=1 + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative + # operand points, to one of the two candidates `apply_cd` describes. + if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then + alt_cwd="$seg_cwd" + seg_cwd="$new_cwd" + else + # Not the harmless segment an allow assumes: whatever this guard could not account for + # may be a redirect, and a redirect writes. Leave the line to the normal flow. + seg_cwd="" alt_cwd="" + only_ours=0 + fi + saw_cd=1 + continue + ;; esac - [ -n "$canon" ] || defer "cannot resolve \`$t\`" - # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its - # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the - # literal-path checks never see — so require literal operands in git repos. - case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac - allowed_target "$canon" || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper, + # an env prefix or a path — this guard cannot say what it deletes. + segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`" + only_ours=0 done -[ "$had_operand" = 1 ] || defer "no operand" -decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +[ "$proved" = 1 ] || exit 0 +[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +exit 0 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index c4d5f5ac9a..6ef76a5466 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,17 +10,6 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f -# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash -# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: -# the command splits on `; & |` and newlines, and a leading env assignment or process wrapper -# (`timeout 5 rm`, `xargs rm`) is skipped before the command word is read. -# -# The split set also carries the characters that open a nested command — `$(`, backticks and -# `( )` — because a rule matches the verb inside one (`echo $(rm -rf ~)` prompts), and a -# separator that only ends statements would read that as an `echo`. Braces are handled as -# words rather than separators, since splitting on them cuts `xargs -I {} … rm` in half and -# strands the `rm` in a segment that no longer knows a wrapper preceded it. - # 0 iff ($1) starts with a command that only reads its input. An allowlist, because the # opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`, # `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only @@ -115,35 +104,159 @@ strip_heredoc_bodies() { done } +# 0 iff ($1) runs as a command word in ($2), which must already be one +# segment (no separator left in it). Wrapper, env-prefix and `/bin/` forms all count. +segment_runs_verb() { + local verb="$1" w wrapped=0 + for w in $2; do + # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and + # `r\m` run rm and have to compare equal to it. + w="${w//[\"\'\\]/}" + case "$w" in + "$verb" | */"$verb") return 0 ;; + *=*) ;; # leading env assignment + -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect + [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose + '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command + timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) + wrapped=1 ;; + # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), + # so past a wrapper the scan runs to the end of the segment instead of stopping at the + # first ordinary word. Before one, that word is the command and the verb cannot follow + # it. Nothing bounds the scan: a wrapper takes unboundedly many operands + # (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted + # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the + # price, and it only over-prompts. + *) [ "$wrapped" = 1 ] || break ;; + esac + done + return 1 +} + +# Splits ($1) into its command segments, into the global array SEGMENTS. Every guard +# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable +# blob, and a newline is a separator like any other. +# +# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested +# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an +# `echo`. Braces are handled as words rather than separators, since splitting on them cuts +# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper +# preceded it. +# +# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself, +# which silently leaves the command unsplit and every separator unseen. +split_segments() { + local seg + SEGMENTS=() + while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')" +} + +# 0 iff ($1) carries a command substitution outside a heredoc body. A substitution is +# concatenated into the word it sits in, and splitting on its opener cuts that word in half: +# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an +# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a +# command. Heredoc bodies are excepted — those are data the split has already dropped. +has_substitution() { + case "$(strip_heredoc_bodies "$1")" in + *'$('* | *'`'*) return 0 ;; + esac + return 1 +} + +# Reads ($1) into the global array SEG_TOKS, dropping the shell keywords that can +# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word +# splitting only: quotes are left in the token and fail the guards' charset check downstream, +# which is what keeps `rm -rf "$HOME/x"` unprovable. +segment_tokens() { + SEG_TOKS=() + read -r -a SEG_TOKS <<< "$1" + while [ "${#SEG_TOKS[@]}" -gt 0 ]; do + case "${SEG_TOKS[0]}" in + '!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;; + *) break ;; + esac + done +} + +# Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the +# `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable, +# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one. +# +# Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and +# `;` runs the next command anyway, leaving it in the directory it started in. So a caller may +# never treat this as the working directory outright — it is one of two candidates, and a +# relative operand has to be provable against the one the command started in as well. That also +# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate, +# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that +# sees a second gives up on relative operands entirely. +apply_cd() { + local cwd="$1" t + shift + [ "$#" -eq 1 ] || return 1 + t="$1" + [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 + # Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first, + # so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out. + case "$t" in /*) ;; *) return 1 ;; esac + realpath -m -- "$t" 2>/dev/null +} + +# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or +# `repo:` for one strictly inside the git working tree at , itself under $HOME. +# Fails, printing nothing, for anything else — those are the only roots the guards are willing +# to touch unprompted. The root is part of the class so that a caller pairing two operands can +# tell one checkout from another: sibling repos are separate permission boundaries, not one. +# +# The `repo` class trades on "this is a project under version control" being lower-stakes than +# the same act elsewhere — NOT on full recoverability: committed content is restorable via git, +# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history +# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# +# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and +# top-level ~ files stay out of one. A working tree's own root folder counts only when it is a +# linked worktree, whose `.git` is a pointer file so the history lives in the main repo and +# survives; a primary checkout's `.git` is a directory holding the history itself, so losing it +# is unrecoverable. +# +# Some paths are in no class in any root, /tmp included. Git history, and the agent's own guards +# and settings, because removing those is what removes the prompt on everything else. And every +# path `.claude/settings.json` refuses to read — `.env`, `secrets/`, `*.pem`, `*.key`, +# `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends +# would rename one out of those globs and hand back through `Read` exactly what they deny. +path_class() { + local canon="$1" d root="" + case "$canon" in + *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; + *"/.env" | *"/.env."*) return 1 ;; + *"/secrets" | *"/secrets/"*) return 1 ;; + *.pem | *.key | *"/credentials.json") return 1 ;; + *"/.secret"* | *.secret | *.secrets) return 1 ;; + esac + case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac + [ -n "${HOME:-}" ] || return 1 + case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac + d="$canon" + while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do + [ -e "$d/.git" ] && { root="$d"; break; } + d=$(dirname "$d") + done + [ -n "$root" ] || return 1 # not inside a git working tree under $HOME + if [ "$canon" = "$root" ]; then + [ -f "$root/.git" ] || return 1 + fi + printf 'repo:%s' "$root" +} + +# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash +# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: +# a guard consults this before it starts proving segments, and every bail-out it then takes +# is a prompt for exactly the commands a rule would have caught. runs_verb() { - local verb="$1" seg w wrapped - while IFS= read -r seg; do - wrapped=0 - for w in $seg; do - # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and - # `r\m` run rm and have to compare equal to it. - w="${w//[\"\'\\]/}" - case "$w" in - "$verb" | */"$verb") return 0 ;; - *=*) ;; # leading env assignment - -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect - [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose - '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command - timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) - wrapped=1 ;; - # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), - # so past a wrapper the scan runs to the end of the segment instead of stopping at the - # first ordinary word. Before one, that word is the command and the verb cannot follow - # it. Nothing bounds the scan: a wrapper takes unboundedly many operands - # (`env -u A -u B …`), and any cutoff — a word count, or stopping at the first quoted - # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the - # price, and it only over-prompts. - *) [ "$wrapped" = 1 ] || break ;; - esac - done - # `tr` and not `${2//[...]}`: a `}` inside the bracket expression closes the expansion - # itself, which silently leaves the command unsplit and every separator unseen. - done <<< "$(strip_heredoc_bodies "$2" | tr ';&|()`' '\n')" + local verb="$1" seg + split_segments "$2" + for seg in "${SEGMENTS[@]}"; do + segment_runs_verb "$verb" "$seg" && return 0 + done return 1 } diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 65631b6a48..eca946baf9 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -4,6 +4,10 @@ # What this pins is the `ask` column: a matcher change that turns one into a no-decision drops # that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted # rows are the ones that catch it. +# +# The `allow` column carries its own weight, because a decision covers the whole command line: +# `allow` may only appear where every segment was proved here, and a line that also runs +# something unexamined has to come out `none` so the normal permission flow still sees it. set -uo pipefail H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" CWD="$(git -C "$H" rev-parse --show-toplevel)" @@ -46,10 +50,11 @@ run $G ask "rm -rf $CWD/*" run $G ask "rm -rf /etc/passwd" run $G ask 'rm -rf "$HOME/x"' run $G ask "rm -rf /tmp/../$OUT" -run $G ask "ls /tmp && rm -rf /tmp/x" +run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour run $G ask 'echo $(rm -rf /etc)' run $G ask 'echo `rm -rf /etc`' run $G ask "{ rm -rf /etc; }" +run $G allow "{ rm -rf /tmp/scratch/x; }" # the keyword drops, the delete still proves run $G ask "find . -name x | xargs rm" run $G ask "timeout 5 rm -rf /tmp/x" run $G ask "stdbuf -o L rm -rf /etc" @@ -107,6 +112,33 @@ run $G none 'echo $(ls /tmp)' run $G none 'grep -rn "rm" backend/' run $G none "cargo build --release" +# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its +# own operands, and a `cd` moves where a relative one points. +run $G allow "rm -f /tmp/a; rm -rf /tmp/b" +run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")" +run $G allow "cd /tmp/scratch && rm -rf sub" +run $G none "mkdir -p /tmp/x && rm -rf /tmp/x" +run $G ask "$(printf 'ls /tmp\nrm -rf /etc')" +# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the +# working directory unknown, and an unknown one proves nothing. +run $G ask "cd /etc && rm -rf foo" +run $G ask 'cd "$D" && rm -rf foo' +run $G ask "cd $CWD && rm -rf .git" +run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back +# A `cd` can fail at runtime, and `;` runs the delete from where the command started, so a +# relative operand is proved from both directories. +run $G ask "cd /tmp/does-not-exist; rm -rf .git" +run $G ask "cd /tmp/does-not-exist; rm -rf backend/.env" +run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub" +run $G ask "rm -rf /tmp/clone/.git" # history is never in a class +run $G ask "rm -rf /tmp/scratch/id_rsa.key" +run $G none "cd /tmp >$OUT; rm -f /tmp/a" +# A substitution is concatenated into its word, so splitting on it would prove only the literal +# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first. +run $G ask 'rm -rf /tmp/a/`printf ../../etc`' +run $G ask 'rm -rf /tmp/a/$(printf ../../etc)' +run $G ask "cd ssh && rm -rf moduli" + echo echo "== allow-fileops-in-tmp.sh ==" A=allow-fileops-in-tmp.sh @@ -117,7 +149,7 @@ run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out" run $A ask "mv /tmp/a $OUT" run $A ask "mv $CWD/AGENTS.md /tmp/a" run $A ask "chmod -R 777 $CWD" -run $A ask "ls && mv /tmp/a /tmp/b" +run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour run $A ask 'echo $(mv /tmp/a /etc)' run $A ask "timeout --signal KILL 5 mv /tmp/a /etc" run $A ask "time -f FORMAT chmod 777 $OUT" @@ -129,5 +161,49 @@ run $A none "cp $CWD/AGENTS.md /tmp/a" run $A none "tar -xzf /tmp/a.tar.gz -C $OUT" run $A none "cargo build" +run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line +run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')" +run $A ask "ls && mv /tmp/a /etc" +run $A ask "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")" +run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out" +# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the +# in-repo delete already was — but one operation may not straddle it and /tmp. +run $A allow "chmod +x scripts/worktree-env" +run $A allow "mv backend/.sqlx backend/.sqlx.bad" +run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts" +run $A ask "mv /tmp/a $CWD/frontend/a.ts" +run $A ask "chmod -R 777 $CWD/.git" +run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak" +run $A ask "mv $CWD/AGENTS.md $OUT" +run $A ask "cd /etc && mv a b" +# An auto-allowed rename may not carry a path out of the `Read` deny globs. +run $A ask "mv backend/server.pem backend/server.txt" +run $A none "cp backend/secrets/token frontend/token.txt" # cp has no prompt of its own, + # so what matters is it is not allowed +run $A ask "mv $CWD/backend/credentials.json /tmp/x" +run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak" +# A segment this hook cannot read whole may carry a redirect, and an earlier write can change +# what a later operand resolves to — neither may ride along on an allow. +run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b" +run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link" +run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b' +# A sibling checkout is a different root: its files are outside what the Read tool is confined +# to, and copying them in would hand back what that confinement withholds. +EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root +run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs" +run $A none "cp $EE/README.md $CWD/README.copy" +# Directory form writes a path the command does not name — DEST/basename(SRC) — and `cp` +# follows that child when it is a symlink, as every `*_ee.rs` in this checkout is. +run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp frontend/a.ts backend" +run $A ask "mv /tmp/a $CWD/backend" +# ... and a `cd` that fails at runtime may not hide that form: the destination is a directory +# in the directory the command actually ran in, whichever of the two that turns out to be. +run $A none "cd $CWD/AGENTS.md; cp frontend/apps_ee.rs backend/windmill-api/src" +run $A ask "cd $CWD/AGENTS.md; mv frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cd /tmp/x && tar -xzf /tmp/a.tar.gz" # no -C, and the cwd is now two candidates +run $A allow "cp frontend/a.ts backend/a.ts" # ... naming the destination proves fine + echo [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index 06b0402fd6..1da4be6c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,10 +147,19 @@ $NAV --root backend callees "X" # what does X call? - **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up - committed. Write `rm`/`mv`/`cp` as one plain unchained command: a PreToolUse hook - auto-allows those when every operand is under `/tmp` or inside this checkout, but it defers - on `&&`, `;`, redirects, quotes and `$VAR` — that deferral, not the delete itself, is what - turns a routine cleanup into a permission prompt. + committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each + operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git + checkout under `$HOME`, as long as one operation stays within a single root — a sibling + checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each + proved on its own operands, but keep writes to one per line, name the destination rather than + a directory to drop it in, and put anything else on its own line: a command the hook does not + prove drops the whole line back to the normal permission flow. A + quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like + `xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt. +- **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline + `python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission + classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash + stays right for running things — tests, builds, git, one-off queries. - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked From 6749015fbf7afe0c6dcd53b1933b0152915afd32 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 18 Aug 2026 12:25:21 +0200 Subject: [PATCH 126/192] fix: audit the icon library against brand guidelines (#10722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: audit the icon library against brand guidelines Every icon component checked against its brand's own published guidelines for correct artwork, current colours, and readability on both app surfaces. - 127 marks now carry a per-theme pair (text-[#light] dark:text-[#dark]), applied only where the brand publishes a reversed or dark variant. twMerge where the component exposes a class prop, so callers can still pass sizing. - 296 of 304 brand icons record their source in a comment above the , including the rule where the brand imposes one (Google forbids recolouring, Cal.com is deliberately greyscale, Oracle reserves the MySQL dolphin). - BRAND_COLORS.md is generated from the components, so the table cannot drift from the code. - Marks that were unreadable on a surface: 13 -> 1 on dark, 9 -> 4 on light. The remainder are blocked by trademark terms, not unfixed. - Wrong artwork replaced where a first-party or CC0 source existed: PayPal is the real three-colour monogram, Stripe is the bare S rather than an app tile, gcloud resolves to Google's mark instead of a generic hexagon. - Concept icons (CACertificate, DbIcon, Webdav, Asset*, Bcrypt) inherit currentColor instead of hardcoding a colour. Fixes a cross-component CSS bug: ten icons embedded
@@ -904,8 +936,8 @@

Instance-configured OAuth APIs

- {#if filteredConnects} - {#each filteredConnects as { key }} + {#if rankedConnects} + {#each rankedConnects as { key }} {/if} {#each filteredResources as r} - {@const isPicked = value === r} + {@const isPicked = value === r.name} {/each} diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7ef3543073..9f0395b924 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -116,15 +116,15 @@ {:else if effectiveKind === 'postgres'} {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'amqp'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} {:else if effectiveKind === 'azure'} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index ac41827d6c..29d12b8084 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -5,16 +5,18 @@ import { getContext } from 'svelte' import { type TriggerContext } from '$lib/components/triggers' import { enterpriseLicense } from '$lib/stores' - import { - MqttIcon, - AmqpIcon, - NatsIcon, - KafkaIcon, - AwsIcon, - GoogleCloudIcon - } from '$lib/components/icons' + import MqttIcon from '$lib/components/icons/MqttIcon.svelte' + import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte' + import NatsIcon from '$lib/components/icons/NatsIcon.svelte' + import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' + import AwsIcon from '$lib/components/icons/AwsIcon.svelte' + import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import AzureIcon from '$lib/components/icons/AzureIcon.svelte' - import { type Trigger, type TriggerType } from '$lib/components/triggers/utils' + import { + triggerIconMapMono, + type Trigger, + type TriggerType + } from '$lib/components/triggers/utils' import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte' @@ -320,10 +322,13 @@ {/snippet} {#snippet simpleTriggerItem({ item, type })} - {@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || { + {@const { icon: ColourIcon, countKey } = triggerTypeConfig()[type] || { icon: Database, countKey: undefined }} + + {@const SvelteComponent = triggerIconMapMono[type] ?? ColourIcon}
diff --git a/frontend/src/lib/components/icons/AblyIcon.svelte b/frontend/src/lib/components/icons/AblyIcon.svelte new file mode 100644 index 0000000000..ce4c360152 --- /dev/null +++ b/frontend/src/lib/components/icons/AblyIcon.svelte @@ -0,0 +1,59 @@ + + + + diff --git a/frontend/src/lib/components/icons/AbstractApiIcon.svelte b/frontend/src/lib/components/icons/AbstractApiIcon.svelte new file mode 100644 index 0000000000..a4b1635775 --- /dev/null +++ b/frontend/src/lib/components/icons/AbstractApiIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AcceloIcon.svelte b/frontend/src/lib/components/icons/AcceloIcon.svelte new file mode 100644 index 0000000000..0f7846e903 --- /dev/null +++ b/frontend/src/lib/components/icons/AcceloIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ActimoIcon.svelte b/frontend/src/lib/components/icons/ActimoIcon.svelte new file mode 100644 index 0000000000..59accffb99 --- /dev/null +++ b/frontend/src/lib/components/icons/ActimoIcon.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte index 836b5a242b..8d0b08374b 100644 --- a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte +++ b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte @@ -7,9 +7,17 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/ActivitypubIcon.svelte b/frontend/src/lib/components/icons/ActivitypubIcon.svelte index 0185d98f3a..b8eea782f7 100644 --- a/frontend/src/lib/components/icons/ActivitypubIcon.svelte +++ b/frontend/src/lib/components/icons/ActivitypubIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AdRapidIcon.svelte b/frontend/src/lib/components/icons/AdRapidIcon.svelte new file mode 100644 index 0000000000..443b96a548 --- /dev/null +++ b/frontend/src/lib/components/icons/AdRapidIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/AdhookIcon.svelte b/frontend/src/lib/components/icons/AdhookIcon.svelte new file mode 100644 index 0000000000..89da89b9cf --- /dev/null +++ b/frontend/src/lib/components/icons/AdhookIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte index 2f04246966..f1b513be98 100644 --- a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte +++ b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte @@ -1,24 +1,24 @@ - - - - - + + diff --git a/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte new file mode 100644 index 0000000000..c104bca866 --- /dev/null +++ b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte new file mode 100644 index 0000000000..e5a6169bbe --- /dev/null +++ b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/Ai21Icon.svelte b/frontend/src/lib/components/icons/Ai21Icon.svelte new file mode 100644 index 0000000000..600fb8a3b6 --- /dev/null +++ b/frontend/src/lib/components/icons/Ai21Icon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/AiAgentIcon.svelte b/frontend/src/lib/components/icons/AiAgentIcon.svelte new file mode 100644 index 0000000000..f3d042c98d --- /dev/null +++ b/frontend/src/lib/components/icons/AiAgentIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AirtableIcon.svelte b/frontend/src/lib/components/icons/AirtableIcon.svelte index e71aa95bdd..70cc6c1698 100644 --- a/frontend/src/lib/components/icons/AirtableIcon.svelte +++ b/frontend/src/lib/components/icons/AirtableIcon.svelte @@ -1,22 +1,31 @@ + + d="m228.6 47.2-190.9 79c-10.6 4.4-10.5 19.5.2 23.7l191.7 76c16.8 6.7 35.6 6.7 52.4 0l191.7-76c10.7-4.2 10.8-19.3.2-23.7L283 47.2c-17.4-7.2-37-7.2-54.4 0" + style="fill:#fcb400" + /> + diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte index e003c778e4..5b1fdddedd 100644 --- a/frontend/src/lib/components/icons/AlgoliaIcon.svelte +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/AmqpIcon.svelte b/frontend/src/lib/components/icons/AmqpIcon.svelte index 41987f704f..c5de0c1699 100644 --- a/frontend/src/lib/components/icons/AmqpIcon.svelte +++ b/frontend/src/lib/components/icons/AmqpIcon.svelte @@ -8,6 +8,8 @@ let { size = 16, color = undefined, class: clazz = '' }: Props = $props() + - - + + diff --git a/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte new file mode 100644 index 0000000000..8fe884c479 --- /dev/null +++ b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ApifyIcon.svelte b/frontend/src/lib/components/icons/ApifyIcon.svelte index b5f1529bdc..9c9e6d5978 100644 --- a/frontend/src/lib/components/icons/ApifyIcon.svelte +++ b/frontend/src/lib/components/icons/ApifyIcon.svelte @@ -1,22 +1,32 @@ + - - - - - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ApolloIcon.svelte b/frontend/src/lib/components/icons/ApolloIcon.svelte index 85dbb1730a..c41d0087f5 100644 --- a/frontend/src/lib/components/icons/ApolloIcon.svelte +++ b/frontend/src/lib/components/icons/ApolloIcon.svelte @@ -1,12 +1,31 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/AppwriteIcon.svelte b/frontend/src/lib/components/icons/AppwriteIcon.svelte index 53d8d0369b..34de69ee08 100644 --- a/frontend/src/lib/components/icons/AppwriteIcon.svelte +++ b/frontend/src/lib/components/icons/AppwriteIcon.svelte @@ -1,21 +1,29 @@ + - - + diff --git a/frontend/src/lib/components/icons/ArcGisIcon.svelte b/frontend/src/lib/components/icons/ArcGisIcon.svelte new file mode 100644 index 0000000000..660d28b804 --- /dev/null +++ b/frontend/src/lib/components/icons/ArcGisIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/AsanaIcon.svelte b/frontend/src/lib/components/icons/AsanaIcon.svelte index 2c03b80a27..e6043784fd 100644 --- a/frontend/src/lib/components/icons/AsanaIcon.svelte +++ b/frontend/src/lib/components/icons/AsanaIcon.svelte @@ -7,6 +7,7 @@ let { height = '24px', width = '24px' }: Props = $props() + Asana diff --git a/frontend/src/lib/components/icons/AssemblyAiIcon.svelte b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte new file mode 100644 index 0000000000..bfc65a32db --- /dev/null +++ b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte index b4d019e4b6..d7342a2765 100644 --- a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte +++ b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte @@ -6,7 +6,12 @@ class?: string } - let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props() + let { + height = '24px', + width = '24px', + fill = 'currentColor', + class: className = '' + }: Props = $props() + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + diff --git a/frontend/src/lib/components/icons/Auth0Icon.svelte b/frontend/src/lib/components/icons/Auth0Icon.svelte index fbf419e3cf..97fa53a3d9 100644 --- a/frontend/src/lib/components/icons/Auth0Icon.svelte +++ b/frontend/src/lib/components/icons/Auth0Icon.svelte @@ -1,4 +1,5 @@ + auth0-svg + + + diff --git a/frontend/src/lib/components/icons/AutheliaIcon.svelte b/frontend/src/lib/components/icons/AutheliaIcon.svelte index 037844b4d3..4878dcb26f 100644 --- a/frontend/src/lib/components/icons/AutheliaIcon.svelte +++ b/frontend/src/lib/components/icons/AutheliaIcon.svelte @@ -1,32 +1,48 @@ - - authelia-svg - - + authelia-svg + + - + - + - + - + - + 1340 25 134 25 437 0 575 -26 150 -80 311 -114 343 -43 41 -103 38 -148 -7z" + /> + diff --git a/frontend/src/lib/components/icons/AuthentikIcon.svelte b/frontend/src/lib/components/icons/AuthentikIcon.svelte index ee52fba6af..e79f18eef1 100644 --- a/frontend/src/lib/components/icons/AuthentikIcon.svelte +++ b/frontend/src/lib/components/icons/AuthentikIcon.svelte @@ -1,27 +1,19 @@ - - authentik-svg - - - - - - - - - - - - - - - + + + authentik-svg + + diff --git a/frontend/src/lib/components/icons/AwsEcrIcon.svelte b/frontend/src/lib/components/icons/AwsEcrIcon.svelte index 2801712f62..379a14e4a0 100644 --- a/frontend/src/lib/components/icons/AwsEcrIcon.svelte +++ b/frontend/src/lib/components/icons/AwsEcrIcon.svelte @@ -1,12 +1,16 @@ + + - + - - - - diff --git a/frontend/src/lib/components/icons/AwsIcon.svelte b/frontend/src/lib/components/icons/AwsIcon.svelte index 3b02d9d773..7f371a6229 100644 --- a/frontend/src/lib/components/icons/AwsIcon.svelte +++ b/frontend/src/lib/components/icons/AwsIcon.svelte @@ -1,33 +1,38 @@ + - - diff --git a/frontend/src/lib/components/icons/AzureIcon.svelte b/frontend/src/lib/components/icons/AzureIcon.svelte index c6c3b6d60b..011142b851 100644 --- a/frontend/src/lib/components/icons/AzureIcon.svelte +++ b/frontend/src/lib/components/icons/AzureIcon.svelte @@ -1,22 +1,86 @@ + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BRAND_COLORS.md b/frontend/src/lib/components/icons/BRAND_COLORS.md new file mode 100644 index 0000000000..1cff77a3d3 --- /dev/null +++ b/frontend/src/lib/components/icons/BRAND_COLORS.md @@ -0,0 +1,578 @@ +# Icon brand colours + +Where every icon's colours come from, and whether they survive both app surfaces. +Compiled from the components themselves during the audit — colours read from the fills and +the Tailwind pair classes, sources from each component's provenance comment, contrast +computed from those hexes against `surface-primary` in each theme. Maintained by hand from +here on: change an icon's colour or source and change its row. + +Surfaces: light `#fbfbfd`, dark `#2e3441`. Ratios are WCAG non-text contrast; **bold** marks a +mark that is effectively invisible on that surface. WCAG exempts logotypes from the 3:1 +floor, so a low ratio is a signal the colour may be wrong, not automatically a defect. + +`pair` = brand publishes a per-theme variant. Usually applied as `text-[#light] dark:text-[#dark]`; `AnsibleIcon` inverts instead (`dark:invert`), and `DatadogIcon`, `DenoIcon`, `DeepLIcon` and `TogglIcon` swap between two SVGs (`dark:hidden` / `hidden dark:block`) because their two marks are different artwork, not the same shape recoloured. +`fixed` = full-colour mark, same in both themes. `inherits` = brand publishes no colour, +so the mark takes the surrounding text colour. `mixed` = the root carries a +`fill="currentColor"` that hardcoded path fills override, so it is inert — these are +candidates for cleanup, not theme-aware icons. + +The Light/Dark columns show the colour that carries the mark; white and black knockout +details are omitted. Ratios are the best contrast any part of the mark achieves. + +**Do not change a colour here without a first-party source.** Several of these look like +mistakes and are not: Cal.com is deliberately greyscale, Google Cloud may not be recoloured, +Stripe is blurple rather than black. Third-party icon sets go stale and have been wrong +repeatedly — check the brand's own page. + +| Icon | Resource types | Mode | Light | Dark | ☀ | 🌙 | Source | +|---|---|---|---|---|---|---|---| +| `AblyIcon` | `ably` | fixed | #FF5416 | #FF5416 | 3.87 | 3.88 | brand.ably.com/logo | +| `AbstractApiIcon` | `abstractapi` | fixed | #20E492 | #20E492 | **1.62** | 12.47 | abstractapi.com's own logo SVG (6538df34291c9fa4ed28d6f7_Logo.svg) | +| `AcceloIcon` | `accelo` | fixed | #4C49CB | #4C49CB | 6.51 | 8.15 | Accelo_Logo-Primary.svg on accelo.com | +| `ActiveCampaignIcon` | `activecampaign` | pair | #004CFF | #FFFFFF | 5.84 | 12.47 | activecampaign.com/brand logo pack (ActiveCampaign-Glyph-Blue.svg / ActiveCampaign-Glyph-White.svg) | +| `ActivitypubIcon` | `activitypub` | fixed | #F1007E | #F1007E | 5.01 | 2.99 | activitypub.rocks/static/images/ActivityPub-logo.svg | +| `AcumbamailIcon` | `acumbamail` | fixed | #E62F71 | #E62F71 | 8.83 | 8.86 | Acumbamail's own isotype SVG, /static/favico/Acumbamail/favicon-32.svg on acumbamail.com | +| `AdhookIcon` | `adhook` | fixed | #00ACC6 | #00ACC6 | 2.63 | 4.58 | adhook's own logo (https://adhook.io/fr/images/logo.svg, `.cls-1{fill:#00acc6}`) | +| `AdobeAcrobatSignIcon` | `adobe_acrobat_sign` | fixed | #584CCC | #584CCC | 6.12 | 12.47 | Adobe's own Acrobat Sign product icon (adobe.com/cc-shared/assets/img/product-icons/svg/acrobat-sign.svg); same value in the live app favicon | +| `Ai21Icon` | `ai21` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ai21.com (ai21-logo-black.svg / ai21-logo-white.svg) | +| `AirtableIcon` | `airtable` | mixed | #FCB400 | #FCB400 | 3.88 | 6.93 | airtable.com/favicon.ico (fixed full-colour mark: #18BFFF and #F82B60 panels) | +| `AlgoliaIcon` | `algolia` | pair | #003DFF | #FFFFFF | 6.53 | 12.47 | algolia.com logo pack (Algolia-mark-blue.svg / Algolia-mark-white.svg) | +| `AmqpIcon` | `amqp` | fixed | — | — | — | — | — | +| `AnsibleIcon` | `ansible` | pair | #1A1918 | #E5E6E7 | 16.99 | 9.98 | ansible/logos community-marks (Black and White variants, CC BY-SA 4.0) | +| `AnthropicIcon` | `anthropic` | pair | #141413 | #FAF9F5 | 17.84 | 11.84 | anthropics/skills | +| `ApifyIcon` | `apify` | fixed | #246DFF | #246DFF | 4.32 | 12.47 | apify.com/resources/brand | +| `ApolloIcon` | `apollo` | pair | #1F1F1E | #F8FF2C | 15.96 | 11.48 | apollo.io | +| `AppwriteIcon` | `appwrite` | mixed | #FD366E | #FD366E | 3.88 | 5.71 | https://appwrite.io/assets | +| `ArcGisIcon` | `arcgis_account` | fixed | #006FDE | #006FDE | 4.69 | 2.57 | Esri's ArcGIS Pro product logo (esri.com/content/dam/esrisites/en-us/common/icons/product-logos/arcgis-pro-64.svg) | +| `AsanaIcon` | `asana` | fixed | #FF584A | #FF584A | 3.01 | 4.01 | asana.com/brand | +| `AssemblyAiIcon` | `assemblyai` | pair | #1D1B16 | #C7C3B2 | 16.65 | 12.47 | assemblyai.com (assemblyai-logo-full-primary.svg / assemblyai-logo-full-secondary.svg) | +| `AttioIcon` | `attio` | pair | #1C1D1F | #FFFFFF | 16.32 | 12.47 | the attio.com header logo (--color-black-100 / --color-white-100) | +| `Auth0Icon` | `auth0` | pair | #232220 | #FFFFFF | 15.38 | 12.47 | auth0.com docs logo light.svg / dark.svg | +| `AutheliaIcon` | `authelia` | fixed | #3F51B4 | #3F51B4 | 6.67 | 1.81 | authelia.com/images/branding/logo-cropped.svg (light stop of the official #3F51B4→#113155 gradient, flattened) | +| `AuthentikIcon` | `authentik` | pair | #FD4B2D | #FFFFFF | 3.27 | 12.47 | goauthentik.io/press | +| `AwsEcrIcon` | `aws_ecr` | fixed | #ED7100 | #ED7100 | 2.92 | 12.47 | the AWS Architecture Icons package (Icon-package_07312026, Arch_Containers/Arch_Amazon-Elastic-Container-Registry) | +| `AwsIcon` | `aws`, `redshift` | pair | #252F3E | #FF9900 | 13.07 | 5.83 | AWS's own logo files (d0.awsstatic.com/logos/powered-by-aws{,-white}.png) | +| `AzureIcon` | `azure` | fixed | — | — | — | — | Microsoft's own logo_azure.svg (learn.microsoft.com/media/logos/logo_azure.svg), whose outer wedges add the #114A8B->#0669BC and #3CCBF4->#2892DF gradients | +| `BambooHrIcon` | `bamboo_hr` | pair | #599D15 | #FFFFFF | 3.25 | 12.47 | bamboohr.com (Encore --brandColor; bamboohr-logo-white.png is the published reversed variant) | +| `BaremetricsIcon` | `baremetrics` | fixed | #5386FF | #5386FF | 3.27 | 3.70 | the mark in baremetrics.com's header logo (baremetrics-logo.svg), the asset this path is taken from | +| `BaserowIcon` | `baserow`, `baserow_table` | fixed | #2BC3F1 | #2BC3F1 | 4.96 | 6.05 | the baserow.io favicon and horizontal logo | +| `BasisTheoryIcon` | `basis_theory` | pair | #1D2032 | #EBEDFF | 15.57 | 10.74 | developers.basistheory.com/img/bt-logo-light.svg and bt-logo-dark.svg, which ship the same mark geometry in the two theme colours | +| `BeamerIcon` | `beamer` | pair | #1C1E21 | #FFFFFF | 16.16 | 12.47 | the getbeamer.com header logo (g#isotype) and their webclip app icon, which sets the same mark in white on #1C1E21 | +| `BigQueryIcon` | `bigquery` | fixed | #34A853 | #34A853 | 3.80 | 7.30 | Google Cloud's official icon library (cloud.google.com/icons, core-products-icons.zip) | +| `BitbucketIcon` | `bitbucket` | pair | #1868DB | #FFFFFF | 5.03 | 12.47 | atlassian.design/foundations/logos (Bitbucket mark, brand and inverse) | +| `BitlyIcon` | `bitly` | fixed | #F36600 | #F36600 | 3.03 | 3.99 | bitly.com/pages/bitly-logo-usage-guidelines-for-media (Bitly-MediaKit glyph_bitly_orange_RGB.svg) | +| `BloggerIcon` | `blogger` | fixed | #F57C00 | #F57C00 | 2.62 | 12.47 | Google's Blogger product logo (gstatic.com/images/branding/productlogos/blogger/v5/192px.svg) | +| `BlueskyIcon` | `bluesky` | pair | #0560FF | #FFFFFF | 4.93 | 12.47 | bsky.social/about/support/branding | +| `BotifyIcon` | `botify` | fixed | #A973FF | #A973FF | 3.09 | 3.91 | botify.com design tokens (--color--surface--purple-05) | +| `BoxIcon` | `box` | pair | #0061D5 | #FFFFFF | 5.54 | 12.47 | box.com (.box-logo-svg fill:#0061d5, reversed to #fff over the dark masthead) | +| `BrevoIcon` | `brevo`, `sendinblue` | fixed | #0B996E | #0B996E | 3.51 | 12.47 | brevo.com's favicon.svg | +| `BrexIcon` | `brex` | pair | #15191E | #FFFFFF | 17.08 | 12.47 | brex.com | +| `BrowserlessIcon` | `browserless` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | browserless.io/favicon.svg | +| `BubbleIcon` | `bubble` | mixed | #0000FF | #0000FF | 8.31 | 5.71 | the logo SVG served on bubble.io/brand; the B is #262626 there, kept as currentColor so the monochrome part follows the app theme | +| `BuildkiteIcon` | `buildkite` | fixed | #30F2A2 | #30F2A2 | 2.04 | 8.52 | buildkite.com/about/brand-assets | +| `BunIcon` | — | fixed | #FBF0DF | #FBF0DF | 6.48 | 12.47 | https://bun.com/logo.svg | +| `ButtondownIcon` | `buttondown` | fixed | #0069FF | #0069FF | 4.55 | 2.65 | https://buttondown.com/brand | +| `CSharpIcon` | — | fixed | #927BE5 | #927BE5 | 7.68 | 12.47 | dotnet/brand logo/language-icons/csharp-72.svg (CC0) | +| `CalcomIcon` | `calcom` | pair | #292929 | #FAFAFA | 14.08 | 11.95 | design.cal.com | +| `CalendlyIcon` | `calendly` | pair | #006BFF | #FFFFFF | 4.47 | 12.47 | Calendly's 2024 External Brand Guidelines and calendly_brand mark_white.svg (media kit on calendly.com/newsroom) | +| `CampaynIcon` | `campayn` | fixed | #008AFF | #008AFF | 3.34 | 12.47 | app.campayn.com/images/campayn/favicons/safari-pinned-tab.svg (colours sampled from android-chrome-512x512.png in the same directory) | +| `CertopusIcon` | `certopus` | fixed | #FF6E30 | #FF6E30 | 12.07 | 12.47 | https://certopus.com/images/logo/logo_circle.svg | +| `ChromaIcon` | `chromadb` | fixed | #FFDE2D | #FFDE2D | 3.65 | 9.35 | Chroma's own logo SVG served by trychroma.com (chroma-wordmark.svg) | +| `CircleCiIcon` | `circleci` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | brand.circleci.com | +| `CiscoIcon` | `cisco` | pair | #00BCEB | #FFFFFF | 2.16 | 12.47 | cisco.com logo SVG and newsroom.cisco.com/logos | +| `ClaudeIcon` | — | fixed | #D97757 | #D97757 | 3.02 | 12.47 | https://claude.ai/favicon.svg (Anthropic's own asset) | +| `ClearbitIcon` | `clearbit` | fixed | #4DB1FD | #4DB1FD | 20.32 | 10.83 | clearbit.com/logo.svg | +| `ClerkIcon` | `clerk` | fixed | #BAB1FF | #BAB1FF | 5.10 | 6.43 | clerk.com/brand-assets (symbol-primary.svg) | +| `ClickhouseIcon` | `clickhouse` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | clickhouse.design/brand/logo-usage (logomark, on-light / on-dark) | +| `ClickupIcon` | `clickup` | fixed | #6647F0 | #6647F0 | 5.46 | 4.12 | clickup.com/brand (v4 Logomark-gradient.svg); the gradient mark is the same on light and dark, and the guidelines say "don't change the color" | +| `CloseIcon` | `close` | fixed | #4EC375 | #4EC375 | 4.77 | 7.39 | close.com/brand (close-logo-2024 mark.svg) | +| `CloudflareIcon` | `cloudflare` | fixed | #FF5F08 | #FF5F08 | 2.95 | 5.83 | the logomark shipped on cloudflare.com, blog.cloudflare.com and workers.cloudflare.com | +| `CloudinaryIcon` | `cloudinary` | pair | #3448C5 | #FFFFFF | 7.06 | 12.47 | cloudinary_logo_for_white_bg.svg and cloudinary_logo_for_black_bg.svg on cloudinary-res.cloudinary.com | +| `CockroachDbIcon` | `cockroachdb` | pair | #6933FF | #FFFFFF | 5.78 | 12.47 | cockroachlabs.com (electric-purple-500, also the CockroachDB docs primaryColor) and the docs light/dark logo pair | +| `CodaIcon` | `coda` | fixed | #F46A54 | #F46A54 | 2.89 | 4.18 | Coda's own app icon, https://cdn.coda.io/icons/png/color/coda-192.png (single-colour mark, no dark variant published) | +| `CodatIcon` | `codat` | fixed | #D1E100 | #D1E100 | 17.31 | 8.60 | codat.io (logo-white.svg glyph outlines, colours from the site palette); framing matches their 300x300 favicon exactly | +| `CohereIcon` | `cohere` | fixed | #355146 | #355146 | 8.41 | 12.47 | https://cohere.com/logo.svg | +| `CoinMarketCapIcon` | `coinmarketcap` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | coinmarketcap.com | +| `CoinbaseIcon` | `coinbase` | pair | #0052FF | — | 5.57 | — | Coinbase's own light/dark logo files (mintcdn.com/coinbase-prod/.../logos/wordmark-light.svg and wordmark-dark.svg, served by docs.cdp.coinbase.com) | +| `ComapeoIcon` | `comapeo_server` | pair | #022199 | #0066FF | 12.09 | 2.58 | the CoMapeo Cloud mark shipped as public/favicon.svg in digidem/comapeo-cloud-app (the server this resource connects to) | +| `ConfluenceIcon` | `confluence` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's @atlaskit/logo (atlassian.design logo library) | +| `ContentfulIcon` | `contentful` | fixed | #1773EB | #1773EB | 4.33 | 9.08 | Contentful's Forma 36 design system (ContentfulLogoIcon) | +| `ContiguityIcon` | `contiguity` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | contiguity.com/assets/icon-white.png and icon-black.png (docs.contiguity.com likewise ships logo/black.svg for light and logo/white.svg for dark) | +| `ConvertKitIcon` | `convertkit` | pair | #1E1E1E | #F2EFE9 | 16.13 | 10.87 | kit.com/brand | +| `CoupaIcon` | `coupa` | pair | #1565C0 | #FFFFFF | 5.56 | 12.47 | the Coupa logo kit linked from coupa.com/company/press-kit, which ships the mark in blue and a white reversed variant | +| `CssIcon` | — | fixed | #663399 | #663399 | 8.13 | 12.47 | github.com/CSS-Next/logo.css (CC0), the official CSS logo endorsed by the W3C CSS WG | +| `CurrencyApiIcon` | `currencyapi` | fixed | #2994FF | #2994FF | 9.13 | 4.67 | currencyapi.com/img/currencyapi_logo_color.svg | +| `DatabricksIcon` | `databricks` | fixed | #FF3621 | #FF3621 | 3.50 | 3.45 | Databricks' own logo asset (databricks.com/sites/default/files/2023-08/databricks-default.png) | +| `DatadogIcon` | `datadog` | pair | #632CA6 | #FFFFFF | 8.32 | 12.47 | datadoghq.com press kit | +| `DatoCmsIcon` | `datocms` | fixed | #FF7751 | #FF7751 | 2.54 | 4.76 | datocms.com/company/brand-assets | +| `DbtIcon` | `dbt_profile` | fixed | #FE6703 | #FE6703 | 2.84 | 4.25 | the dbt Labs brand assets (getdbt.com/brand-guidelines) | +| `DeelIcon` | `deel` | pair | #1B1B1B | #FFFFFF | 16.67 | 12.47 | deel.com's own logo_revamp.svg / logo_revamp_white.svg | +| `DeepInfraIcon` | `deep_infra` | pair | #2A3275 | #4C9CEC | 11.22 | 12.47 | the DeepInfra press-kit logo pack (deepinfra.com/media-center → DEEPINFRA_LOGO_COLOR / DEEPINFRA_LOGO_WHITE) | +| `DeepLIcon` | `deepl` | pair | #0F2B46 | #FFFFFF | 13.97 | 12.47 | DeepL's official logo pack on deepl.com/en/press ("Logo Deep Blue" RGB #0F2B46 and the published "Logo White" reversed variant) | +| `DeepSeekIcon` | `deepseek` | pair | #4D6BFE | #6799FE | 4.19 | 4.49 | deepseek.com design tokens (--ds-color-brand under :root / [data-theme=dark]) | +| `DenoIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Deno Logo Guidelines 2024" asset pack on deno.com/brand | +| `DigitalOceanIcon` | `digitalocean` | fixed | #0080FF | #0080FF | 3.67 | 3.29 | DigitalOcean's official logo kit (DO_Logo_icon_blue.svg, linked from digitalocean.com/press) | +| `DiscordIcon` | `discord`, `discord_webhook` | mixed | #5865F2 | #5865F2 | 4.46 | 5.71 | https://discord.com/branding | +| `DiscourseIcon` | `discourse` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | discourse.org/brand (discourse-icon.svg / discourse-icon-dark.svg) | +| `DocSpringIcon` | `docspring` | fixed | #3C8EE0 | #3C8EE0 | 3.31 | 12.47 | DocSpring's own logo SVG, docspring.com/assets/logo-text-*.svg | +| `DockerIcon` | — | fixed | #2560FF | #2560FF | 4.84 | 2.49 | Docker's official logo kit (docker.com/company/newsroom/media-resources) | +| `DocusignIcon` | `docusign` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.docusign.com/logo: only the Nexus overlap flips per background, Cobalt #4C00FF and Poppy #FF5252 must not be recoloured | +| `DropboxIcon` | `dropbox` | fixed | #0061FE | #0061FE | 4.91 | 2.46 | brand.dropbox.com/logo and the DIG token dig-color__primary__base | +| `DuckDbIcon` | `duckdb` | pair | #1A1A1A | #FFF100 | 16.84 | 10.59 | duckdb.org/design logo package (DuckDB_icon-lightmode.svg / DuckDB_icon-darkmode.svg) | +| `DucklakeIcon` | — | pair | #1A1A1A | #2EAFFF | 16.84 | 5.16 | duckdb.org | +| `DustIcon` | `dust` | fixed | #FE9C1A | #FE9C1A | 4.04 | 10.64 | dust.tt/home/brand-resources (Dust_LogoSquare.svg from their brand kit) | +| `DynatraceIcon` | `dynatrace` | fixed | #1496FF | #1496FF | 10.01 | 7.83 | Dynatrace brand guidelines (live.standards.site/dynatrace, Dynatrace_mark_color.svg) | +| `EdgeDbIcon` | `edgedb` | fixed | #8FAF24 | #8FAF24 | 2.44 | 4.94 | geldata.com (favicon/apple-touch-icon glyph and its ) | +| `EnodeIcon` | `enode` | pair | #5D770D | #E8E8E1 | 4.94 | 10.13 | enode.com/static/favicon.svg | +| `EventbriteIcon` | `eventbrite` | fixed | #FF5E30 | #FF5E30 | 2.95 | 4.10 | the 2025 Eventbrite press kit logos; the brand publishes no reversed variant | +| `ExaIcon` | `exa` | pair | #0143D9 | #FFFFFF | 7.24 | 12.47 | exa.ai/brand (Exa Brand Assets kit, Logomark Blue/White) | +| `FaunadbIcon` | `faunadb` | pair | #3F00A5 | #604BE9 | 11.58 | 2.19 | Fauna's own VS Code extension icons (fauna/fauna-vscode: icons/fauna.svg for light themes, icons/fauna-light.svg for dark) | +| `FigmaIcon` | `figma` | fixed | #24CB71 | #24CB71 | 4.42 | 5.85 | static.figma.com/app/icon/2/favicon.svg (2025 brand refresh) | +| `FirebaseIcon` | `firebase` | fixed | #FF9100 | #FF9100 | 4.58 | 7.81 | firebase.google.com/brand-guidelines (Logomark_Full Color.svg in firebase-brand-assets.zip) | +| `FlyIcon` | `fly` | pair | #24175B | #FFFFFF | 15.07 | 12.47 | fly.io | +| `FormstackIcon` | `formstack` | fixed | #21B573 | #21B573 | 2.56 | 4.70 | the brand guide at formstack.com/press-kit | +| `FoxentryIcon` | `foxentry` | fixed | #E74600 | #E74600 | 5.09 | 4.14 | foxentry.com/assets/img/logo-foxentry-symbol.svg | +| `FreshdeskIcon` | `freshdesk` | fixed | #20A849 | #20A849 | 3.01 | 12.47 | Freshworks' own product-logo asset (freshdesk-dew.svg, used on freshworks.com/apps) | +| `FrontAppIcon` | `frontapp` | fixed | #A857F1 | #A857F1 | 3.83 | 3.15 | the logo mark front.com ships inline on its own pages; the mark keeps this purple on both light and dark backgrounds | +| `FunkwhaleIcon` | `funkwhale` | mixed | #009FE3 | #009FE3 | 10.69 | 5.71 | www.funkwhale.audio/logos (theme/images/icon.svg) | +| `GSheetsIcon` | `gsheets` | fixed | #009954 | #009954 | 3.57 | 12.47 | Google product logo sheets_2026q3 (gstatic productlogos, used on workspace.google.com/products/sheets) | +| `GcalIcon` | `gcal` | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | Google's own Calendar 2026 product logo, https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg (paths verbatim) | +| `GdocsIcon` | `gdocs` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | Google's own Docs product icon (gstatic.com/images/branding/productlogos/docs_2026/v2/web/192px.svg, served on workspace.google.com/products/docs) | +| `GdriveIcon` | `gdrive` | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg, Google's own product-logo CDN; paths and gradient stops are verbatim | +| `GhostCmsIcon` | `ghostcms` | pair | #15171A | #FFFFFF | 17.38 | 12.47 | docs.ghost.org | +| `GiphyIcon` | `giphy` | fixed | #FFF35C | #FFF35C | 4.76 | 10.83 | GIPHY's own app icon (giphy.com/static/img/icons/apple-touch-icon-180px.png) | +| `GitBookIcon` | `gitbook` | pair | #181C1F | #F2F7F7 | 16.59 | 11.54 | the GitBook-icon-dark / GitBook-icon-light downloads on gitbook.gitbook.io/brand-assets, matching the live gitbook.com favicon | +| `GitIcon` | `git_repository`, `git` | fixed | #F03C2E | #F03C2E | 3.77 | 3.20 | git-scm.com/community/logos (Git-Icon-1788C.svg, logo by Jason Long, CC BY 3.0) | +| `GithubIcon` | `github` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.github.com/foundations/logo | +| `GitlabIcon` | `gitlab` | mixed | #FC6D26 | #FC6D26 | 4.01 | 6.18 | https://design.gitlab.com/brand-design/color (Orange 03p/02p/01p, "colors from our core logo") | +| `GmailIcon` | `gmail` | mixed | #4285F4 | #4285F4 | 5.61 | 7.30 | gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png | +| `GoogleAiIcon` | `googleai` | fixed | #217BFE | #217BFE | 3.81 | 5.44 | Google's standard Gemini product icon (gstatic.com/images/branding/productlogos/gemini/v1/192px.svg) | +| `GoogleCalendarIcon` | — | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | the Google Calendar 2026 product icon, taken verbatim from https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg | +| `GoogleCloudIcon` | `gcloud`, `gcp_service_account` | fixed | #EA4335 | #EA4335 | 3.80 | 7.30 | Google's own product logo asset https://www.gstatic.com/images/branding/product/2x/google_cloud_64dp.png | +| `GoogleDriveIcon` | — | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg (Drive 2026 mark, copied verbatim) | +| `GoogleFormsIcon` | `gforms` | fixed | #969DFF | #969DFF | 5.99 | 12.47 | Google's own Forms product icon at www.gstatic.com/images/branding/productlogos/forms_2026/v2/web/192px.svg | +| `GoogleIcon` | `google`, `gworkspace` | mixed | #4285F4 | #4285F4 | 3.88 | 7.30 | the G mark Google serves in accounts.google.com/gsi/client | +| `GorgiasIcon` | `gorgias` | pair | #000000 | #FFF9F4 | 20.32 | 11.94 | gorgias.com/about-us/style, which ships the symbol as a "Dark"/"Light" pair | +| `GraphqlIcon` | `graphql` | pair | #E10098 | #FFFFFF | 4.37 | 12.47 | graphql.org | +| `GreipIcon` | `greip` | pair | #141C27 | #FFFFFF | 16.59 | 12.47 | docs.greip.io | +| `GristIcon` | `grist` | fixed | #16B378 | #16B378 | 2.62 | 8.25 | getgrist.com/trademark/assets/ | +| `GroqIcon` | `groqai`, `groq` | fixed | #F43E01 | #F43E01 | 3.67 | 12.47 | https://groq.com/favicon.svg | +| `HackernewsIcon` | `hackernews` | fixed | #FF6600 | #FF6600 | 2.84 | 12.47 | news.ycombinator.com/y18.svg | +| `HoldedIcon` | `holded` | fixed | #FD454D | #FD454D | 3.31 | 3.64 | cdn.holded.com/assets/img/brand/holded-logo.svg | +| `HoneybadgerIcon` | `honeybadger` | fixed | #EA5937 | #EA5937 | 3.40 | 3.55 | honeybadger.io/favicon.svg | +| `HtmlIcon` | — | fixed | #E44D26 | #E44D26 | 3.77 | 12.47 | the W3C HTML5 logo, w3.org/html/logo (downloads/HTML5_Logo.svg) | +| `HubspotIcon` | `hubspot` | pair | #FF2F00 | #FFFFFF | 3.59 | 12.47 | hubspot.com | +| `IfsIcon` | `ifs_cloud_oidc` | fixed | #72C9F8 | #72C9F8 | 6.03 | 6.79 | the IFS symbol on ifs.com | +| `IftttIcon` | `ifttt` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ifttt.com | +| `InkeepIcon` | `inkeep` | fixed | #D5E5FF | #D5E5FF | 2.45 | 9.79 | Inkeep's brand page "Icon Core" (https://inkeep.com/brand) | +| `IntercomIcon` | `intercom` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | intercom.com | +| `IpinfoIcon` | `ipinfo` | pair | #3091CF | #FFFFFF | 3.34 | 12.47 | ipinfo.io logo-positive.svg and logo-negative.svg | +| `JavaIcon` | — | fixed | #007396 | #007396 | 5.22 | 4.93 | Oracle's Java Branding and Licensing Guidelines v21 (oracle.com/a/ocom/docs/java-licensing-logo-guidelines-1908204.pdf) | +| `JavaScriptIcon` | — | fixed | #F7DF1E | #F7DF1E | 20.32 | 9.22 | js.svg in github.com/voodootikigod/logo.js, the origin of the JavaScript logo | +| `JiraIcon` | `jira` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's official Jira logo pack (atlassian.design/foundations/logos) | +| `JoomlaIcon` | `joomla` | fixed | #7AC143 | #7AC143 | 3.58 | 6.23 | the official logo at cdn.joomla.org/images/joomla-colours-logo.svg | +| `JotformIcon` | `jotform` | pair | #0A1551 | #FFFFFF | 16.40 | 12.47 | jotform.com footer logomark (#jotform-logomark-fourth is filled with --jf-logo-img: #0A1551 light, #fff dark) | +| `JsonIcon` | — | fixed | #F9A825 | #F9A825 | 1.91 | 6.33 | Material Design Yellow 800 (api.flutter.dev Colors.yellow[800]); glyph is Google's Material Symbols "data_object" | +| `JumpCloudIcon` | `jumpcloud` | pair | #002B49 | #F7F7FB | 14.09 | 11.67 | jumpcloud.com/press (Ocean Blue / White Smoke); White Smoke is the brand's own reversed logo for dark backgrounds | +| `KafkaIcon` | `kafka` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | apache/kafka | +| `KanidmIcon` | `kanidm` | fixed | #B1DEF4 | #B1DEF4 | 20.32 | 12.47 | artwork/logo-square.svg in github.com/kanidm/kanidm (full palette: #FF6600 #803300 #D45500 #2A3455 #B1B3B8 #CCCCCC) | +| `KeycloakIcon` | `keycloak` | fixed | #00B8E3 | #00B8E3 | 8.18 | 10.65 | keycloak.org's own mark, https://www.keycloak.org/resources/images/icon.svg (cyan #00B8E3/#33C6E9/#008AAA over greys #4D4D4D–#EDEDED, single theme) | +| `KlaviyoIcon` | `klaviyo` | pair | #1D1E20 | #FFFFFF | 16.14 | 12.47 | klaviyo.com --color-core-charcoal; the flag mark is the standalone logomark the site header collapses to, and the shape of klaviyo.com/icons/icon-512x512.png | +| `KoboToolboxIcon` | `kobotoolbox` | fixed | #2095F3 | #2095F3 | 3.05 | 3.95 | the kobotoolbox.org header logo and $kobo-blue in kobotoolbox/kpi jsapp/scss/colors.scss | +| `KustomerIcon` | `kustomer` | fixed | #FBEC2A | #FBEC2A | 14.08 | 12.47 | kustomer.com/images/kustomer/Kusty.svg | +| `LangfuseIcon` | `langfuse` | fixed | #FF5D5F | #FF5D5F | 2.91 | 4.47 | langfuse.com/brand "Icon - Color (SVG)", used unmodified | +| `LessIcon` | — | pair | #274F82 | #FFFFFF | 8.04 | 12.47 | github.com/less/logo (MIT) | +| `LineIcon` | `line` | fixed | #06C755 | #06C755 | 2.18 | 5.53 | LINE's official brand icon asset (line.me/en/logo) | +| `LinearIcon` | `linear` | pair | #222326 | #F4F5F8 | 15.20 | 11.44 | linear.app/brand | +| `LinkdingIcon` | — | pair | #5856E0 | #ADABF7 | 5.32 | 5.91 | sissbruecker/linkding | +| `LinkedinIcon` | `linkedin` | mixed | #0A66C2 | #0A66C2 | 5.50 | 12.47 | the official inbug SVGs embedded in brand.linkedin.com/in-logo | +| `LinodeIcon` | `linode` | fixed | #004B16 | #004B16 | 10.07 | 4.54 | Linode's own packages/manager/src/assets/logo/logo.svg in linode/manager @3e53c92, the last revision before the Akamai rebrand dropped it | +| `LumaAiIcon` | `lumaai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | lumalabs.ai (favicon-black.ico on light, favicon-white.ico on dark) | +| `MSSqlServerIcon` | — | fixed | #0094F0 | #0094F0 | 10.54 | 12.47 | learn.microsoft.com/en-us/azure/architecture/icons — Microsoft's anchor blue, a stop in its own SQL Server SVG and throughout the set's Fluent gradients | +| `MSTeamsIcon` | — | fixed | #A98AFF | #A98AFF | 12.96 | 12.47 | Microsoft's Teams-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com, served on microsoft.com/microsoft-teams); every gradient stop here is verbatim from it | +| `MagentoIcon` | `magento` | fixed | #F26322 | #F26322 | 3.09 | 3.91 | Magento's own logo asset, magento2 lib/web/images/logo.svg | +| `MailchimpIcon` | `mailchimp` | fixed | #241C15 | #241C15 | 16.23 | 10.74 | mailchimp.com/about/brand-assets | +| `MailerLiteIcon` | `mailerlite` | fixed | #09C269 | #09C269 | 2.27 | 5.31 | mailerlite.com/brand-assets | +| `MailgunIcon` | `mailgun` | fixed | #F04126 | #F04126 | 3.70 | 12.47 | mailgun.com's own logo-mailgun-icon.svg | +| `MandrillIcon` | `mandrill` | pair | #241C15 | #FFFFFF | 16.23 | 12.47 | mailchimp.com/about/brand-assets and Mandrill's own mandrillapp.com/img/navigation/freddie.svg | +| `MapboxIcon` | `mapbox` | pair | #0E1012 | #FFFFFF | 18.45 | 12.47 | mapbox.com | +| `MarkdownIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | dcurtis/markdown-mark (public domain) | +| `MastodonIcon` | `mastodon` | mixed | #6364FF | #6364FF | 7.07 | 12.47 | https://joinmastodon.org/branding | +| `MatrixIcon` | `matrix` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | matrix.org/branding | +| `MatteroomIcon` | `matteroom` | fixed | #134A81 | #134A81 | 8.74 | 12.47 | the MATTEROOM logomark vector at login.matteroom.com/images/login_logo.svg; square tile proportions taken from their own app icon at matteroom.com/favicon.ico | +| `MauticIcon` | `mautic` | pair | #4E5E9E | #FFFFFF | 5.94 | 12.47 | mautic.org/about/brand-logos-graphics (Mautic_Logo_LB.svg / Mautic_Logo_DB.svg); the "M" stays Sunglow #FDB933 in both, as the trademark policy requires the mark in its exact published form without alteration in colour | +| `McpIcon` | `mcp` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | modelcontextprotocol/modelcontextprotocol | +| `MediumIcon` | `medium` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | medium.design | +| `MeteosourceIcon` | `meteosource` | fixed | #FAD961 | #FAD961 | 2.87 | 9.01 | the logo in the meteosource.com site header (no brand page published) | +| `MezmoIcon` | `mezmo` | pair | #0A090C | #E6E6E5 | 19.22 | 9.99 | mezmo.com nav mark and docs.mezmo.com logo/light.png + logo/dark.png | +| `MicrosoftIcon` | `microsoft` | mixed | #F25022 | #F25022 | 3.88 | 7.24 | the official logo asset linked from Microsoft's logo third-party usage guidance | +| `MiroIcon` | `miro` | fixed | #FFDD33 | #FFDD33 | 16.46 | 9.29 | the Miro logo on miro.com | +| `MistralIcon` | `mistral` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | mistral.ai/favicon.svg (mid-band of the #FFAF01 -> #C4001D ramp); drawn here in currentColor, the monochrome variant mistral.ai/brand ships | +| `MixpanelIcon` | `mixpanel` | pair | #7856FF | #FFFFFF | 4.44 | 12.47 | brand.mixpanel.com/logo and /color (Purple 100); mixpanel.com ships the same pair as its light/dark favicons | +| `MollieIcon` | `mollie` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Mollie's app icon (my.mollie.com/assets/images/favicons/apple-touch-icon-180x180.png): a full-bleed disc with the lowercase m knocked out | +| `MondayIcon` | `monday` | fixed | #FB275D | #FB275D | 3.66 | 8.25 | monday.com's official logo pack (brand-monday.com/logo) | +| `MongodbIcon` | `mongodb` | pair | #00684A | #00ED64 | 6.60 | 7.90 | MongoDB brand resources and their LeafyGreen palette | +| `MotimateIcon` | `motimate` | fixed | #2DC89C | #2DC89C | 2.06 | 5.85 | motimateapp.com theme assets | +| `MqttIcon` | `mqtt` | pair | #660066 | #FFFFFF | 11.57 | 12.47 | mqtt/mqttorg-graphics | +| `Mysql` | `mysql` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | — | +| `NatsIcon` | `nats` | pair | #375C93 | #27AAE1 | 6.52 | 4.71 | cncf/artwork | +| `NeonDbIcon` | `neondb` | pair | #37C38F | #34D59A | 2.17 | 6.61 | neon.com/brand (neon-logomark-light-color.svg / neon-logomark-dark-color.svg) | +| `NetBoxIcon` | `netbox` | pair | #001423 | #FFFFFF | 18.07 | 12.47 | theme, so the second path carries its own fill- utilities | +| `NetlifyIcon` | `netlify` | pair | #05BDBA | #32E6E2 | 10.06 | 12.47 | netlify.com/brand (netlify-logo-monogram.zip, full-colour lightmode/darkmode) | +| `NetsuiteIcon` | `netsuite` | fixed | #BACCDB | #BACCDB | 7.71 | 7.57 | — | +| `NewsApiIcon` | `newsapi` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | newsapi.org | +| `NextcloudIcon` | `ocs`, `nextcloud` | pair | #0082C9 | #FFFFFF | 4.03 | 12.47 | nextcloud.com | +| `NocoDbIcon` | `nocodb` | fixed | #4351E8 | #4351E8 | 11.12 | 3.30 | nocodb.com's own Logo.svg / favicon | +| `NotionIcon` | `notion` | fixed | #FFFFFF | #FFFFFF | 20.32 | 12.47 | Notion's own app icon (notion.com/front-static/logo-ios.png) | +| `NuIcon` | — | fixed | #4D9B05 | #4D9B05 | 3.38 | 3.57 | nushell/vscode-nushell-lang assets/nu.svg | +| `OdkIcon` | `odk` | fixed | #3E77B4 | #3E77B4 | 6.37 | 2.67 | ODK brand assets (getodk.org/legal/brand/) | +| `OktaIcon` | `okta` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | okta.com | +| `OneSignalIcon` | `onesignal` | pair | #051B2C | #FFFFFF | 16.94 | 12.47 | OneSignal's official media kit (OneSignal-Logomark.svg / OneSignal-Logomark-White.svg), matching the prefers-color-scheme pair in their own onesignal.com/favicon.svg | +| `OpenRouterIcon` | `openrouter` | pair | #7624F4 | #C8FF00 | 6.10 | 10.55 | openrouter.ai/brand/v2/openrouter-glyph-{light,dark}.svg | +| `OpenWeatherIcon` | `openweather` | pair | #EA6D4A | — | 2.99 | — | openweather.co.uk/brand_guidelines | +| `OpenaiIcon` | `openai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | openai.com/brand (Blossom_Light.svg / Blossom_Dark.svg) | +| `OracleDBIcon` | `oracledb` | fixed | #C74634 | #C74634 | 4.67 | 2.59 | Oracle's own logo SVG at https://www.oracle.com/a/ocom/img/oracle-logo.svg | +| `OutreachIcon` | `outreach` | pair | #5951FF | #FFFFFF | 5.02 | 12.47 | outreach.ai | +| `PHPIcon` | — | fixed | #AEB2D5 | #AEB2D5 | 20.32 | 12.47 | php.net/images/logos/new-php-logo.svg (php.net/download-logos.php) | +| `PagerDutyIcon` | `pagerduty` | pair | #048A24 | #FFFFFF | 4.35 | 12.47 | pagerduty.com/brand "P icon" pack (P-GreenRGB.svg / P-WhiteRGB.svg) | +| `PandaDocIcon` | `pandadoc` | fixed | #248567 | #248567 | 4.39 | 12.47 | the PandaDoc logo shipped on pandadoc.com (header logo SVG and favicon); white monogram on the green tile in both themes | +| `PaychexIcon` | `paychex` | fixed | #004B8D | #004B8D | 8.50 | **1.42** | paychex.com's own logo SVG (themes/custom/paychex2/images/svg/logo-paychex.svg, .st0) | +| `PaylocityIcon` | `paylocity` | fixed | #ED2024 | #ED2024 | 4.20 | 12.47 | paylocity.com design-system CSS (.styleBGBrandGradient) | +| `PaypalIcon` | `paypal` | fixed | #002991 | #002991 | 11.77 | 6.93 | PayPal's own paypal-mark-color_new.svg (site header logo on paypal.com) | +| `PersonaIcon` | `persona` | fixed | #7379FD | #7379FD | 3.45 | 3.49 | https://withpersona.com/favicon.svg (Persona, identity verification) | +| `PersonioIcon` | `personio` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | personio.design/brand/how-we-look/logo | +| `PhraseIcon` | `phrase` | pair | #181818 | #FFFFFF | 17.18 | 12.47 | Logo_primary.svg and Logo_black_background.svg on phrase.com/brand | +| `PineconeIcon` | `pinecone` | pair | #201D1E | #FFFFFF | 16.18 | 12.47 | pinecone.io/newsroom/media-kit | +| `PinterestIcon` | `pinterest` | fixed | #E60023 | #E60023 | 4.63 | 2.61 | Pinterest Gestalt tokens (color.icon.brand.primary = red.pushpin.450, identical in sema-color-light and sema-color-dark) | +| `PipedriveIcon` | `pipedrive` | fixed | #017737 | #017737 | 5.50 | 12.47 | pipedrive.com logo token --pd-puco-global-color-green-500 | +| `PlanetScaleIcon` | `planetscale` | pair | #1A1A1A | #FAFAFA | 16.84 | 11.95 | planetscale.com | +| `PocketIdIcon` | `pocketid` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | pocket-id.org header logo (fill isDark ? #ffffff : #000000) and pocket-id/pocket-id frontend/src/lib/components/logo.svelte | +| `PostgresIcon` | `postgresql` | mixed | #336791 | #336791 | 20.32 | 12.47 | the official 3-colour Slonik SVG on wiki.postgresql.org/wiki/Logo | +| `PostmarkIcon` | `postmark` | fixed | #FFDE00 | #FFDE00 | 20.32 | 9.33 | postmarkapp.com/images/logo-stamp-simple.svg | +| `PowershellIcon` | — | fixed | #00FF18 | #00FF18 | 20.32 | 12.47 | github.com/PowerShell/PowerShell/blob/master/assets/ps_black_64.svg | +| `PusherIcon` | `pusher` | pair | #300D4F | #FFFFFF | 15.68 | 12.47 | pusher.com media kit (Pusher logo primary.png / Pusher logo secondary.png) | +| `PushoverIcon` | `pushover` | fixed | #249DF1 | #249DF1 | 2.83 | 12.47 | support.pushover.net/i63-pushover-logos-and-usage | +| `QoveryIcon` | `qovery` | fixed | #642DFF | #642DFF | 6.05 | 2.00 | qovery.com/logos/qovery-logo-black.svg | +| `QuickbooksIcon` | `quickbooks` | fixed | #2CA01C | #2CA01C | 3.30 | 3.65 | the QuickBooks logo SVG on intuit.com's press room | +| `RIcon` | — | fixed | #276DC3 | #276DC3 | 6.46 | 7.89 | r-project.org/logo (gradient stops copied from the authoritative Rlogo.svg) | +| `RaindropIcon` | `raindrop` | fixed | #1988E0 | #1988E0 | 5.33 | 12.47 | app.raindrop.io/assets/icon_raw.svg and raindrop.io icon_128.png | +| `ReactIcon` | — | pair | #087EA4 | #58C4DC | 4.48 | 6.14 | react.dev brand menu (images/brand/logo_light.svg, logo_dark.svg) | +| `ReadmeIcon` | `readme` | pair | #213AFF | #FFFFFF | 6.53 | 12.47 | readme.com's own prefers-color-scheme favicon pair (favicon-213aff.ico / favicon-ffffff.ico) | +| `ReadwiseIcon` | `readwise` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | readwise.io's logo-standalone-dark.svg (light) and logo-standalone-white.svg (dark) | +| `RecraftIcon` | `recraft` | fixed | #000000 | #000000 | 20.32 | 12.47 | Recraft's press-kit "Icon White" mark (https://www.recraft.ai/press-releases) | +| `RedditIcon` | `reddit` | fixed | #FF6600 | #FF6600 | 20.32 | 12.47 | redditinc.com/brand ("a stylized Snoo head contained within an OrangeRed (#FF4500) conversation bubble") | +| `RenderIcon` | `render` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | render.com | +| `ReplicateIcon` | `replicate` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | replicate.com header logo (glyph is currentColor; site CSS sets #000, and #FFF under .dark) | +| `ResendIcon` | `resend` | pair | #000000 | #FDFDFD | 20.32 | 12.26 | cdn.resend.com/brand/resend-icon-black.svg and resend-icon-white.svg | +| `RingCentralIcon` | `ringcentral` | pair | #FF7A00 | #FFFFFF | 2.53 | 12.47 | assets.ringcentral.com/us/brand-library/logos/ringcentral-logo.zip (RingCentral logo fullcolor.svg / RingCentral logo white.svg) | +| `RocketChatIcon` | `rocketchat` | fixed | #F5455C | #F5455C | 3.45 | 3.50 | Rocket.Chat brand colours (docs.rocket.chat/v1/docs/colors), the primary red of their logo | +| `RssIcon` | `rss` | fixed | #FFA500 | #FFA500 | 1.91 | 12.47 | Mozilla's feed icon guidelines (mozilla.org/en-US/foundation/feed-icon-guidelines/), which fix no exact hex | +| `RubyIcon` | — | fixed | #FB7655 | #FB7655 | 10.63 | 12.47 | the official logo kit at ruby-lang.org/en/about/logo | +| `RunPodIcon` | `runpod` | pair | #5D29F0 | #FFFFFF | 6.68 | 12.47 | runpod.io/brandkit | +| `RustIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | rust-lang/rust-artwork | +| `S3Icon` | `s3` | fixed | #7AA116 | #7AA116 | 2.93 | 12.47 | AWS Architecture Icons (Icon-package_07312026, Arch_Storage/64/Arch_Amazon-Simple-Storage-Service_64.svg) | +| `SageIcon` | `sage_intacct` | pair | #000000 | #00D639 | 20.32 | 6.34 | @sage/design-tokens --logo-sage-bg-default | +| `SalesflareIcon` | `salesflare` | fixed | #0053FF | #0053FF | 5.52 | 2.19 | salesflare.com's own `--color--major-blue` design token | +| `SalesforceIcon` | `salesforce` | fixed | #00B3FF | #00B3FF | 2.29 | 5.28 | brand.salesforce.com/brand/color | +| `SassIcon` | — | fixed | #CC6699 | #CC6699 | 3.43 | 12.47 | sass-lang.com's own style guide token --sl-color--hopbush (assets/dist/css/sass.css) | +| `SegmentIcon` | `segment` | fixed | #52BD94 | #52BD94 | 2.24 | 5.38 | Segment's own app favicon (app.segment.com) and Evergreen green500 #52BD95 | +| `SendflakeIcon` | `snowflake` | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SendgridIcon` | `sendgrid` | fixed | #00B3E3 | #00B3E3 | 3.81 | 8.57 | styleguide.sendgrid.com/colors.html | +| `SensorTowerIcon` | `sensortower` | fixed | #00CFB8 | #00CFB8 | 1.91 | 12.47 | sensortower.com/favicon.svg, copied verbatim | +| `SentryIcon` | `sentry` | pair | #181225 | #FFFFFF | 17.64 | 12.47 | sentry.io/branding logo generator (Dark/Light themes, "Invert in dark mode") | +| `ServiceNowIcon` | `servicenow` | fixed | #62D84E | #62D84E | 1.77 | 6.80 | servicenow.com/company/servicenow-logo.html (servicenow-logo-icon.svg) | +| `ShopifyIcon` | `shopify` | fixed | #95BF47 | #95BF47 | 3.75 | 12.47 | shopify.com/brand-assets (shopify-logo-shopping-bag-full-color.svg: #95BF47/#5E8E3E/#fff) | +| `ShortcutIcon` | `shortcut` | pair | #494BCB | #797ADE | 6.45 | 3.36 | shortcut.com/branding (mark-default.svg; the reversed lockup uses #797ADE on dark) | +| `ShutterstockIcon` | `shutterstock` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.shutterstock.com | +| `SigNozIcon` | `signoz` | fixed | #FF5E19 | #FF5E19 | 3.63 | 12.47 | signoz.io/img/SigNozLogo-orange.svg | +| `Slack` | `slack` | mixed | #E01E5A | #E01E5A | 4.51 | 6.52 | slack.com's own nav logo (a.slack-edge.com/38f0e7c/marketing/img/nav/logo.svg, linked from slack.com/media-kit) | +| `SmartsheetIcon` | `smartsheet` | pair | #031C59 | #FFFFFF | 15.46 | 12.47 | brandguides.brandfolder.com/smartsheet-visual-guide/basics | +| `SnowflakeIcon` | — | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SpeechifyIcon` | `speechify` | pair | #2F43FA | #FFFFFF | 6.15 | 12.47 | the Speechify brand kit (speechify.com/brand-kit, Logomark_blue.svg and Logomark_white.svg) | +| `SplitwiseIcon` | `splitwise` | pair | #1CC29F | — | 2.19 | — | splitwise.com/press (sw.svg / sw-wide.svg / bg-primary.svg) | +| `SpotifyIcon` | `spotify` | pair | #1ED760 | #FFFFFF | 1.86 | 12.47 | developer.spotify.com/documentation/design (2024 Primary Logo icon pack) | +| `SquareIcon` | `square` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Square_Logo_2025 in squareup.com/us/en/press/logo | +| `StraleIcon` | `strale` | pair | #0D0D0E | #F2F2F3 | 18.80 | 11.15 | strale.dev favicon.svg and the site's own --foreground token | +| `StravaIcon` | `strava` | fixed | #FC5200 | #FC5200 | 3.20 | 3.77 | developers.strava.com/guidelines (Strava API logo pack, orange SVGs) | +| `StripeIcon` | `stripe` | fixed | #533AFD | #533AFD | 5.99 | 12.47 | Stripe's own favicon.svg and Stripe_logo_kit.zip (stripe.com/newsroom/brand-assets) | +| `SupabaseIcon` | `supabase` | fixed | #3ECF8E | #3ECF8E | 3.75 | 6.25 | supabase.com/brand-assets | +| `SurrealdbIcon` | `surrealdb` | mixed | #D255FE | #D255FE | 7.33 | 5.71 | surrealdb.com/brand | +| `SvelteIcon` | — | fixed | #FF3E00 | #FF3E00 | 3.42 | 12.47 | sveltejs/branding (svelte-logo.svg, white cutout #fff) | +| `TallyIcon` | `tally` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Tally Icon - Black" / "Tally Icon - White" files in the icon pack on tally.so/help/press-kit, matching the live tally.so/favicon.svg | +| `TaskadeIcon` | `taskade` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | taskade.com/press (Mascot Mark light = agent_taskade.svg, Genesis Icon dark = taskade-icon-dark.svg) | +| `TelegramIcon` | `telegram` | fixed | #2AABEE | #2AABEE | 2.92 | 12.47 | Telegram's press-kit Logo.svg (telegram.org/press) | +| `TelnyxIcon` | `telnyx` | pair | #000000 | #00E3AA | 20.32 | 12.47 | telnyx.com | +| `TerraIcon` | `terra` | fixed | #008AFF | #008AFF | 20.32 | 10.43 | tryterra.co/providers/terra_icon.svg, the only vector square mark Terra ships (the site logo is a "TERRA API" wordmark, the favicon a raster .ico) | +| `TheirStackIcon` | `their_stack` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | theirstack.com/en/docs/brand, which lists both as core brand colours | +| `ThreadsIcon` | `threads` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Meta's Threads Brand Resource Center logo pack (meta.com/brand/resources/threads) | +| `TodoistIcon` | `todoist` | fixed | #E44232 | #E44232 | 3.97 | 3.04 | Todoist Brand Guidelines (doist.com/brand-assets/todoist-logo.zip), "Red — the primary brand color for Todoist" | +| `TogetherAiIcon` | `togetherai` | fixed | #EF2CC1 | #EF2CC1 | 3.50 | 6.46 | together.ai's brand page (https://www.together.ai/brand) | +| `TogglIcon` | `toggl` | pair | #2C1138 | #E57CD8 | 16.33 | 4.87 | Toggl Track media toolkit (toggl.com/track/media-toolkit, icon-dark-purple.svg / icon-pink.svg) | +| `TomorrowIoIcon` | `tomorrow` | fixed | #004CF8 | #004CF8 | 6.00 | 12.47 | tomorrow.io's own design tokens (--color-logo-blue in site-frame.min.css, matching the header lockup SVG and logo-490.png) | +| `TrelloIcon` | `trello` | fixed | #1558BC | #1558BC | 6.44 | 12.47 | Atlassian Design logo library (atlassian.design/foundations/logos → trello_app.zip, Trello_icon.svg) | +| `TripadvisorIcon` | `tripadvisor` | fixed | #002B11 | #002B11 | 15.01 | **1.24** | 2025 Tripadvisor Brand Guidelines for Partners, tripadvisor.mediaroom.com | +| `TursoIcon` | `turso` | pair | #183134 | #FFFFFF | 13.30 | 12.47 | turso.tech/brand (Dark Teal and white logomark variants) | +| `TwilioIcon` | `twilio` | fixed | #F22F46 | #F22F46 | 3.86 | 3.13 | twilio.com (mask-icon color, favicon and apple-touch-icon artwork) | +| `TwitchIcon` | `twitch` | fixed | #9146FF | #9146FF | 4.49 | 2.69 | brand.twitch.com | +| `TwitterIcon` | `twitter` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | about.x.com | +| `TypeformIcon` | `typeform` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | typeform.com/brand | +| `UltravoxIcon` | `ultravox` | fixed | #BB3B57 | #BB3B57 | 6.67 | 7.65 | the ultravox.ai favicon (framerusercontent.com/images/hzAEdihxJ11mv3l4trNh2WprE.svg) | +| `VectaraIcon` | `vectara` | fixed | #7E00FF | #7E00FF | 7.00 | 9.80 | — | +| `VercelIcon` | `vercel` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | vercel.com | +| `VismaIcon` | `visma` | pair | #131313 | #FFFFFF | 17.98 | 12.47 | design.visma.com/logo (VA symbol from the official Visma Logopack) | +| `VueIcon` | — | fixed | #42B883 | #42B883 | 8.97 | 5.00 | vuejs/art logo.svg | +| `WebflowIcon` | `webflow` | fixed | #146EF5 | #146EF5 | 4.44 | 2.72 | brand.webflow.com/brand-assets | +| `WhatsappBusinessIcon` | `whatsapp_business` | fixed | #25D366 | #25D366 | 1.92 | 6.29 | WhatsApp's Digital_Glyph_Green_RGB_2026.svg, shipped by whatsapp.com/business (→ whatsappbusiness.com) | +| `WizIcon` | `wiz` | pair | #0254EC | #FFFFFF | 5.84 | 12.47 | wiz.io/press media kit logo pack (WizLogo_Blue_Vector.svg / WizLogo_White_Vector.svg) | +| `WooCommerceIcon` | `woocommerce` | pair | #873EFF | #FFFFFF | 4.88 | 12.47 | the Woo logo pack at woocommerce.com/brand-and-logo-guidelines (Woo_logo_color.svg and Woo_logo_white.svg) | +| `WordpressIcon` | `wordpress` | pair | #32373C | #FFFFFF | 11.63 | 12.47 | wordpress.org/about/logos/ | +| `XataIcon` | `xata` | fixed | #8468F6 | #8468F6 | 3.84 | 3.14 | xata.io/brand (logo-symbol.svg) | +| `XeroIcon` | `xero` | fixed | #13B5EA | #13B5EA | 2.30 | 12.47 | xero.com favicon.svg and the site header logo (Xero__LogoPath fill) | +| `YamlIcon` | — | mixed | #CB171E | #CB171E | 5.52 | 5.71 | yaml.org's own assets/favicon.svg and assets/logo.png; the Y, M and L carry no fill in YAML's SVG, so they take the surrounding text colour | +| `YelpIcon` | `yelp` | fixed | #FF1A1A | #FF1A1A | 3.75 | 3.22 | yelp.com/brand (burst_red.svg and the official logo kit) | +| `YnabIcon` | `ynab` | pair | #3B5EDA | #FEF9E6 | 5.35 | 11.82 | ynab.com press kit tree logo (Tree Logo Blurple.svg / Tree Logo Buttermilk.svg — the buttermilk reverse is what ynab.com itself uses on its dark footer) | +| `YoutubeIcon` | `youtube` | fixed | #FF0033 | #FF0033 | 3.83 | 12.47 | brand.youtube/color (YouTube Red, updated from #FF0000) | +| `ZammadIcon` | `zammad` | fixed | #CD2015 | #CD2015 | 7.62 | 9.73 | zammad.com favicon-32x32.svg | +| `ZendeskIcon` | `zendesk` | pair | #11110D | #FFFFFF | 18.31 | 12.47 | zendesk.com | +| `ZeroTierIcon` | `zerotier` | fixed | #FFB25B | #FFB25B | 16.58 | 6.99 | zerotier.com's own icon.svg and logo lockups | +| `ZitadelIcon` | `zitadel` | pair | #232323 | #FFFFFF | 15.21 | 12.47 | zitadel/zitadel console assets zitadel-logo-solo-dark.svg / zitadel-logo-solo-light.svg | +| `ZixflowIcon` | `zixflow` | pair | #141414 | #FFFFFF | 17.83 | 12.47 | docs.zixflow.com logo pack (logo/light.svg / logo/dark.svg) | +| `ZohoIcon` | `zoho` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | zoho.com/branding (zoho-logo-web.svg / zoho-logo-white.svg) | +| `ZoomIcon` | `zoom` | pair | #0B5CFF | #FFFFFF | 5.09 | 12.47 | brand.zoom.com | +| `ZuploIcon` | `zuplo` | fixed | #FF00BD | #FF00BD | 3.39 | 3.56 | https://zuplo.com/brand | + +## Rules the brand imposes + +Constraints that would otherwise be broken by a well-meaning change. + +- **AblyIcon** — "Don't use other colours or gradients for the symbol." +- **AcceloIcon** — The mark keeps these three fills in both themes; only the wordmark (not drawn here) swaps #10202D for white. +- **AmqpIcon** — No brand colour: AMQP is an OASIS protocol, not a vendor, and amqp.org publishes no palette (https://www.amqp.org/legal.html). Generic glyph, deliberately monochrome — keep it on currentColor. +- **AnsibleIcon** — The mark is a solid disc knocked out with a white "A", so the pair is applied by inverting rather than currentColor: recolouring the disc alone would leave white on light grey. +- **ApifyIcon** — White/black variants are reserved for monochromatic contexts, so the tricolour mark stays in both themes. +- **AppwriteIcon** — Brand asks that the logo not be altered, so no per-theme variant. +- **ArcGisIcon** — Esri publishes no reversed variant for this badge and forbids altering its logos. +- **AsanaIcon** — Asana's guidelines forbid recolouring: the symbol always appears in coral, on light and dark backgrounds alike. +- **AssemblyAiIcon** — Two colours per theme, so the second stroke carries its own fill- utilities: #777673 on light, #FFFFFF on dark. +- **AttioIcon** — The mark is a filled compound path; stroking it instead thickens it and leaks the default black fill. +- **Auth0Icon** — Okta's content terms forbid altering the mark, so ship only these published variants. +- **AutheliaIcon** — authelia.com/reference/guides/branding permits format/layout changes only — do not alter the design. +- **AuthentikIcon** — The white variant is the brand's own asset for dark backgrounds; proportions and colour must not be altered otherwise. +- **AwsEcrIcon** — AWS ships one flat fill for both themes; the gradient tile was retired in the 2023 accessibility refresh. +- **AwsIcon** — aws.amazon.com/trademark-guidelines forbids altering the logo's colour, so only these two published variants may be used. +- **BaserowIcon** — The mark keeps these three colours on light and dark; only the wordmark reverses to white. +- **BeamerIcon** — The isotype is monochrome in all first-party artwork. +- **BigQueryIcon** — Google publishes no reversed variant, so the same mark is used on both themes. +- **BitbucketIcon** — Atlassian ships brand/neutral/inverse only: "don't use unapproved color combinations". +- **BitlyIcon** — Bitly's reversed logomark is white over orange, not over neutral dark, so the orange mark is used on both. +- **BloggerIcon** — Google publishes no reversed variant. +- **BlueskyIcon** — The downloadable media-kit butterfly still ships the older #006AFF, but the palette is the normative source: "use only the official color values above. Do not substitute, tint, or approximate." White is the approved monochrome variant for dark backgrounds. +- **BoxIcon** — box.com/legal/trademark forbids any other recolouring of the mark. +- **BrevoIcon** — Brevo publishes no per-theme variant of the app mark; the reversed "Mint" #F9FFF6 asset is the wordmark only. +- **BrowserlessIcon** — Its own prefers-color-scheme block sets black on light, white on dark. +- **BubbleIcon** — Bubble's brand terms forbid re-colouring the mark beyond its published dark/light pair. +- **BuildkiteIcon** — Buildkite ships a single mark "for any context", so there is no per-theme variant, and asks that it not be altered. +- **ButtondownIcon** — Brand forbids recolouring the logo, so no per-theme variant. +- **CSharpIcon** — Its README forbids altering the mark, so the same full-colour icon is used on light and dark. +- **CalcomIcon** — Cal.com's design system states it is deliberately a grayscale brand and publishes exactly two logo variants. +- **CalendlyIcon** — Guidelines: "Only show our logo and lockups in blue or white." +- **CertopusIcon** — Verbatim copy of the brand's own circle mark: #2C353D and the white disc are its other fixed tones, not a dark-theme variant. +- **CircleCiIcon** — Guidelines require Terminal (#161616) on light backgrounds and White on dark, and forbid any color not named in them. +- **CiscoIcon** — Cisco requires all parts of the mark be knocked out to white on dark backgrounds. +- **ClerkIcon** — Same two-tone symbol on light and dark; the mono symbol-dark/symbol-light pair is Clerk's alternate for single-colour contexts. +- **ClickhouseIcon** — Brand forbids recolouring the mark, so only its own published pair is used. +- **CloseIcon** — Close forbids modifying the logo, so the same colours are kept on light and dark. +- **CloudflareIcon** — Cloudflare's logo guidelines forbid altering the colours or filling the flare, so the flare stays knocked out on both themes. +- **CloudinaryIcon** — Cloudinary Blue is reserved for the logo; no other recolouring is permitted. +- **CockroachDbIcon** — The full-colour mark is a cyan-to-purple gradient; Cockroach Labs reduces it to solid white on dark backgrounds. +- **CoinbaseIcon** — Coinbase asks that the mark not be altered or recoloured, so only these two published variants are used. +- **ComapeoIcon** — Awana Digital publishes no reversed variant, so dark mode uses the brand's own accent blue #0066FF from CoMapeoLogo.svg (digidem/comapeo-mobile); the navy is 1.3:1 on dark surfaces. +- **ConfluenceIcon** — Atlassian requires the logo be used without modification, and its brand appearance is identical in light and dark. +- **ContentfulIcon** — Same full-colour mark on light and dark. +- **ContiguityIcon** — The `>_` glyph is knocked out to the opposite colour, so it carries its own fill- utilities. +- **ConvertKitIcon** — ConvertKit rebranded to Kit in 2024. +- **CssIcon** — Small-size variant; the only per-theme variants published are mono black/white fallbacks, so the rebeccapurple tile is kept in both themes. +- **DatadogIcon** — Datadog publishes one mark per background, the purple tile with Bits knocked out on light and the white Bits silhouette on dark, and forbids recolouring or inverting either. +- **DatoCmsIcon** — Brand kit forbids altering the logo's shape or colour. +- **DbtIcon** — Their Trademark Policy states "The dbt logo mark color cannot be altered", so this stays orange on both themes. +- **DeelIcon** — Post-rebrand the period is a square in the wordmark colour, not a blue circle. +- **DeepInfraIcon** — Their brand guidelines say "use the primary white logo on dark backgrounds", where the connector bars invert to #FFFFFF. +- **DenoIcon** — Deno publishes no hex and forbids colorizing; the black "Light (no outline)" and white "Dark (outlined)" marks are separate artworks to be swapped per background, never inverted. +- **DiscordIcon** — Discord forbids recolouring the logo, so no per-theme variant. +- **DiscourseIcon** — Only the outer bubble reverses; the five inner colours are the same in both variants. +- **DockerIcon** — Docker requires its logos appear only in its primary brand colours. +- **DropboxIcon** — Dropbox's branding terms forbid recolouring the logo, and their inverse-theme token keeps the same blue. +- **DuckDbIcon** — The pair is a full inversion, so the duck carries its own fill- utilities; the manual forbids recolouring outside these two brand hexes. +- **DustIcon** — Dust's guidelines forbid recolouring the logo. +- **DynatraceIcon** — Guidelines forbid colorizing the logo, so all six fills stay fixed in both themes. +- **EdgeDbIcon** — EdgeDB is now Gel — edgedb.com redirects to geldata.com — so this is Gel's "g" symbol, not the retired EDGE|DB wordmark. +- **EnodeIcon** — Their own favicon carries the pair in a prefers-color-scheme block. +- **ExaIcon** — Blue for standard applications, white on dark backgrounds. +- **FigmaIcon** — Figma's guidelines forbid modifying the marks, so the five-colour original is used on both themes. +- **FirebaseIcon** — Same full-colour artwork on light and dark; the guidelines forbid recolouring or redrawing the mark. +- **FoxentryIcon** — Fixed tri-tone mark, no per-theme variant: the brand ships a separate greyscale logo rather than a recoloured one. +- **FreshdeskIcon** — Freshworks publishes no reversed variant: the white glyph always sits on the green leaf. +- **FunkwhaleIcon** — Identity guidelines forbid recolouring. +- **GSheetsIcon** — Google forbids recolouring its marks, so the same full-colour artwork is used on both themes. +- **GcalIcon** — Google forbids modifying its logos, colour included, so this stays fixed with no per-theme pair. +- **GdriveIcon** — Google forbids modifying its logos "in any way, including changing the color", so this stays full-colour with no per-theme pair. +- **GiphyIcon** — Same full-colour mark on light and dark. +- **GitBookIcon** — #1C1917 is the marketing palette's dark base, not the logomark. +- **GitIcon** — A white reversed logomark exists, but git-scm.com's own dark theme exempts the mark from inversion and keeps it orange. +- **GithubIcon** — GitHub allows the Invertocat in white or black only and forbids recolouring it, so the pair is fixed here rather than inherited from the caller. +- **GmailIcon** — Google's brand guidelines forbid recolouring the mark. +- **GoogleAiIcon** — Google ships no reversed variant; the same gradient is used on light and dark. +- **GoogleCalendarIcon** — Google's trademark guidelines forbid distorting or altering a brand feature, so no per-theme recolour. +- **GoogleCloudIcon** — Google forbids recolouring its logos. +- **GoogleDriveIcon** — Google's Drive branding guide permits resizing only — no other change to the logo — so no per-theme recolour. +- **GoogleFormsIcon** — Google's brand guidelines forbid modifying or recolouring its product icons. +- **GoogleIcon** — developers.google.com/identity/branding-guidelines forbids changing the colour of the G. +- **GorgiasIcon** — The guide allows only black or white for the symbol: "Do not use gray!". +- **GristIcon** — "Keep it exactly as depicted — no recoloring, no cropping." +- **GroqIcon** — Logo use in a UI requires a license from Groq. +- **HoldedIcon** — Holded ships one flat red mark for both themes; the red-orange gradient is retired. +- **HubspotIcon** — The legacy Coral #FF7A59 is not the current logo color. +- **IfsIcon** — IFS's negative lockup reverses only the wordmark, so the symbol keeps its #8427E2-to-#72C9F8 gradient on dark. +- **IftttIcon** — IFTTT's brand guidelines state "Our wordmark may be used in solid white or black" and publish no other hex for the mark. +- **IntercomIcon** — Intercom ships the mark as fill="currentColor" bound to its nav foreground token, so it takes the colour of the surface it sits on. +- **JavaIcon** — The Coffee Cup mark is licensee-only and "you may not use a modified version of the Coffee Cup logo" — do not recolour it or flatten it to currentColor. +- **JavaScriptIcon** — Fixed mark: yellow field, black lettering, no per-theme variant. +- **JiraIcon** — The logomark is identical on light and dark; only the wordmark changes colour. +- **JoomlaIcon** — Joomla's trademark policy forbids recolouring the mark, so there is no per-theme variant. +- **JotformIcon** — The other three bars keep their fixed brand colours in both themes. +- **JsonIcon** — JSON itself has no brand owner or published colours — json.org states none — so this is a Material palette pick, not a brand colour. +- **KanidmIcon** — Kanidm's artwork is CC-BY-NC-ND — no recolouring or other derivatives. +- **KlaviyoIcon** — Klaviyo draws it in currentColor, hence the white swap on dark. +- **LangfuseIcon** — Langfuse's trademark terms forbid modifying the assets. +- **LineIcon** — LINE forbids any change to the logo's colour, so there is no reversed variant. +- **LinearIcon** — Guidelines ship a light/dark logomark pair and forbid altering the assets in any other way. +- **LinkedinIcon** — That page forbids recolouring: only the approved blue, black and white variants. +- **LinodeIcon** — The keyline path stays unfilled so it follows currentColor instead of the source's near-black #231f20. +- **LumaAiIcon** — The two faces ship at 65% opacity, which is what makes their overlap read as a cube. +- **MSSqlServerIcon** — Microsoft licenses its product icons for diagrams, docs, and training only, and forbids cropping, rotating, or reshaping them. +- **MSTeamsIcon** — Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. +- **MailchimpIcon** — Mailchimp forbids altering the files, so both official tones are painted and neither is recoloured per theme. +- **MailerLiteIcon** — Their IP guidelines forbid altering or recolouring the mark. +- **MailgunIcon** — Mailgun ships no reversed variant; the tile is identical on light and dark. +- **MandrillIcon** — On dark their rule is the reversed (white) Freddie; Cavendish Yellow #FFE01B is a background colour, never the mark. +- **MarkdownIcon** — Spec: keep the enclosure's aspect ratio and radius, keep the M/arrow/box relative sizes, and draw all three in one colour. +- **MastodonIcon** — Swap to the black or white logo rather than recolouring when contrast fails. +- **MatrixIcon** — Artwork is the Foundation's matrix-icon.svg verbatim; the trademark policy forbids altering it. +- **MediumIcon** — Guidelines mandate black or white only for both the wordmark and the icon and forbid "any other colors, gradients, or filled with images". +- **MezmoIcon** — The star stays #F4B811 in both themes. +- **MicrosoftIcon** — Microsoft forbids recolouring the symbol, so it stays full-colour on both themes. +- **MistralIcon** — Brand forbids any other recolouring. +- **MixpanelIcon** — "The Mixpanel logo is only ever used in three colors: black, white and the primary brand purple." +- **MollieIcon** — The m is the glyph from Mollie-Logo-Black-2023.svg (Mollie logo pack, mollie.com/resources), scaled and placed to match that icon pixel for pixel; the logo pack itself ships only the 320x94 wordmark. Mollie publishes black and white variants, so the pair flips for dark mode. +- **MondayIcon** — All three colours are required; the brand forbids monochrome or recoloured versions, so no dark-theme variant. +- **MongodbIcon** — MongoDB permits only four logo colours, chosen for contrast with the background, and forbids any other recolour. +- **MotimateIcon** — Motimate is a registered trademark of Motimate AS (Kahoot!). +- **Mysql** — Used under Fair Use: https://fr.wikipedia.org/wiki/Fichier:MySQL.svg +- **NeonDbIcon** — Neon forbids recolouring, so only these published variants may be used. +- **NetlifyIcon** — Two colours per theme, so the "n" carries its own fill- utilities: #014847 on light, #FFFFFF on dark. +- **NetsuiteIcon** — Pre-Oracle NetSuite "N" mark. #125580/#baccdb approximate netsuite.com's own 2014 logo art, which is itself inconsistent: /portal/common/img/ns-logo.png is #14487e/#b9c9d5 and /portal/common/img/logo-ns-mobile.png is #13527d/#b6c7d5 (both via web.archive.org/web/2014/). Not Oracle's current NetSuite mark, which is a different logo in a different palette (#264759/#36677D/#94BFCE/#E2C06B). +- **NocoDbIcon** — NocoDB publishes no reversed variant; the full-colour mark is used on light and dark alike. +- **NotionIcon** — The plate is fixed, not theme-swapped: the mark is pure black and disappears on dark backgrounds without it. +- **NuIcon** — Nushell registers that one file as both the `light` and `dark` icon, so the green is not theme-swapped. +- **OdkIcon** — ODK publishes no reversed or monochrome variant. +- **OktaIcon** — Okta's official April-2025 logo package (logos-04-2025.zip) ships the mark in Black and White only. +- **OpenWeatherIcon** — Their negative (dark-background) logo reverses only the wordmark; the symbol stays brand orange. +- **OpenaiIcon** — The guidelines state "DON'T add any colors to the Blossom" — black or white only. +- **OracleDBIcon** — Oracle reserves its logo for licensees. +- **PHPIcon** — Official logo, CC BY-SA 4.0: keep it verbatim and credit Colin Viebrock rather than recolouring. +- **PaychexIcon** — The isolated P is the square mark Paychex ships as its own 192x192 app icon. Paychex requires prior approval for any use of its marks. +- **PaypalIcon** — The third fill is the deep/bright blue overlap: a two-colour or flat fill loses it. +- **PersonioIcon** — Black on light, white on dark or coloured backgrounds. +- **PhraseIcon** — The green wedge stays #03EAB3 in both — Phrase forbids altering the logo mark colour. +- **PineconeIcon** — The mark is stroke-only, so fill must stay none. +- **PinterestIcon** — Brand guidelines: "Do not alter the logo colour." +- **PipedriveIcon** — Pipedrive's partner media kit says "do not alter, rotate, modify or animate the logo", so the mark keeps its published colours on both themes. +- **PostgresIcon** — The PostgreSQL trademark policy forbids recolouring the mark without prior approval. +- **PostmarkIcon** — Postmark publishes no reversed variant. +- **PowershellIcon** — Trademarked Microsoft logo, exempt from that repo's MIT license. The #00FF18 line below is opacity-0 in the upstream asset and paints nothing. +- **PusherIcon** — Only the colourways shown in their brand guidelines are permitted. +- **PushoverIcon** — Forbids recolouring. +- **QoveryIcon** — The mark keeps the same purple in Qovery's white lockup for dark backgrounds, so there is no reversed variant. +- **QuickbooksIcon** — Intuit forbids altering the mark. +- **RIcon** — R Foundation licenses the mark CC-BY-SA 4.0 / GPL-2 — attribution required, changes must be indicated. +- **RaindropIcon** — Full-colour mark, no reversed variant published. +- **ReadwiseIcon** — The serif R and its highlight block are knocked out to the opposite colour, so they carry their own fill- utilities. Do not restore the mix-blend-mode: multiply wrapper Readwise's dark file carries: it turns the knocked-out white to the backdrop colour on anything but a white page. +- **RecraftIcon** — The plated mark carries its own background: recraft.ai serves it to prefers-color-scheme light and dark alike — not a theme pair. +- **RedditIcon** — Reddit publishes no reversed variant; the icon must always appear in Orangered when in colour. +- **RenderIcon** — Official Render Brand Kit contains only Black and White logomark folders and the SVGs use pure black / pure white. +- **ResendIcon** — Brand guidelines forbid multi-color use or altering the mark, so these are the only two published fills. +- **RssIcon** — Never rotate or flip the mark. +- **RubyIcon** — CC BY-SA 2.5; the kit's LICENSE asks that the mark not represent anything other than the Ruby language. +- **RunPodIcon** — Brand forbids recolouring, so both values are its own published cube-icon variants. +- **RustIcon** — rust-lang.org ships only rust-logo-blk.svg (pure black). +- **S3Icon** — AWS ships no dark variant for service icons. +- **SageIcon** — Sage sets its logo black on light surfaces and Sage green only on dark ones. +- **SalesforceIcon** — Salesforce reserves the white/reversed cloud for its own blue backgrounds, so the blue mark stands in both themes. +- **SegmentIcon** — Segment ships the mark in one flat green and publishes no reversed variant. +- **SendflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SendgridIcon** — Twilio's trademark guidelines forbid recolouring the mark, so it stays multicolour in both themes. +- **ServiceNowIcon** — Trademark guidelines require the mark in the graphic form provided. +- **ShopifyIcon** — The "S" stays white regardless of background; no gradients, shadows or recolouring. +- **ShortcutIcon** — The mark "is used across various colors but never changes its visual structure." +- **ShutterstockIcon** — Brand rule: the logo is only ever black or white. +- **SigNozIcon** — SigNoz ships no reversed variant; the tile mark is used unchanged on light and dark. +- **Slack** — Fixed full-colour mark, no per-theme variant. +- **SmartsheetIcon** — Those are two of the approved logo colorways; the guide forbids any other recolouring. +- **SnowflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SpeechifyIcon** — The blue logomark is reserved for white backgrounds; every other background takes the black or white monochrome version. +- **SplitwiseIcon** — Splitwise's logos carry a single green and no reversed variant, so the same colour is used on light and dark. +- **SpotifyIcon** — Spotify permits the green icon only on black or white backgrounds and requires the white monochrome colourway on any other dark background, so the pair is fixed rather than caller-set. +- **SquareIcon** — Square ships only black and white logo files and states "Do not change the color", so no tinted variant is allowed. +- **StraleIcon** — Strale's own logo component fills with currentColor, so the mark is meant to take the theme's foreground. +- **StravaIcon** — Strava's guidelines forbid modifying or altering its logos, and the orange Echelon is the mark Strava itself uses on light and dark alike. +- **StripeIcon** — Stripe's Marks Usage Terms forbid altering the marks, so this ships verbatim in both themes rather than as a recoloured pair. +- **SupabaseIcon** — Forbids modifying or recolouring the mark. +- **SurrealdbIcon** — Same gradient mark on light and dark; monochrome variants are for subtle placements only. +- **SvelteIcon** — Its guidelines count the official colour scheme as part of the mark — do not recolour. +- **TaskadeIcon** — Brand forbids recolouring, so only those two published variants are used. +- **TelegramIcon** — The shaded-plane drawing is Telegram's retired Logo_old. +- **TerraIcon** — The outlines are part of the artwork and stay black in both themes; the blue T carries the mark on dark backgrounds. +- **TheirStackIcon** — The mark ships black-only, but the same brand rules forbid placing it on low-contrast backgrounds; on a dark surface black is 1.68:1, so it is tinted to the brand's own white. +- **ThreadsIcon** — The pack ships the mark in black and white only, so it must never be tinted. +- **TogglIcon** — Toggl requires the mark be used as is, unmodified. +- **TomorrowIoIcon** — Their stylesheet reverses only the wordmark on dark headers (path.logo-letter{fill:#fff}); the mark itself stays logo blue in both themes. +- **TrelloIcon** — Atlassian: never compose your own versions or deconstruct official assets. +- **TripadvisorIcon** — Dark backgrounds take Tripadvisor's separate outlined Ollie, never an inverted or recoloured one. +- **TwilioIcon** — Twilio reserves its corporate logo for permitted use and forbids recreating or modifying it. +- **TwitchIcon** — Trademark guidelines forbid recolouring. +- **TwitterIcon** — The component draws the X glyph, not the legacy bird. +- **TypeformIcon** — Default brand colours are "Paper (white) and Ink (black)". +- **UltravoxIcon** — Gradient mark; ultravox.ai links that same asset for both prefers-color-scheme light and dark, so there is no per-theme variant. +- **VectaraIcon** — #7E00FF → #07FEEE iridescent sweep, sampled from the logo mark on vectara.com (no brand kit is published). Vectara reserves its trademarks: do not recolour. +- **VercelIcon** — Vercel ships only light-theme (black) and dark-theme (white) triangle marks and explicitly forbids modifying or recoloring the trademarks. +- **VismaIcon** — Positive black on light, negative white on dark; the logo may not be given any other colour. +- **VueIcon** — Vue's dark-background variant is separate outlined artwork rather than a recolour, so the two-tone mark is used in both themes. +- **WebflowIcon** — Mark ships in blue, black or white only. +- **WhatsappBusinessIcon** — "You shouldn't modify any colors in our logos." +- **WooCommerceIcon** — Automattic requires the mark in its exact, most up-to-date form, so only their two published colorways are used, never a recolour. +- **WordpressIcon** — Every official logotype vector is BaseGray #32373C, shipped alongside a White/transparent version for dark backgrounds. +- **XataIcon** — Brand forbids recolouring, and the full-colour symbol is the same purple in light and dark modes. +- **XeroIcon** — Single-colour mark: white wordmark on the blue badge in both themes. +- **YamlIcon** — YAML publishes no reversed variant, and its black letters would be invisible on the dark surface. +- **YelpIcon** — Yelp forbids altering the logos and requires the ® to accompany the mark at all times. +- **YoutubeIcon** — "The triangle in the full-color red icon must always be white." +- **ZendeskIcon** — Zendesk's brand guidelines specify the logo in Licorice #11110D and Coconut #FFFFFF only and forbid unapproved color variations. +- **ZeroTierIcon** — The tile is identical on light and dark; only the wordmark inverts. +- **ZitadelIcon** — The gradient chevrons stay #FF8F00→#FE00FF in both variants. +- **ZohoIcon** — The four squares carry their own brand hexes (#E42527/#089949/#226DB4/#F9B21D) on light; Zoho's reversed lock-up is entirely white, so they invert with the wordmark on dark. +- **ZoomIcon** — The logo "may only be used in Bloom (#0B5CFF), White, or Black", with White reserved for dark backgrounds and Black requiring prior brand approval. +- **ZuploIcon** — Brand guidelines forbid recolouring the mark; pink is the official variant on both light and dark surfaces. + +## Concept icons + +Not brands. These inherit `currentColor` on purpose and must not be given a pair. + +`AgentInstructionsIcon`, `AiAgentIcon`, `ApiKeyAuthIcon`, `AssetDatabaseIcon`, `AssetDucklakeIcon`, `AssetGenericIcon`, `AssetResIcon`, `AssetS3Icon`, `BarsStaggered`, `BasicHttpAuthIcon`, `BcryptIcon`, `CACertificate`, `CustomAiIcon`, `DbIcon`, `FormInputIcon`, `FunnelCog`, `GpgKeyIcon`, `HttpIcon`, `JsonSchemaIcon`, `LdapIcon`, `Mail`, `OauthIcon`, `PaintbrushOff`, `QRCodeIcon`, `QuestionInputIcon`, `RecordIcon`, `RestIcon`, `SchedulePollIcon`, `SignatureAuthIcon`, `SparklesOffIcon`, `WebdavIcon`, `WindmillAiIcon`, `WindmillIcon`, `WindmillIcon2` + +## Coverage + +- brand icons: **314**, of which **310** carry a recorded source +- per-theme pairs applied: **136** (5 of them by inversion or a two-SVG swap, see above) +- concept icons: **34** +- effectively invisible on light: **1** (AbstractApiIcon) +- effectively invisible on dark: **2** (PaychexIcon, TripadvisorIcon) diff --git a/frontend/src/lib/components/icons/BambooHrIcon.svelte b/frontend/src/lib/components/icons/BambooHrIcon.svelte index efbeec2ece..534b48cbe6 100644 --- a/frontend/src/lib/components/icons/BambooHrIcon.svelte +++ b/frontend/src/lib/components/icons/BambooHrIcon.svelte @@ -1,12 +1,27 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte index be550a88fc..a05a84c7c9 100644 --- a/frontend/src/lib/components/icons/BaremetricsIcon.svelte +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BaserowIcon.svelte b/frontend/src/lib/components/icons/BaserowIcon.svelte new file mode 100644 index 0000000000..5d51c8cfa5 --- /dev/null +++ b/frontend/src/lib/components/icons/BaserowIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte new file mode 100644 index 0000000000..2a570cfb3e --- /dev/null +++ b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasisTheoryIcon.svelte b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte new file mode 100644 index 0000000000..6f2dfc2d4e --- /dev/null +++ b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/BcryptIcon.svelte b/frontend/src/lib/components/icons/BcryptIcon.svelte index bf9bf9b6a2..be79ba1418 100644 --- a/frontend/src/lib/components/icons/BcryptIcon.svelte +++ b/frontend/src/lib/components/icons/BcryptIcon.svelte @@ -1,15 +1,39 @@ - - - - + + + + diff --git a/frontend/src/lib/components/icons/BeamerIcon.svelte b/frontend/src/lib/components/icons/BeamerIcon.svelte new file mode 100644 index 0000000000..696eac11f4 --- /dev/null +++ b/frontend/src/lib/components/icons/BeamerIcon.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BigQueryIcon.svelte b/frontend/src/lib/components/icons/BigQueryIcon.svelte index 10d099f75d..43958d302e 100644 --- a/frontend/src/lib/components/icons/BigQueryIcon.svelte +++ b/frontend/src/lib/components/icons/BigQueryIcon.svelte @@ -1,42 +1,36 @@ + -Icon_24px_BigQuery_Color + + + + + + + + diff --git a/frontend/src/lib/components/icons/BitbucketIcon.svelte b/frontend/src/lib/components/icons/BitbucketIcon.svelte index 9b33e6bb10..ca368edc70 100644 --- a/frontend/src/lib/components/icons/BitbucketIcon.svelte +++ b/frontend/src/lib/components/icons/BitbucketIcon.svelte @@ -1,24 +1,23 @@ - - - - - - - - Bitbucket-blue - - - - - - - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte index 3cc3b43122..a023981cec 100644 --- a/frontend/src/lib/components/icons/BitlyIcon.svelte +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -1,12 +1,21 @@ + - - + + diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte index ab1d2d262f..13ec49a1c0 100644 --- a/frontend/src/lib/components/icons/BloggerIcon.svelte +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/BlueskyIcon.svelte b/frontend/src/lib/components/icons/BlueskyIcon.svelte index 1f8545e325..68dea0f3d2 100644 --- a/frontend/src/lib/components/icons/BlueskyIcon.svelte +++ b/frontend/src/lib/components/icons/BlueskyIcon.svelte @@ -1,12 +1,25 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BotifyIcon.svelte b/frontend/src/lib/components/icons/BotifyIcon.svelte new file mode 100644 index 0000000000..375e128ac4 --- /dev/null +++ b/frontend/src/lib/components/icons/BotifyIcon.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/BoxIcon.svelte b/frontend/src/lib/components/icons/BoxIcon.svelte index a6a5d5dc07..980d8f5757 100644 --- a/frontend/src/lib/components/icons/BoxIcon.svelte +++ b/frontend/src/lib/components/icons/BoxIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrandLetterIcon.svelte b/frontend/src/lib/components/icons/BrandLetterIcon.svelte new file mode 100644 index 0000000000..a9e9fa2966 --- /dev/null +++ b/frontend/src/lib/components/icons/BrandLetterIcon.svelte @@ -0,0 +1,58 @@ + + + + + + {letter} + diff --git a/frontend/src/lib/components/icons/BrevoIcon.svelte b/frontend/src/lib/components/icons/BrevoIcon.svelte index 2907c346f7..971fb5d9f3 100644 --- a/frontend/src/lib/components/icons/BrevoIcon.svelte +++ b/frontend/src/lib/components/icons/BrevoIcon.svelte @@ -1,12 +1,20 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/BrexIcon.svelte b/frontend/src/lib/components/icons/BrexIcon.svelte index 5f9fbf8de1..526b2fe536 100644 --- a/frontend/src/lib/components/icons/BrexIcon.svelte +++ b/frontend/src/lib/components/icons/BrexIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte index 768d51145c..9dcf785adf 100644 --- a/frontend/src/lib/components/icons/BrowserlessIcon.svelte +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BubbleIcon.svelte b/frontend/src/lib/components/icons/BubbleIcon.svelte index fdfb049e70..15277d5436 100644 --- a/frontend/src/lib/components/icons/BubbleIcon.svelte +++ b/frontend/src/lib/components/icons/BubbleIcon.svelte @@ -1,16 +1,24 @@ + - - - - - + + + + diff --git a/frontend/src/lib/components/icons/BuildkiteIcon.svelte b/frontend/src/lib/components/icons/BuildkiteIcon.svelte index 282d782e31..e510c61449 100644 --- a/frontend/src/lib/components/icons/BuildkiteIcon.svelte +++ b/frontend/src/lib/components/icons/BuildkiteIcon.svelte @@ -1,12 +1,16 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BunIcon.svelte b/frontend/src/lib/components/icons/BunIcon.svelte index ef845d4834..792ad10b3c 100644 --- a/frontend/src/lib/components/icons/BunIcon.svelte +++ b/frontend/src/lib/components/icons/BunIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + diff --git a/frontend/src/lib/components/icons/CACertificate.svelte b/frontend/src/lib/components/icons/CACertificate.svelte index c73aa5d3ca..d6a6a6cdde 100644 --- a/frontend/src/lib/components/icons/CACertificate.svelte +++ b/frontend/src/lib/components/icons/CACertificate.svelte @@ -1,12 +1,17 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/icons/CSharpIcon.svelte b/frontend/src/lib/components/icons/CSharpIcon.svelte index 703c692036..c9c06d77cb 100644 --- a/frontend/src/lib/components/icons/CSharpIcon.svelte +++ b/frontend/src/lib/components/icons/CSharpIcon.svelte @@ -1,18 +1,41 @@ - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CalcomIcon.svelte b/frontend/src/lib/components/icons/CalcomIcon.svelte index 382b8e7a56..5dea23818d 100644 --- a/frontend/src/lib/components/icons/CalcomIcon.svelte +++ b/frontend/src/lib/components/icons/CalcomIcon.svelte @@ -1,13 +1,16 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() - - + + + diff --git a/frontend/src/lib/components/icons/CampaynIcon.svelte b/frontend/src/lib/components/icons/CampaynIcon.svelte new file mode 100644 index 0000000000..c343cb186f --- /dev/null +++ b/frontend/src/lib/components/icons/CampaynIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CertopusIcon.svelte b/frontend/src/lib/components/icons/CertopusIcon.svelte new file mode 100644 index 0000000000..227528bae2 --- /dev/null +++ b/frontend/src/lib/components/icons/CertopusIcon.svelte @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ChromaIcon.svelte b/frontend/src/lib/components/icons/ChromaIcon.svelte new file mode 100644 index 0000000000..b42a6b58e4 --- /dev/null +++ b/frontend/src/lib/components/icons/ChromaIcon.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte index 92402ac6cd..59c97c4e37 100644 --- a/frontend/src/lib/components/icons/CircleCiIcon.svelte +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte index d2d4b14bd8..aa3ed22d0f 100644 --- a/frontend/src/lib/components/icons/CiscoIcon.svelte +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/ClaudeIcon.svelte b/frontend/src/lib/components/icons/ClaudeIcon.svelte index 5c5cdeffe5..44ec5723c2 100644 --- a/frontend/src/lib/components/icons/ClaudeIcon.svelte +++ b/frontend/src/lib/components/icons/ClaudeIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + - + diff --git a/frontend/src/lib/components/icons/ClerkIcon.svelte b/frontend/src/lib/components/icons/ClerkIcon.svelte index 21baeae38d..9649794578 100644 --- a/frontend/src/lib/components/icons/ClerkIcon.svelte +++ b/frontend/src/lib/components/icons/ClerkIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/ClickhouseIcon.svelte b/frontend/src/lib/components/icons/ClickhouseIcon.svelte index 6d3870762a..a1ea211f36 100644 --- a/frontend/src/lib/components/icons/ClickhouseIcon.svelte +++ b/frontend/src/lib/components/icons/ClickhouseIcon.svelte @@ -1,27 +1,36 @@ + - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/ClickupIcon.svelte b/frontend/src/lib/components/icons/ClickupIcon.svelte index 22e160931a..e84ddc0633 100644 --- a/frontend/src/lib/components/icons/ClickupIcon.svelte +++ b/frontend/src/lib/components/icons/ClickupIcon.svelte @@ -1,10 +1,11 @@ + - - + + - + diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte index 73e6f414f9..e61b7ada6c 100644 --- a/frontend/src/lib/components/icons/CloseIcon.svelte +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -1,42 +1,37 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/frontend/src/lib/components/icons/CloudflareIcon.svelte b/frontend/src/lib/components/icons/CloudflareIcon.svelte index 6ade506d8a..7fed848d2d 100644 --- a/frontend/src/lib/components/icons/CloudflareIcon.svelte +++ b/frontend/src/lib/components/icons/CloudflareIcon.svelte @@ -1,12 +1,13 @@ + - diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte index a61f3bbe7c..9dc633dccd 100644 --- a/frontend/src/lib/components/icons/CloudinaryIcon.svelte +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CockroachDbIcon.svelte b/frontend/src/lib/components/icons/CockroachDbIcon.svelte index d88f7c52de..2953c152f5 100644 --- a/frontend/src/lib/components/icons/CockroachDbIcon.svelte +++ b/frontend/src/lib/components/icons/CockroachDbIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte index d8486224d9..9a58fb8998 100644 --- a/frontend/src/lib/components/icons/CodaIcon.svelte +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/CodatIcon.svelte b/frontend/src/lib/components/icons/CodatIcon.svelte new file mode 100644 index 0000000000..732a4a878e --- /dev/null +++ b/frontend/src/lib/components/icons/CodatIcon.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CohereIcon.svelte b/frontend/src/lib/components/icons/CohereIcon.svelte index c0a4aea8ee..dba01ef6ed 100644 --- a/frontend/src/lib/components/icons/CohereIcon.svelte +++ b/frontend/src/lib/components/icons/CohereIcon.svelte @@ -1,21 +1,35 @@ + - - - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte index 770b60f40a..67ff2998c8 100644 --- a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte +++ b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte index b58a45fff0..5d61e83df7 100644 --- a/frontend/src/lib/components/icons/CoinbaseIcon.svelte +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -1,12 +1,23 @@ + - - + + diff --git a/frontend/src/lib/components/icons/ComapeoIcon.svelte b/frontend/src/lib/components/icons/ComapeoIcon.svelte new file mode 100644 index 0000000000..062c45e087 --- /dev/null +++ b/frontend/src/lib/components/icons/ComapeoIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte index 60dbcef880..a892e912f2 100644 --- a/frontend/src/lib/components/icons/ConfluenceIcon.svelte +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -1,12 +1,17 @@ + - - + + + diff --git a/frontend/src/lib/components/icons/ContentfulIcon.svelte b/frontend/src/lib/components/icons/ContentfulIcon.svelte index 2af55d48fc..38f466d92d 100644 --- a/frontend/src/lib/components/icons/ContentfulIcon.svelte +++ b/frontend/src/lib/components/icons/ContentfulIcon.svelte @@ -1,12 +1,32 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/ContiguityIcon.svelte b/frontend/src/lib/components/icons/ContiguityIcon.svelte new file mode 100644 index 0000000000..30de7f9c13 --- /dev/null +++ b/frontend/src/lib/components/icons/ContiguityIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte index 2fbcf0e53f..0506f62f43 100644 --- a/frontend/src/lib/components/icons/ConvertKitIcon.svelte +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -1,12 +1,24 @@ - - + + + + + diff --git a/frontend/src/lib/components/icons/CoupaIcon.svelte b/frontend/src/lib/components/icons/CoupaIcon.svelte index 4d4058fdac..a8c780bdbc 100644 --- a/frontend/src/lib/components/icons/CoupaIcon.svelte +++ b/frontend/src/lib/components/icons/CoupaIcon.svelte @@ -1,19 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/CustomAiIcon.svelte b/frontend/src/lib/components/icons/CustomAiIcon.svelte new file mode 100644 index 0000000000..a646753b7c --- /dev/null +++ b/frontend/src/lib/components/icons/CustomAiIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/DatabricksIcon.svelte b/frontend/src/lib/components/icons/DatabricksIcon.svelte index e767337442..e06471cdd4 100644 --- a/frontend/src/lib/components/icons/DatabricksIcon.svelte +++ b/frontend/src/lib/components/icons/DatabricksIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/DatoCmsIcon.svelte b/frontend/src/lib/components/icons/DatoCmsIcon.svelte index d462832e17..e2e4b2be0e 100644 --- a/frontend/src/lib/components/icons/DatoCmsIcon.svelte +++ b/frontend/src/lib/components/icons/DatoCmsIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DbIcon.svelte b/frontend/src/lib/components/icons/DbIcon.svelte index fe74ca9adb..44227ad43a 100644 --- a/frontend/src/lib/components/icons/DbIcon.svelte +++ b/frontend/src/lib/components/icons/DbIcon.svelte @@ -1,17 +1,17 @@ diff --git a/frontend/src/lib/components/icons/DbtIcon.svelte b/frontend/src/lib/components/icons/DbtIcon.svelte index b647cb215e..a90f82b90f 100644 --- a/frontend/src/lib/components/icons/DbtIcon.svelte +++ b/frontend/src/lib/components/icons/DbtIcon.svelte @@ -7,21 +7,18 @@ let { height = 24, width = 24 }: Props = $props() + - - - - - - - - + diff --git a/frontend/src/lib/components/icons/DeelIcon.svelte b/frontend/src/lib/components/icons/DeelIcon.svelte index 0b93a4a98f..e36b64fe3f 100644 --- a/frontend/src/lib/components/icons/DeelIcon.svelte +++ b/frontend/src/lib/components/icons/DeelIcon.svelte @@ -1,12 +1,25 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/DeepInfraIcon.svelte b/frontend/src/lib/components/icons/DeepInfraIcon.svelte new file mode 100644 index 0000000000..3750b5ff42 --- /dev/null +++ b/frontend/src/lib/components/icons/DeepInfraIcon.svelte @@ -0,0 +1,27 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/DeepLIcon.svelte b/frontend/src/lib/components/icons/DeepLIcon.svelte index 028b061dc0..58b2a14833 100644 --- a/frontend/src/lib/components/icons/DeepLIcon.svelte +++ b/frontend/src/lib/components/icons/DeepLIcon.svelte @@ -1,12 +1,40 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DeepSeekIcon.svelte b/frontend/src/lib/components/icons/DeepSeekIcon.svelte index d0ba0c6423..560c5704bb 100644 --- a/frontend/src/lib/components/icons/DeepSeekIcon.svelte +++ b/frontend/src/lib/components/icons/DeepSeekIcon.svelte @@ -7,7 +7,15 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/DenoIcon.svelte b/frontend/src/lib/components/icons/DenoIcon.svelte index 86dca42410..776144b34f 100644 --- a/frontend/src/lib/components/icons/DenoIcon.svelte +++ b/frontend/src/lib/components/icons/DenoIcon.svelte @@ -1,41 +1,44 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte index 206e395175..6daeb458d0 100644 --- a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte +++ b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DiscordIcon.svelte b/frontend/src/lib/components/icons/DiscordIcon.svelte index 384008b773..ca92f30ed4 100644 --- a/frontend/src/lib/components/icons/DiscordIcon.svelte +++ b/frontend/src/lib/components/icons/DiscordIcon.svelte @@ -1,21 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/DiscourseIcon.svelte b/frontend/src/lib/components/icons/DiscourseIcon.svelte index fd1e53f9a3..dd590cee4a 100644 --- a/frontend/src/lib/components/icons/DiscourseIcon.svelte +++ b/frontend/src/lib/components/icons/DiscourseIcon.svelte @@ -1,12 +1,38 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DocSpringIcon.svelte b/frontend/src/lib/components/icons/DocSpringIcon.svelte new file mode 100644 index 0000000000..f67ba12c70 --- /dev/null +++ b/frontend/src/lib/components/icons/DocSpringIcon.svelte @@ -0,0 +1,55 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DockerIcon.svelte b/frontend/src/lib/components/icons/DockerIcon.svelte index d7ab3f498c..f5a5ac11a5 100644 --- a/frontend/src/lib/components/icons/DockerIcon.svelte +++ b/frontend/src/lib/components/icons/DockerIcon.svelte @@ -1,27 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/DocusignIcon.svelte b/frontend/src/lib/components/icons/DocusignIcon.svelte index 91d96bac36..5fe8c42f82 100644 --- a/frontend/src/lib/components/icons/DocusignIcon.svelte +++ b/frontend/src/lib/components/icons/DocusignIcon.svelte @@ -1,12 +1,30 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DropboxIcon.svelte b/frontend/src/lib/components/icons/DropboxIcon.svelte index 3facd1dda2..51821fdd3e 100644 --- a/frontend/src/lib/components/icons/DropboxIcon.svelte +++ b/frontend/src/lib/components/icons/DropboxIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DuckDbIcon.svelte b/frontend/src/lib/components/icons/DuckDbIcon.svelte index 1fa2e55b2c..793827c981 100644 --- a/frontend/src/lib/components/icons/DuckDbIcon.svelte +++ b/frontend/src/lib/components/icons/DuckDbIcon.svelte @@ -1,23 +1,32 @@ - + + diff --git a/frontend/src/lib/components/icons/DucklakeIcon.svelte b/frontend/src/lib/components/icons/DucklakeIcon.svelte index 488260ea1a..5665734ff5 100644 --- a/frontend/src/lib/components/icons/DucklakeIcon.svelte +++ b/frontend/src/lib/components/icons/DucklakeIcon.svelte @@ -1,4 +1,5 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DynatraceIcon.svelte b/frontend/src/lib/components/icons/DynatraceIcon.svelte index e8d57c58ce..df0ad401a4 100644 --- a/frontend/src/lib/components/icons/DynatraceIcon.svelte +++ b/frontend/src/lib/components/icons/DynatraceIcon.svelte @@ -1,31 +1,53 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/EdgeDbIcon.svelte b/frontend/src/lib/components/icons/EdgeDbIcon.svelte index cc31f328d4..19cb62e2b3 100644 --- a/frontend/src/lib/components/icons/EdgeDbIcon.svelte +++ b/frontend/src/lib/components/icons/EdgeDbIcon.svelte @@ -1,3 +1,6 @@ + - + - diff --git a/frontend/src/lib/components/icons/EnodeIcon.svelte b/frontend/src/lib/components/icons/EnodeIcon.svelte new file mode 100644 index 0000000000..91a5f59094 --- /dev/null +++ b/frontend/src/lib/components/icons/EnodeIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/EventbriteIcon.svelte b/frontend/src/lib/components/icons/EventbriteIcon.svelte index f851a933f3..c98db1071e 100644 --- a/frontend/src/lib/components/icons/EventbriteIcon.svelte +++ b/frontend/src/lib/components/icons/EventbriteIcon.svelte @@ -1,15 +1,16 @@ - - - - - + + + diff --git a/frontend/src/lib/components/icons/ExaIcon.svelte b/frontend/src/lib/components/icons/ExaIcon.svelte new file mode 100644 index 0000000000..eef3057e92 --- /dev/null +++ b/frontend/src/lib/components/icons/ExaIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/FaunadbIcon.svelte b/frontend/src/lib/components/icons/FaunadbIcon.svelte index 62c4dde4f2..7bb3f371e3 100644 --- a/frontend/src/lib/components/icons/FaunadbIcon.svelte +++ b/frontend/src/lib/components/icons/FaunadbIcon.svelte @@ -1,13 +1,15 @@ + - \ No newline at end of file diff --git a/frontend/src/lib/components/icons/FigmaIcon.svelte b/frontend/src/lib/components/icons/FigmaIcon.svelte index e31fe0a71d..bc39256ab2 100644 --- a/frontend/src/lib/components/icons/FigmaIcon.svelte +++ b/frontend/src/lib/components/icons/FigmaIcon.svelte @@ -1,12 +1,20 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/FirebaseIcon.svelte b/frontend/src/lib/components/icons/FirebaseIcon.svelte index 6d1ce00468..fd85fc72d2 100644 --- a/frontend/src/lib/components/icons/FirebaseIcon.svelte +++ b/frontend/src/lib/components/icons/FirebaseIcon.svelte @@ -1,10 +1,11 @@ + - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/FlyIcon.svelte b/frontend/src/lib/components/icons/FlyIcon.svelte index 02118212b2..d48b62556e 100644 --- a/frontend/src/lib/components/icons/FlyIcon.svelte +++ b/frontend/src/lib/components/icons/FlyIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/FormInputIcon.svelte b/frontend/src/lib/components/icons/FormInputIcon.svelte new file mode 100644 index 0000000000..1f1dcbc242 --- /dev/null +++ b/frontend/src/lib/components/icons/FormInputIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/icons/FormstackIcon.svelte b/frontend/src/lib/components/icons/FormstackIcon.svelte new file mode 100644 index 0000000000..e0da265e6a --- /dev/null +++ b/frontend/src/lib/components/icons/FormstackIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FoxentryIcon.svelte b/frontend/src/lib/components/icons/FoxentryIcon.svelte new file mode 100644 index 0000000000..88f6b71054 --- /dev/null +++ b/frontend/src/lib/components/icons/FoxentryIcon.svelte @@ -0,0 +1,20 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FreshdeskIcon.svelte b/frontend/src/lib/components/icons/FreshdeskIcon.svelte index 72f2d7b1fa..bbeb87d668 100644 --- a/frontend/src/lib/components/icons/FreshdeskIcon.svelte +++ b/frontend/src/lib/components/icons/FreshdeskIcon.svelte @@ -1,12 +1,19 @@ + - + diff --git a/frontend/src/lib/components/icons/FrontAppIcon.svelte b/frontend/src/lib/components/icons/FrontAppIcon.svelte index dfba63c8b7..aba4bccae4 100644 --- a/frontend/src/lib/components/icons/FrontAppIcon.svelte +++ b/frontend/src/lib/components/icons/FrontAppIcon.svelte @@ -1,12 +1,16 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte index 45a1821beb..0fb36804f6 100644 --- a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte +++ b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte @@ -1,73 +1,45 @@ + image/svg+xml + - - - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/GCloudIcon.svelte b/frontend/src/lib/components/icons/GCloudIcon.svelte deleted file mode 100644 index 6fba8faa05..0000000000 --- a/frontend/src/lib/components/icons/GCloudIcon.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/lib/components/icons/GSheetsIcon.svelte b/frontend/src/lib/components/icons/GSheetsIcon.svelte index 419f9d1b26..b23010fd90 100644 --- a/frontend/src/lib/components/icons/GSheetsIcon.svelte +++ b/frontend/src/lib/components/icons/GSheetsIcon.svelte @@ -1,21 +1,69 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GcalIcon.svelte b/frontend/src/lib/components/icons/GcalIcon.svelte index 6b8f90b086..3a46c28c3e 100644 --- a/frontend/src/lib/components/icons/GcalIcon.svelte +++ b/frontend/src/lib/components/icons/GcalIcon.svelte @@ -1,21 +1,103 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdocsIcon.svelte b/frontend/src/lib/components/icons/GdocsIcon.svelte index 2e202cc2c8..c25121c2d3 100644 --- a/frontend/src/lib/components/icons/GdocsIcon.svelte +++ b/frontend/src/lib/components/icons/GdocsIcon.svelte @@ -1,14 +1,77 @@ - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdriveIcon.svelte b/frontend/src/lib/components/icons/GdriveIcon.svelte index eff87a3645..70c5466cc0 100644 --- a/frontend/src/lib/components/icons/GdriveIcon.svelte +++ b/frontend/src/lib/components/icons/GdriveIcon.svelte @@ -1,21 +1,71 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GhostCmsIcon.svelte b/frontend/src/lib/components/icons/GhostCmsIcon.svelte index 018960d9b7..fcebf9f38f 100644 --- a/frontend/src/lib/components/icons/GhostCmsIcon.svelte +++ b/frontend/src/lib/components/icons/GhostCmsIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GiphyIcon.svelte b/frontend/src/lib/components/icons/GiphyIcon.svelte index 521a358840..fc075eeac5 100644 --- a/frontend/src/lib/components/icons/GiphyIcon.svelte +++ b/frontend/src/lib/components/icons/GiphyIcon.svelte @@ -1,12 +1,17 @@ - - + + + + + + + diff --git a/frontend/src/lib/components/icons/GitBookIcon.svelte b/frontend/src/lib/components/icons/GitBookIcon.svelte index b310c868f7..c3d1c1cf52 100644 --- a/frontend/src/lib/components/icons/GitBookIcon.svelte +++ b/frontend/src/lib/components/icons/GitBookIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GitIcon.svelte b/frontend/src/lib/components/icons/GitIcon.svelte index 08f7631a91..c6dd709daa 100644 --- a/frontend/src/lib/components/icons/GitIcon.svelte +++ b/frontend/src/lib/components/icons/GitIcon.svelte @@ -1,3 +1,4 @@ + diff --git a/frontend/src/lib/components/icons/GithubIcon.svelte b/frontend/src/lib/components/icons/GithubIcon.svelte index 0b47c0d281..0bb14688cd 100644 --- a/frontend/src/lib/components/icons/GithubIcon.svelte +++ b/frontend/src/lib/components/icons/GithubIcon.svelte @@ -1,9 +1,10 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte new file mode 100644 index 0000000000..44bcd61411 --- /dev/null +++ b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/GmailIcon.svelte b/frontend/src/lib/components/icons/GmailIcon.svelte index 2a3281adef..cbcad5a889 100644 --- a/frontend/src/lib/components/icons/GmailIcon.svelte +++ b/frontend/src/lib/components/icons/GmailIcon.svelte @@ -1,21 +1,19 @@ - + + + + diff --git a/frontend/src/lib/components/icons/GoogleAiIcon.svelte b/frontend/src/lib/components/icons/GoogleAiIcon.svelte index af0e153f7b..9cae17abd8 100644 --- a/frontend/src/lib/components/icons/GoogleAiIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleAiIcon.svelte @@ -7,19 +7,21 @@ let { height = '24px', width = '24px' }: Props = $props() + - - - + + + + - - + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte index 5fb4e21797..61455feb1d 100644 --- a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte @@ -1,14 +1,20 @@ + diff --git a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte index 0cccffa372..943f4c2cb0 100644 --- a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte @@ -7,29 +7,68 @@ let { height = '24px', width = '24px' }: Props = $props() - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte index de25179dc4..3c43dab1f9 100644 --- a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte @@ -1,31 +1,64 @@ + - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleIcon.svelte b/frontend/src/lib/components/icons/GoogleIcon.svelte index bd53eb1bab..da75fb7a3b 100644 --- a/frontend/src/lib/components/icons/GoogleIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleIcon.svelte @@ -1,36 +1,37 @@ + - - - - + diff --git a/frontend/src/lib/components/icons/GorgiasIcon.svelte b/frontend/src/lib/components/icons/GorgiasIcon.svelte new file mode 100644 index 0000000000..290ca62806 --- /dev/null +++ b/frontend/src/lib/components/icons/GorgiasIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/GpgKeyIcon.svelte b/frontend/src/lib/components/icons/GpgKeyIcon.svelte new file mode 100644 index 0000000000..86d11487ae --- /dev/null +++ b/frontend/src/lib/components/icons/GpgKeyIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GraphqlIcon.svelte b/frontend/src/lib/components/icons/GraphqlIcon.svelte index 292bdbad20..245cdc7b37 100644 --- a/frontend/src/lib/components/icons/GraphqlIcon.svelte +++ b/frontend/src/lib/components/icons/GraphqlIcon.svelte @@ -1,13 +1,15 @@ +
-

+ {#snippet titleExtra()} + + {/snippet} +
{#if resourceTypeViewerObj.description} -
- -
+ {/if} {#if resourceTypeViewerObj.isFileset} @@ -1147,9 +1165,14 @@ - - {removeMarkdown(truncate(description ?? '', 30))} - +
+ + {removeMarkdown(truncate(description ?? '', 200))} + +
@@ -1367,19 +1390,31 @@ - - {removeMarkdown(truncate(description ?? '', 200))} - + +
+ + {removeMarkdown(truncate(description ?? '', 200))} + +
- + {#if !canWrite} - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - + +
+ + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + +
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
+ + {#if $copilotWorkspace} + + {/if} {/if}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index e08b9ba2a3..f68501b293 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -58,7 +58,7 @@ import { import { dfs } from '$lib/components/flows/previousResults' import { SvelteMap, SvelteSet } from 'svelte/reactivity' import { createLongHash } from '$lib/editorLangUtils' -import type { UserDraftItemKind } from '$lib/gen' +import type { AIProvider, UserDraftItemKind } from '$lib/gen' import { maskKey } from '$lib/components/sessions/modifiedItemsMask' import { getStringError } from './utils' import { type PasteAttachment } from './pasteTokens' @@ -101,7 +101,12 @@ import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' import { runChatLoop, truncateToToolPairedPrefix } from './chatLoop' import { sanitizeToolCallArguments } from './toolCallArguments' -import { normalizeContextUsage } from './tokenUsage' +import { + billedTokens, + normalizeContextUsage, + type ChatTokenUsage +} from './tokenUsage' +import { logAiUsage } from '$lib/utils/aiUsageReporter' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, @@ -709,6 +714,39 @@ export class AIChatManager { await this.#persistModifiedItems() } + /** Report one completed provider response's tokens to the workspace usage view. + * Called per response rather than per turn: a tool loop makes several, each + * separately billed, and a turn that fails partway through has still spent + * everything up to that point. + * + * Only token counts leave the browser — rates are applied when the usage is + * read, so a corrected price also corrects everything already recorded. */ + private recordUsage( + usage: ChatTokenUsage, + provider: AIProvider, + model: string, + workspace: string | undefined + ) { + // A provider that reports no usage still yields an all-zero report. Recording + // it would add a $0 row to the usage view, claiming the request cost nothing + // rather than that it went uncounted. + if (usage.total === 0 && usage.prompt === 0 && usage.completion === 0) { + return + } + const tokens = billedTokens(usage) + logAiUsage({ + provider, + model, + sessionId: this.sessionId, + inputTokens: tokens.input, + cacheReadTokens: tokens.cacheRead, + cacheWriteTokens: tokens.cacheWrite, + outputTokens: tokens.output, + costUsd: usage.cost, + workspace + }) + } + // Serialized, snapshot-at-write-time persistence: two rapid dock actions // would otherwise race their saveChat writes, and the earlier (staler) // snapshot could land last — dropping the later mutation until the next @@ -2458,6 +2496,11 @@ export class AIChatManager { // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. const self = this + // Pinned for the whole turn, like the `workspace` the loop routes through: + // the global chat's operating workspace follows workspaceStore, so a switch + // while a response streams would bill it to the workspace the user landed + // on rather than the one whose credentials and proxy served it. + const usageWorkspace = this.operatingWorkspace const result = await runChatLoop({ messages, addedMessages, @@ -2535,6 +2578,14 @@ export class AIChatManager { } return undefined }, + onUsage: (usage, modelProvider) => { + // Accounting must never take a turn down with it. + try { + this.recordUsage(usage, modelProvider.provider, modelProvider.model, usageWorkspace) + } catch (e) { + console.error('Failed to record AI usage', e) + } + }, onBeforeIteration: async (tools, _helpers, modelProvider) => { this.lastIterationModel = modelProvider for (const tool of tools) { diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index a307fad255..ffa06ca5df 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -4,6 +4,7 @@ import { getAiChatManager } from './aiChatManagerContext' import { AIMode } from './AIChatManager.svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' + import { formatTokenCount } from './tokenUsage' const aiChatManager = getAiChatManager() @@ -45,16 +46,6 @@ ? 'bg-amber-500' : 'bg-surface-accent-primary' ) - - function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` - } - if (tokens >= 1000) { - return `${Math.round(tokens / 1000)}k` - } - return `${tokens}` - } {#if visible} diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 52074a84c9..8d38f10916 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -195,7 +195,7 @@ export async function parseAnthropicCompletion( tools: Tool[], helpers: any, abortController?: AbortController, - options?: { workspace?: string } + options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -417,6 +417,7 @@ export async function parseAnthropicCompletion( const finalMessage = await completion.finalMessage() const tokenUsage = anthropicUsageToChatTokenUsage(finalMessage.usage) + options?.onTokenUsage?.(tokenUsage) // Process tool calls if any if (toolCallsToProcess.length > 0) { diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 2c4eb466e9..a010f1369a 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -518,7 +518,7 @@ describe('runChatLoop lastIterationUsage', () => { expect(result.lastIterationUsage).toEqual({ prompt: 1200, completion: 80, total: 1280 }) // the aggregate keeps summing across iterations - expect(result.tokenUsage).toEqual({ prompt: 2200, completion: 130, total: 2330 }) + expect(result.tokenUsage).toMatchObject({ prompt: 2200, completion: 130, total: 2330 }) }) it('ignores empty usage reports and returns null when none are real', async () => { diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index cc963e4b25..91dd3ab29c 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -77,11 +77,16 @@ export interface ChatLoopConfig { helpers: any, modelProvider: ReasoningProviderModel ) => Promise + /** Fired for each completed provider response, before the loop continues. The + * loop can fail or be aborted at any iteration, so spend has to be handed over + * as it happens — a callback only at the end would discard everything the + * earlier iterations were already billed for. */ + onUsage?: (usage: ChatTokenUsage, modelProvider: ReasoningProviderModel) => void } export interface ChatLoopResult { addedMessages: ChatCompletionMessageParam[] - /** Sum of usage across all loop iterations (suitable for cost accounting). */ + /** Sum of usage across all loop iterations. */ tokenUsage: ChatTokenUsage lastIterationUsage: ChatTokenUsage | null hitMaxIterations: boolean @@ -328,6 +333,20 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { + if (usage && iterationModel) { + config.onUsage?.(usage, iterationModel) + } + } const trackUsage = (usage: ChatTokenUsage | null | undefined) => { tokenUsage = addChatTokenUsage(tokenUsage, usage) @@ -351,6 +370,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise t.def) - const parseOptions = { workspace, provider: modelProvider.provider } + const parseOptions = { + workspace, + provider: modelProvider.provider, + onTokenUsage: reportUsage + } if (isOpenAI) { const reasoningSummaryCacheKey = getReasoningSummaryCacheKey(workspace, modelProvider) diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 19efff2cd4..4118eb492d 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -392,7 +392,7 @@ export async function parseOpenAIResponsesCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - options?: { workspace?: string } + options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -566,6 +566,7 @@ export async function parseOpenAIResponsesCompletion( const finalResponse = await runner.finalResponse() const tokenUsage = openAIResponsesUsageToChatTokenUsage(finalResponse.usage) + options?.onTokenUsage?.(tokenUsage) for (const item of finalResponse.output ?? []) { if (item.type === 'web_search_call' && !surfacedWebSearchCalls.has(item.id)) { diff --git a/frontend/src/lib/components/copilot/chat/tokenUsage.test.ts b/frontend/src/lib/components/copilot/chat/tokenUsage.test.ts new file mode 100644 index 0000000000..f23064f5a3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/tokenUsage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + anthropicUsageToChatTokenUsage, + billedTokens, + openAICompletionsUsageToChatTokenUsage +} from './tokenUsage' + +// The two providers report cache tokens under opposite conventions — Anthropic's +// input_tokens excludes them, OpenAI's includes them. Both are normalized so that +// `prompt` is the whole input, which is what makes `prompt - cached` the uncached +// share. Getting this backwards double-counts (or loses) the cached prefix, which +// is most of a long chat's input. +describe('billedTokens', () => { + it('derives uncached input under the Anthropic convention', () => { + const usage = anthropicUsageToChatTokenUsage({ + input_tokens: 1000, + output_tokens: 200, + cache_creation_input_tokens: 300, + cache_read_input_tokens: 5000 + }) + expect(usage.prompt).toBe(6300) + expect(billedTokens(usage)).toEqual({ + input: 1000, + cacheRead: 5000, + cacheWrite: 300, + output: 200 + }) + }) + + it('derives uncached input under the OpenAI convention', () => { + const usage = openAICompletionsUsageToChatTokenUsage({ + prompt_tokens: 6000, + completion_tokens: 200, + prompt_tokens_details: { cached_tokens: 5000 } + }) + expect(usage.prompt).toBe(6000) + expect(billedTokens(usage)).toEqual({ + input: 1000, + cacheRead: 5000, + cacheWrite: 0, + output: 200 + }) + }) + + // OpenRouter extends the OpenAI shape with cache-creation tokens, counted + // inside prompt_tokens like the reads beside them. Missing the field bills + // them as uncached input. + it('splits out OpenRouter cache-creation tokens', () => { + const usage = openAICompletionsUsageToChatTokenUsage({ + prompt_tokens: 6300, + completion_tokens: 200, + prompt_tokens_details: { cached_tokens: 5000, cache_write_tokens: 300 } + }) + expect(billedTokens(usage)).toEqual({ + input: 1000, + cacheRead: 5000, + cacheWrite: 300, + output: 200 + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/tokenUsage.ts b/frontend/src/lib/components/copilot/chat/tokenUsage.ts index 0091e11227..06ef5e71d8 100644 --- a/frontend/src/lib/components/copilot/chat/tokenUsage.ts +++ b/frontend/src/lib/components/copilot/chat/tokenUsage.ts @@ -1,7 +1,19 @@ +import type { PricedTokens } from '../modelPricing' + export interface ChatTokenUsage { prompt: number completion: number total: number + /** + * Subsets of `prompt`, split out because they are billed at different rates + * (a cached read is a fraction of an uncached one). `prompt` stays the whole + * input so the context gauge keeps measuring the whole request; uncached + * input is `prompt - cacheRead - cacheWrite`. + */ + cacheRead: number + cacheWrite: number + /** Cost in USD as billed, for the providers that report one. */ + cost?: number } /** @@ -28,7 +40,7 @@ export function normalizeContextUsage( } export function emptyChatTokenUsage(): ChatTokenUsage { - return { prompt: 0, completion: 0, total: 0 } + return { prompt: 0, completion: 0, total: 0, cacheRead: 0, cacheWrite: 0 } } export function addChatTokenUsage( @@ -39,10 +51,48 @@ export function addChatTokenUsage( return total } + const cost = + total.cost === undefined && usage.cost === undefined + ? undefined + : (total.cost ?? 0) + (usage.cost ?? 0) + return { prompt: total.prompt + usage.prompt, completion: total.completion + usage.completion, - total: total.total + usage.total + total: total.total + usage.total, + // `?? 0`: the cache split is newer than the field it lives on, so a usage + // object read back from storage may predate it. + cacheRead: (total.cacheRead ?? 0) + (usage.cacheRead ?? 0), + cacheWrite: (total.cacheWrite ?? 0) + (usage.cacheWrite ?? 0), + ...(cost === undefined ? {} : { cost }) + } +} + +/** Compact token count for readouts and tables (`1.2M`, `34k`, `567`). */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + } + if (tokens >= 1000) { + return `${Math.round(tokens / 1000)}k` + } + return `${tokens}` +} + +/** + * Split a usage report into the four separately-billed token classes. `prompt` + * counts the whole input, so the uncached share is whatever the cached classes + * do not account for — which holds for both provider conventions below + * (Anthropic adds its cache counts into `prompt`, OpenAI's already includes them). + */ +export function billedTokens(usage: ChatTokenUsage): PricedTokens { + const cacheRead = usage.cacheRead ?? 0 + const cacheWrite = usage.cacheWrite ?? 0 + return { + input: Math.max(0, usage.prompt - cacheRead - cacheWrite), + cacheRead, + cacheWrite, + output: usage.completion } } @@ -57,16 +107,17 @@ export function anthropicUsageToChatTokenUsage( | null | undefined ): ChatTokenUsage { - const prompt = - (usage?.input_tokens ?? 0) + - (usage?.cache_creation_input_tokens ?? 0) + - (usage?.cache_read_input_tokens ?? 0) + const cacheWrite = usage?.cache_creation_input_tokens ?? 0 + const cacheRead = usage?.cache_read_input_tokens ?? 0 + const prompt = (usage?.input_tokens ?? 0) + cacheWrite + cacheRead const completion = usage?.output_tokens ?? 0 return { prompt, completion, - total: prompt + completion + total: prompt + completion, + cacheRead, + cacheWrite } } @@ -89,7 +140,11 @@ export function openAIResponsesUsageToChatTokenUsage( return { prompt, completion, - total: usage?.total_tokens ?? prompt + completion + total: usage?.total_tokens ?? prompt + completion, + cacheRead: usage?.input_tokens_details?.cached_tokens ?? 0, + // Automatic caching: nothing is billed for populating it, and no usage + // field reports it either. + cacheWrite: 0 } } @@ -101,7 +156,16 @@ export function openAICompletionsUsageToChatTokenUsage( prompt_tokens?: number | null completion_tokens?: number | null total_tokens?: number | null - prompt_tokens_details?: { cached_tokens?: number | null } | null + prompt_tokens_details?: { + cached_tokens?: number | null + /** Cache creation, reported by the providers that bill for it: OpenRouter + * passes Anthropic's through, and the Bedrock proxy folds + * `cacheWriteInputTokens` in here. OpenAI, whose caching is automatic and + * unbilled, reports no such field. */ + cache_write_tokens?: number | null + } | null + /** OpenRouter reports what it actually charged when the request opts in. */ + cost?: number | null } | null | undefined @@ -112,6 +176,9 @@ export function openAICompletionsUsageToChatTokenUsage( return { prompt, completion, - total: usage?.total_tokens ?? prompt + completion + total: usage?.total_tokens ?? prompt + completion, + cacheRead: usage?.prompt_tokens_details?.cached_tokens ?? 0, + cacheWrite: usage?.prompt_tokens_details?.cache_write_tokens ?? 0, + ...(typeof usage?.cost === 'number' ? { cost: usage.cost } : {}) } } diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 09f34806c2..fc8a00727f 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -1089,6 +1089,23 @@ export async function getFimCompletion( } } +// A streamed OpenAI-compatible response carries no usage at all unless the request +// asks for it, so a provider missing from this set reports zero tokens — no context +// gauge, no cost. `stream_options.include_usage` is part of the OpenAI streaming +// spec and these providers document supporting it; `customai` is deliberately absent +// because it points at an arbitrary endpoint that may reject the field outright. +const STREAM_USAGE_PROVIDERS = new Set([ + 'openai', + 'azure_openai', + 'azure_foundry', + 'googleai', + 'openrouter', + 'groq', + 'deepseek', + 'mistral', + 'togetherai' +]) + export async function getCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, @@ -1132,17 +1149,17 @@ export async function getCompletion( // Use Completions API for other providers const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() const completionConfig = applyReasoningToConfig( - (provider === 'openai' || - provider === 'azure_openai' || - provider === 'azure_foundry' || - provider === 'googleai') && - config.stream + config.stream && STREAM_USAGE_PROVIDERS.has(provider) ? { ...config, stream_options: { ...(config.stream_options ?? {}), include_usage: true - } + }, + // OpenRouter's own extension, on top of stream_options: it returns the + // credits actually charged next to the token counts, which is the one + // route by which the chat sees a real cost rather than an estimate. + ...(provider === 'openrouter' ? { usage: { include: true } } : {}) } : config, provider === 'deepseek' ? 'deepseek' : provider === 'mistral' ? 'mistral' : 'completions', @@ -1178,7 +1195,11 @@ export async function parseOpenAICompletion( tools: Tool[], helpers: any, _abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion - options?: { workspace?: string; provider?: string } + options?: { + workspace?: string + provider?: string + onTokenUsage?: (usage: ChatTokenUsage) => void + } ): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> { const finalToolCalls: Record = {} // The tool call currently receiving argument deltas; when the stream moves on @@ -1328,6 +1349,7 @@ export async function parseOpenAICompletion( callbacks.onMessageEnd() + options?.onTokenUsage?.(tokenUsage) // Stream over: every parsed call is queued until its turn in processToolCall. for (const toolCall of Object.values(finalToolCalls)) { if (toolCall.id) { diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index a11fd5c034..cc45a23dac 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -123,21 +123,77 @@ function normalizeVersionSeparators(model: string): string { return model.replace(/\./g, '-') } -// An entry that ends on a version digit must not run into a longer version: -// `gpt-4.1` collapses to `gpt-4-1`, which would otherwise claim the 128K -// `gpt-4-1106-preview` as a 1M model. Suffixes that continue with a separator -// (`claude-opus-4-8` in `...-4-8-v1`, `gpt-5` in `gpt-5-mini`) still match. -// Family fallbacks ending on a letter get no such guard — a version welded -// straight onto the name (`llama3.1`) is exactly what they exist to catch. -const MODEL_CONTEXT_WINDOW_MATCHERS: [matcher: RegExp, contextWindow: number][] = - MODEL_CONTEXT_WINDOWS.map(([name, contextWindow]) => { +/** Suffixes that name a route to a model rather than a different model. */ +const DECORATIVE_SUFFIXES = ['latest', 'preview', 'beta', 'stable'] + +/** + * Compile a most-specific-first `[name, value]` table into matchers against the + * bare model id. Shared with the pricing table so both resolve the same set of + * ids — a model whose window is known but whose price is not (or vice versa) + * should be a gap in one table, never a difference in matching. + * + * An entry that ends on a version digit must not run into a longer version: + * `gpt-4.1` collapses to `gpt-4-1`, which would otherwise claim + * `gpt-4-1106-preview`. Suffixes that continue with a separator + * (`claude-opus-4-8` in `...-4-8-v1`, `gpt-5` in `gpt-5-mini`) still match. + * Family fallbacks ending on a letter get no such guard — a version welded + * straight onto the name (`llama3.1`) is exactly what they exist to catch. + */ +export function buildModelMatchers( + entries: [name: string, value: T][], + { strictVariants = false }: { strictVariants?: boolean } = {} +): [RegExp, T][] { + return entries.map(([name, value]) => { const pattern = normalizeVersionSeparators(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return [new RegExp(/\d$/.test(pattern) ? `${pattern}(?!\\d)` : pattern), contextWindow] + const guards = [ + // An entry ending on a version digit must not run into a longer version. + /\d$/.test(pattern) ? '(?!\\d)' : '', + // A named sub-model (`gpt-5-pro`, `gpt-5-mini`) is a different model with + // its own price, not another route to this one — so under strictVariants an + // entry does not match when a further *name* segment follows. What follows + // is only a decoration when it is a date (`-20251101`), Bedrock's `-v1`, or + // one of the alias words below, at the very end of the id + // (`claude-3-5-haiku-latest` is the same model as `claude-3-5-haiku`, and is + // a shipped default; `gpt-5-preview-pro` would be a different one again). + // Off by default: for a context window an inherited value is a safe + // approximation, for a price it is a wrong number. + // A further revision segment (`gpt-5` vs `gpt-5-4-mini`) is a different model + // too, and the entry-ends-on-a-digit guard above does not catch it once the + // separator is normalized. Only a short segment: a date is digits as well + // (`-20251101`) and stays a decoration. + strictVariants ? '(?!-\\d{1,3}(?:$|-))' : '', + strictVariants + ? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])` + : '' + ].join('') + return [new RegExp(pattern + guards), value] }) +} + +/** + * The `provider:model` key the workspace AI settings use for their per-model maps + * (`max_tokens_per_model`, `model_pricing`). A bare model id is not enough: the + * same id can be served by more than one provider at different rates. + * + * Matched exactly, unlike the fuzzy tables above. Those tables generalize across + * every route to one model on purpose; a per-model *setting* must not, or an + * admin could not give two variants of a family different values — and the key is + * built from the exact id the provider config lists, which is the same string the + * chat sends. + */ +export function modelKey(provider: AIProvider | string, model: string): string { + return `${provider}:${model}` +} + +export function matchModel(matchers: [RegExp, T][], model: string): T | undefined { + const id = normalizeVersionSeparators(parseModelId(model).base) + return matchers.find(([matcher]) => matcher.test(id))?.[1] +} + +const MODEL_CONTEXT_WINDOW_MATCHERS = buildModelMatchers(MODEL_CONTEXT_WINDOWS) export function getKnownModelContextWindow(model: string): number | undefined { - const id = normalizeVersionSeparators(parseModelId(model).base) - return MODEL_CONTEXT_WINDOW_MATCHERS.find(([matcher]) => matcher.test(id))?.[1] + return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model) } export function getModelContextWindow(model: string) { diff --git a/frontend/src/lib/components/copilot/modelPricing.test.ts b/frontend/src/lib/components/copilot/modelPricing.test.ts new file mode 100644 index 0000000000..e295682187 --- /dev/null +++ b/frontend/src/lib/components/copilot/modelPricing.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { billedTokens } from './chat/tokenUsage' +import { estimateCost, priceSpend, resolveModelPrice } from './modelPricing' + +describe('resolveModelPrice', () => { + it('resolves the same model across the routes that decorate its id', () => { + const direct = resolveModelPrice('anthropic', 'claude-opus-5', undefined) + expect(direct?.price.input).toBe(5) + // A gateway prefix, a dot-versioned id, a date suffix and a variant suffix + // must all land on the same entry — a miss here silently under-reports cost. + for (const id of [ + 'anthropic/claude-opus-5', + 'anthropic/claude-opus-4.8', + 'claude-opus-4-8-20260101', + 'anthropic/claude-opus-5:thinking' + ]) { + expect(resolveModelPrice('openrouter', id, undefined)?.price.input).toBe(5) + } + }) + + it('does not let a version-digit entry claim a longer version', () => { + expect(resolveModelPrice('openai', 'gpt-4.1', undefined)?.price.input).toBe(2) + expect(resolveModelPrice('openai', 'gpt-4-1106-preview', undefined)?.price.input).not.toBe(2) + }) + + it('prices flat-rate Gemini Flash while leaving the tiered Pro alone', () => { + expect(resolveModelPrice('googleai', 'gemini-2.5-flash', undefined)?.price.input).toBe(0.3) + expect(resolveModelPrice('googleai', 'gemini-2.5-flash-lite', undefined)?.price.input).toBe(0.1) + expect(resolveModelPrice('googleai', 'gemini-3.5-flash', undefined)?.price.output).toBe(9) + // Pro charges roughly double above a 200k prompt, which a per-model rate cannot + // express, so it must stay unpriced rather than be estimated at the low tier. + expect(resolveModelPrice('googleai', 'gemini-2.5-pro', undefined)).toBeUndefined() + expect(resolveModelPrice('googleai', 'gemini-3.1-pro', undefined)).toBeUndefined() + // Promotional rates carry an end date a timeless table cannot represent. + expect(resolveModelPrice('googleai', 'gemini-3.7-flash', undefined)).toBeUndefined() + }) + + it('reports an unknown model as unpriced rather than guessing', () => { + expect(resolveModelPrice('customai', 'some-in-house-model', undefined)).toBeUndefined() + }) + + it('does not let another model inherit a price through a shared prefix', () => { + // A sub-model (`-pro`) or a newer revision (`gpt-5.6` → `gpt-5-6`) is a + // different model at a different rate; inheriting `gpt-5`'s would be off by + // an order of magnitude, and silently so. + expect(resolveModelPrice('openai', 'gpt-5', undefined)?.price.input).toBe(1.25) + expect(resolveModelPrice('openai', 'gpt-5-mini', undefined)?.price.input).toBe(0.25) + expect(resolveModelPrice('openai', 'gpt-5-pro', undefined)).toBeUndefined() + expect(resolveModelPrice('openai', 'gpt-5.6', undefined)).toBeUndefined() + expect(resolveModelPrice('googleai', 'gemini-3.1', undefined)).toBeUndefined() + // A revision carrying a variant has to be caught by the matcher, not by an + // explicit entry: `gpt-5.4-mini` cannot match the `gpt-5.4` one (the `-mini` + // makes it a sub-model), so nothing but the guard stops it reaching `gpt-5`. + expect(resolveModelPrice('openai', 'gpt-5.4-mini', undefined)).toBeUndefined() + expect(resolveModelPrice('openai', 'gpt-5.5-pro', undefined)).toBeUndefined() + }) + + it('still resolves the route decorations that name the same model', () => { + // Dates, Bedrock's -v1 and floating aliases are ways of spelling one model, + // not sub-models. `claude-3-5-haiku-latest` is a shipped picker default, so + // unpricing it would silently disable cost tracking out of the box. + expect(resolveModelPrice('anthropic', 'claude-opus-4-5-20251101', undefined)?.price.input).toBe(5) + // The revision guard must not swallow a date, which is digits too. + expect(resolveModelPrice('openai', 'gpt-5-2026-01-01', undefined)?.price.input).toBe(1.25) + expect( + resolveModelPrice('bedrock', 'anthropic.claude-sonnet-4-6-20250101-v1:0', undefined)?.price + .input + ).toBe(3) + expect(resolveModelPrice('anthropic', 'claude-3-5-haiku-latest', undefined)?.price.input).toBe( + 0.8 + ) + // …while a genuine sub-model stays unpriced, including one hiding behind a + // decoration. + expect(resolveModelPrice('openai', 'gpt-5-pro', undefined)).toBeUndefined() + expect(resolveModelPrice('openai', 'gpt-5-preview-pro', undefined)).toBeUndefined() + // A family fallback must not price a model the table deliberately left out, + // nor the floating alias pointing at it. + expect(resolveModelPrice('anthropic', 'claude-sonnet-5', undefined)).toBeUndefined() + expect( + resolveModelPrice('openrouter', '~anthropic/claude-sonnet-latest', undefined) + ).toBeUndefined() + }) + + it('prefers a workspace override, keeping the model’s own cache ratios', () => { + const resolved = resolveModelPrice('anthropic', 'claude-opus-5', { + 'anthropic:claude-opus-5': { input: 2, output: 8 } + }) + expect(resolved?.source).toBe('override') + expect(resolved?.price.input).toBe(2) + // Anthropic reads a cached prefix at a tenth and writes at 1.25x. + expect(resolved?.price.cacheRead).toBeCloseTo(0.2) + expect(resolved?.price.cacheWrite).toBeCloseTo(2.5) + }) + + it('applies the overridden model’s own cache discount, not Anthropic’s', () => { + // gpt-4o discounts a cached read by half, not by a tenth — an override that + // only states input/output must not silently inherit the Anthropic ratio. + const resolved = resolveModelPrice('openai', 'gpt-4o', { + 'openai:gpt-4o': { input: 2, output: 8 } + }) + expect(resolved?.price.cacheRead).toBeCloseTo(1) + }) + + it('bills an unpriced model’s cached tokens at its input rate', () => { + // Gemini Pro is deliberately unpriced, so there is no ratio to inherit. Falling + // back to Anthropic's tenth would invent a discount the provider may not give; + // the admin states the cache rates explicitly or pays full input. + const resolved = resolveModelPrice('googleai', 'gemini-2.5-pro', { + 'googleai:gemini-2.5-pro': { input: 2, output: 8 } + }) + expect(resolved?.price.cacheRead).toBe(2) + expect(resolved?.price.cacheWrite).toBe(2) + + const stated = resolveModelPrice('googleai', 'gemini-2.5-pro', { + 'googleai:gemini-2.5-pro': { input: 2, output: 8, cache_read: 0.5, cache_write: 1 } + }) + expect(stated?.price.cacheRead).toBe(0.5) + expect(stated?.price.cacheWrite).toBe(1) + }) + + it('ignores an override whose rates could not be a price', () => { + for (const bad of [{ input: -1, output: 8 }, { input: 1e9, output: 8 }]) { + const resolved = resolveModelPrice('anthropic', 'claude-opus-5', { + 'anthropic:claude-opus-5': bad + }) + expect(resolved?.source).toBe('builtin') + } + }) +}) + +describe('estimateCost', () => { + it('bills each token class at its own rate', () => { + const cost = estimateCost( + { input: 1_000_000, cacheRead: 1_000_000, cacheWrite: 1_000_000, output: 1_000_000 }, + { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } + ) + expect(cost).toBeCloseTo(5 + 0.5 + 6.25 + 25) + }) + + it('charges a cached prefix less than an uncached one', () => { + const usage = { + prompt: 100_000, + completion: 0, + total: 100_000, + cacheRead: 90_000, + cacheWrite: 0 + } + const uncached = { ...usage, cacheRead: 0 } + const price = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } + expect(estimateCost(billedTokens(usage), price)).toBeLessThan( + estimateCost(billedTokens(uncached), price) + ) + }) +}) + +describe('priceSpend', () => { + it('prefers a provider-reported cost over the estimate', () => { + const priced = priceSpend( + [ + { + provider: 'openrouter', + model: 'anthropic/claude-opus-5', + tokens: { input: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 }, + reportedCostUsd: 0.42 + } + ], + undefined + ) + expect(priced.total).toBe(0.42) + expect(priced.hasReported).toBe(true) + }) + + it('flags an unpriced model instead of counting it as free', () => { + const priced = priceSpend( + [ + { + provider: 'customai', + model: 'some-in-house-model', + tokens: { input: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } + } + ], + undefined + ) + expect(priced.hasUnpriced).toBe(true) + expect(priced.rows[0].cost).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/modelPricing.ts b/frontend/src/lib/components/copilot/modelPricing.ts new file mode 100644 index 0000000000..1e5b63247b --- /dev/null +++ b/frontend/src/lib/components/copilot/modelPricing.ts @@ -0,0 +1,288 @@ +import type { AIProvider, ModelPriceOverride } from '$lib/gen' +import { buildModelMatchers, matchModel, modelKey } from './modelConfig' + +/** Rates in USD per million tokens, one per billed token class. */ +export type ModelPrice = { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export type ModelPriceSource = 'override' | 'builtin' + +export type ResolvedModelPrice = { + price: ModelPrice + source: ModelPriceSource +} + +/** What a chat spent on one model, in tokens. */ +export type PricedTokens = { + input: number + cacheRead: number + cacheWrite: number + output: number +} + +// Fallbacks for entries that do not price their cache separately: Anthropic reads a +// cached prefix at a tenth of the input rate and writes one at 1.25x (5-minute TTL, +// the default the chat uses). The read ratio is NOT universal — OpenAI and Google +// discount a cached read far less — so every non-Anthropic entry below states its own +// `cacheRead` rather than inheriting this. Providers whose caching is automatic never +// report a cache write, so their write rate is unused. +const CACHE_READ_RATIO = 0.1 +const CACHE_WRITE_RATIO = 1.25 + +type PriceEntry = { + input: number + output: number + cacheRead?: number + cacheWrite?: number +} + +/** + * Published list prices, most specific entry first — the first name found in the + * bare model id wins, so vendor-namespaced and date-suffixed ids + * (anthropic/claude-opus-5, gpt-5-2026-01-01) still resolve. Matching is shared + * with the context-window table via `buildModelMatchers`. + * + * This is a best-effort snapshot: vendors change rates, ship models faster than + * this table is updated, and negotiated rates differ from list. A model that is + * not listed resolves to undefined and is reported as unpriced rather than + * guessed at, and any entry can be corrected per workspace from the AI settings. + * Providers whose catalogue turns over too quickly to track (DeepSeek, Mistral, + * Groq, TogetherAI, custom deployments) are deliberately absent. + * + * `null` marks a model that is known to exist but whose rates are not. Unpriced is + * a supported state (the UI says so and points at the override); a confidently + * wrong number is not — which is also why these matchers are built with + * `strictVariants`, so an unlisted sub-model (`gpt-5-pro`) reports no rate instead + * of inheriting its family's. + * + * One known gap the per-model shape cannot express: Anthropic's 1M-context beta + * charges more above a threshold. Usage is aggregated per model before pricing, so + * those requests are estimated at the standard tier and understate. An affected + * workspace can set the higher rate as its override. + */ +const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [ + // Anthropic — Opus 4.1 and older bill at the pre-4.5 Opus rate, so the family + // fallback sits below the explicit entries rather than covering them. + ['claude-fable-5', { input: 10, output: 50 }], + ['claude-mythos-5', { input: 10, output: 50 }], + ['claude-opus-5', { input: 5, output: 25 }], + ['claude-opus-4-8', { input: 5, output: 25 }], + ['claude-opus-4-7', { input: 5, output: 25 }], + ['claude-opus-4-6', { input: 5, output: 25 }], + ['claude-opus-4-5', { input: 5, output: 25 }], + ['claude-opus-4-1', { input: 15, output: 75 }], + ['claude-opus-4', { input: 15, output: 75 }], + // Sonnet 5 runs a promotional rate with a published end date, and + // `claude-sonnet-latest` floats to it. Rates carry no date and apply at read + // time, so either figure misstates one side of that boundary — unpriced until + // the rate is a single number again. + ['claude-sonnet-5', null], + ['claude-sonnet-latest', null], + ['claude-sonnet-4-6', { input: 3, output: 15 }], + ['claude-sonnet-4-5', { input: 3, output: 15 }], + ['claude-sonnet-4', { input: 3, output: 15 }], + ['claude-haiku-4-5', { input: 1, output: 5 }], + ['claude-3-5-haiku', { input: 0.8, output: 4 }], + ['claude-opus', { input: 5, output: 25 }], + ['claude-sonnet', { input: 3, output: 15 }], + ['claude-haiku', { input: 1, output: 5 }], + // OpenAI — the cached-input discount varies by family (a tenth on gpt-5, a + // quarter on 4.1 and the o-series, half on 4o), so each entry carries its own + // rate. There is no charge for writing the cache and no usage field reporting + // one, so the write rate never applies. The -mini/-nano entries must precede + // the family entry, which would otherwise claim them. + // Revisions past gpt-5 are priced separately by OpenAI and are not tracked here. + // The matcher's revision guard already keeps them off the family rate; these + // entries stay so a revision the guard admits still resolves to no rate. + ['gpt-5.6', null], + ['gpt-5.5', null], + ['gpt-5.4', null], + ['gpt-5.2', null], + ['gpt-5.1', null], + ['gpt-5-mini', { input: 0.25, output: 2, cacheRead: 0.025 }], + ['gpt-5-nano', { input: 0.05, output: 0.4, cacheRead: 0.005 }], + ['gpt-5', { input: 1.25, output: 10, cacheRead: 0.125 }], + ['gpt-4.1-mini', { input: 0.4, output: 1.6, cacheRead: 0.1 }], + ['gpt-4.1-nano', { input: 0.1, output: 0.4, cacheRead: 0.025 }], + ['gpt-4.1', { input: 2, output: 8, cacheRead: 0.5 }], + ['gpt-4o-mini', { input: 0.15, output: 0.6, cacheRead: 0.075 }], + ['gpt-4o', { input: 2.5, output: 10, cacheRead: 1.25 }], + ['o4-mini', { input: 1.1, output: 4.4, cacheRead: 0.275 }], + ['o3-mini', { input: 1.1, output: 4.4, cacheRead: 0.55 }], + ['o3', { input: 2, output: 8, cacheRead: 0.5 }], + // Google — Flash takes a flat rate and is priced; Pro is not, because both its + // input and output roughly double above a 200k-token prompt and a per-model rate + // cannot express a threshold. Explicit context caching also bills storage per hour, + // which nothing here represents, so a workspace using it sees an underestimate. + // Gemini 3.7 and 3.6 Flash run a promotional rate with an end date, and stay + // unpriced for the same reason Sonnet 5 does. + ['gemini-2.5-flash-lite', { input: 0.1, output: 0.4, cacheRead: 0.01 }], + ['gemini-2.5-flash', { input: 0.3, output: 2.5, cacheRead: 0.03 }], + ['gemini-3.5-flash-lite', { input: 0.3, output: 2.5, cacheRead: 0.03 }], + ['gemini-3.5-flash', { input: 1.5, output: 9, cacheRead: 0.15 }], + ['gemini-3.7', null], + ['gemini-3.6', null], + ['gemini-3.1', null], + ['gemini-3', null], + ['gemini-2.5', null] +] + +const MODEL_PRICE_MATCHERS = buildModelMatchers( + MODEL_PRICES.map(([name, entry]): [string, ModelPrice | null] => [ + name, + entry && { + input: entry.input, + output: entry.output, + cacheRead: entry.cacheRead ?? entry.input * CACHE_READ_RATIO, + cacheWrite: entry.cacheWrite ?? entry.input * CACHE_WRITE_RATIO + } + ]), + { strictVariants: true } +) + +export function getKnownModelPrice(model: string): ModelPrice | undefined { + return matchModel(MODEL_PRICE_MATCHERS, model) ?? undefined +} + +/** + * Rates the API bounds on the way in — but an instance-level config is stored as an + * untyped settings blob that bypasses that handler, so the reader enforces the same + * bounds rather than rendering a negative, infinite or absurd total. + */ +const MAX_MODEL_RATE = 1000 + +function isUsableRate(rate: number | undefined): boolean { + return rate === undefined || (Number.isFinite(rate) && rate >= 0 && rate <= MAX_MODEL_RATE) +} + +/** What a cache rate falls back to when an override leaves it unset: the model's + * own published multiple of the input rate where the table has one, and the input + * rate itself where it does not, so an unstated discount is never borrowed from + * another vendor. Shared with the rates editor, which shows these as placeholders. */ +export function inheritedCacheRates( + model: string, + input: number +): { cacheRead: number; cacheWrite: number } { + const builtin = getKnownModelPrice(model) + return { + cacheRead: input * (builtin ? builtin.cacheRead / builtin.input : 1), + cacheWrite: input * (builtin ? builtin.cacheWrite / builtin.input : 1) + } +} + +/** + * The rate a workspace should be billed at for one model: its override when an + * admin set one, otherwise the published list price, otherwise nothing. An override + * that omits a cache rate takes it from `inheritedCacheRates`. + */ +export function resolveModelPrice( + provider: AIProvider | string, + model: string, + overrides: Record | undefined +): ResolvedModelPrice | undefined { + const builtin = getKnownModelPrice(model) + const candidate = overrides?.[modelKey(provider, model)] + const override = + candidate && + isUsableRate(candidate.input) && + isUsableRate(candidate.output) && + isUsableRate(candidate.cache_read) && + isUsableRate(candidate.cache_write) + ? candidate + : undefined + if (override) { + const inherited = inheritedCacheRates(model, override.input) + return { + source: 'override', + price: { + input: override.input, + output: override.output, + cacheRead: override.cache_read ?? inherited.cacheRead, + cacheWrite: override.cache_write ?? inherited.cacheWrite + } + } + } + return builtin ? { source: 'builtin', price: builtin } : undefined +} + +/** Cost in USD of `tokens` at `price`. */ +export function estimateCost(tokens: PricedTokens, price: ModelPrice): number { + return ( + (tokens.input * price.input + + tokens.cacheRead * price.cacheRead + + tokens.cacheWrite * price.cacheWrite + + tokens.output * price.output) / + 1_000_000 + ) +} + +/** Tokens spent on one model, from a chat's running totals or the usage API. */ +export type ModelSpend = { + provider: string + model: string + tokens: PricedTokens + /** What the provider billed, where it reports a figure. */ + reportedCostUsd?: number +} + +export type Priced = { + /** Undefined when no rate is known for the model — reported as unpriced, never guessed. */ + cost: number | undefined + source: ModelPriceSource | 'reported' | undefined +} + +export type PricedSpend = { + /** The input entries, each with its cost — callers carry their own fields through + * rather than zipping the result back against the input by index. */ + rows: (T & Priced)[] + total: number + /** True when at least one row has no rate, so `total` understates the truth. */ + hasUnpriced: boolean + /** True when at least one row is a figure the provider billed rather than an estimate. */ + hasReported: boolean +} + +/** + * Cost a set of per-model token counts. A provider-reported figure always wins: + * it is what was actually charged, where everything else is list price times + * tokens. `source` says which, so a view never presents an estimate as a bill. + */ +export function priceSpend( + spend: T[], + overrides: Record | undefined +): PricedSpend { + let total = 0 + let hasUnpriced = false + let hasReported = false + const rows = spend.map((entry): T & Priced => { + if (entry.reportedCostUsd !== undefined) { + hasReported = true + total += entry.reportedCostUsd + return { ...entry, cost: entry.reportedCostUsd, source: 'reported' } + } + const resolved = resolveModelPrice(entry.provider, entry.model, overrides) + if (!resolved) { + hasUnpriced = true + return { ...entry, cost: undefined, source: undefined } + } + const cost = estimateCost(entry.tokens, resolved.price) + total += cost + return { ...entry, cost, source: resolved.source } + }) + return { rows, total, hasUnpriced, hasReported } +} + +/** + * Money, at the precision the amount deserves: sub-cent spend is where a chat + * spends most of its life, and rounding it to `$0.00` would read as free. + */ +export function formatUsd(amount: number): string { + if (amount === 0) return '$0' + if (amount < 0.01) return `$${amount.toFixed(4)}` + if (amount < 1) return `$${amount.toFixed(3)}` + return `$${amount.toFixed(2)}` +} diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 60d1cb77a0..5979e8a535 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -5,9 +5,11 @@ type AIConfig, type AIProvider, type GetCopilotSettingsStateResponse, - type InstanceAISummary + type InstanceAISummary, + type ModelPriceOverride } from '$lib/gen' import { workspaceStore } from '$lib/stores' + import { copilotInfo } from '$lib/aiStore' import { sendUserToast } from '$lib/toast' import { AI_PROVIDERS, fetchAvailableModels, providerSupportsWebSearch } from '../copilot/lib' import { supportsAutocomplete } from '../copilot/utils' @@ -25,6 +27,8 @@ import Badge from '../common/badge/Badge.svelte' import Tooltip from '../Tooltip.svelte' import ModelTokenLimits from './ModelTokenLimits.svelte' + import ModelPricing from './ModelPricing.svelte' + import AiUsagePanel from './AiUsagePanel.svelte' import { setCopilotInfo } from '$lib/aiStore' import AIPromptsModal from '../settings/AIPromptsModal.svelte' import { Settings } from 'lucide-svelte' @@ -73,6 +77,7 @@ let metadataModel: string | undefined = $state(undefined) let customPrompts: Record = $state({}) let maxTokensPerModel: Record = $state({}) + let modelPricing: Record = $state({}) let usingOpenaiClientCredentialsOauth = $state(false) let workspaceOverrideEditorOpened = $state(false) @@ -83,6 +88,7 @@ let initialMetadataModel: string | undefined = $state(undefined) let initialCustomPrompts: Record = $state({}) let initialMaxTokensPerModel: Record = $state({}) + let initialModelPricing: Record = $state({}) let initialPrompts: Record = $state({}) let lastLoadedConfigKey = $state(undefined) @@ -110,6 +116,7 @@ codeCompletionModel = config?.code_completion_model?.model customPrompts = clone(config?.custom_prompts ?? {}) maxTokensPerModel = clone(config?.max_tokens_per_model ?? {}) + modelPricing = clone(config?.model_pricing ?? {}) for (const mode of ['edit', 'fix', 'gen']) { if (!(mode in customPrompts)) { customPrompts[mode] = '' @@ -124,6 +131,7 @@ initialCodeCompletionModel = codeCompletionModel initialCustomPrompts = clone(customPrompts) initialMaxTokensPerModel = clone(maxTokensPerModel) + initialModelPricing = clone(modelPricing) initialPrompts = clone(customPrompts) } @@ -139,6 +147,7 @@ codeCompletionModel = initialCodeCompletionModel customPrompts = clone(initialCustomPrompts) maxTokensPerModel = clone(initialMaxTokensPerModel) + modelPricing = clone(initialModelPricing) } $effect(() => { @@ -172,7 +181,8 @@ metadataModel !== initialMetadataModel || codeCompletionModel !== initialCodeCompletionModel || JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) || - JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel) + JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel) || + JSON.stringify(modelPricing) !== JSON.stringify(initialModelPricing) ) $effect(() => { @@ -285,7 +295,8 @@ metadata_model, custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined, max_tokens_per_model: - Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined + Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined, + model_pricing: Object.keys(modelPricing).length > 0 ? modelPricing : undefined } : {} } @@ -610,6 +621,24 @@ scope={promptScope} /> +{#if promptScope === 'workspace'} + + +{/if} + + +{#if showWorkspaceOverrideEditor} + +{/if} + {#if showWorkspaceOverrideEditor} + import { AiService, ApiError, type AITokenUsageBucket, type ModelPriceOverride } from '$lib/gen' + import { formatUsd, priceSpend, type ModelSpend } from '../copilot/modelPricing' + import { formatTokenCount } from '../copilot/chat/tokenUsage' + import SettingCard from '../instanceSettings/SettingCard.svelte' + import Select from '../select/Select.svelte' + import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' + import { resource } from 'runed' + import Tooltip from '../meltComponents/Tooltip.svelte' + import DataTable from '../table/DataTable.svelte' + import Head from '../table/Head.svelte' + import Cell from '../table/Cell.svelte' + + // Workspace and rates are both passed in rather than read from a store: the + // settings component that mounts this one also serves the instance scope, and + // the rates that priced a chat are the workspace's *effective* ones, which an + // inheriting workspace does not hold itself. + let { + workspace, + modelPricing, + scope = 'workspace' + }: { + workspace: string + modelPricing: Record + scope?: 'workspace' | 'self' + } = $props() + + type GroupBy = 'day' | 'user' | 'model' + + let days = $state(30) + let groupBy = $state('day') + + const rangeOptions = [ + { label: 'Last 7 days', value: 7 }, + { label: 'Last 30 days', value: 30 }, + { label: 'Last 90 days', value: 90 } + ] + + let usage = resource( + () => ({ workspace, days, groupBy, scope }), + async ({ workspace, days, groupBy, scope }) => + workspace ? await AiService.listAiUsage({ workspace, days, groupBy, scope }) : undefined + ) + + // The API groups by (dimension, provider, model) so every bucket resolves to a + // single rate; the table folds those back into one line per dimension value. + type Bucket = ModelSpend & { key: string; requests: number } + + function toSpend(bucket: AITokenUsageBucket): Bucket { + return { + // Grouping by model has no separate dimension — the model is the key. + key: groupBy === 'model' ? `${bucket.provider}/${bucket.model}` : bucket.key || '—', + requests: bucket.requests, + provider: bucket.provider, + model: bucket.model, + tokens: { + input: bucket.input_tokens, + cacheRead: bucket.cache_read_tokens, + cacheWrite: bucket.cache_write_tokens, + output: bucket.output_tokens + }, + reportedCostUsd: + bucket.reported_cost_nano_usd != undefined + ? bucket.reported_cost_nano_usd / 1_000_000_000 + : undefined + } + } + + let priced = $derived(priceSpend((usage.current?.buckets ?? []).map(toSpend), modelPricing)) + + type Row = { + key: string + cost: number | undefined + /** Every model behind this line was billed back by its provider, so the + * figure is an invoice rather than an estimate. A line mixing sources — or + * one holding a model with no rate, whose spend the figure omits entirely — + * makes the weaker claim. */ + reported: boolean + tokensIn: number + tokensOut: number + requests: number + } + + // Only a 403 on the workspace scope is a permission problem; reading your own + // usage is open to any member. Attributing every failure to permissions sends an + // admin looking for access they already hold, and buries the real cause of the + // far more common transient ones (an expired session, a database hiccup). + function usageError(error: unknown): string { + if (scope === 'workspace' && error instanceof ApiError && error.status === 403) { + return 'Only workspace admins can read workspace usage.' + } + return 'Could not load usage. Try again in a moment.' + } + + // The headline sums both kinds, so it only escapes the ~ when nothing under it + // was estimated. + let totalIsEstimated = $derived( + priced.rows.some((row) => row.cost !== undefined && row.source !== 'reported') + ) + + let rows = $derived.by(() => { + const byKey = new Map() + for (const row of priced.rows) { + const existing = byKey.get(row.key) ?? { + key: row.key, + cost: undefined, + reported: true, + tokensIn: 0, + tokensOut: 0, + requests: 0 + } + existing.tokensIn += row.tokens.input + row.tokens.cacheRead + row.tokens.cacheWrite + existing.tokensOut += row.tokens.output + existing.requests += row.requests + if (row.cost !== undefined) { + existing.cost = (existing.cost ?? 0) + row.cost + } + existing.reported &&= row.source === 'reported' + byKey.set(row.key, existing) + } + return [...byKey.values()].sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0)) + }) + + + +
+
+
+
+ + {#snippet endSnippet({ item, close })} + +
+ {/snippet} + + {#if dataset && hoveringDataset} +
+
+ {/if} +
+ {/if} + + + + +
+ {#snippet actions()} + + {/snippet} + diff --git a/frontend/src/lib/components/aiEvals/EvalRunsList.svelte b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte new file mode 100644 index 0000000000..5d84ef88ce --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte @@ -0,0 +1,170 @@ + + + + + + + + + + + + + Run + Dataset + Cases + Scores + When + + + + {#each experiments as experiment (experiment.id)} + onOpen(experiment)}> + +
+
+ {experimentName(experiment)} + + {subjectLabel(experiment, deployedHash, currentVersion)} + +
+ {experiment.created_by} +
+
+ + {@const summary = datasetSummary(datasets, experiment.dataset)} + + + + {experiment.case_count} + + +
+ {#each experiment.scores ?? [] as score (score.scorer_id)} + {@const value = headline(score)} + + + {#if score.kind === 'agent'} + + {:else} + + {/if} + {score.name} + {#if value != undefined} + {value} + {:else if score.failed > 0} + failed + {:else if experiment.running} + + {:else} + + {/if} + + + {/each} + {#if (experiment.scores ?? []).length === 0} + {#if experiment.running} + + + scoring + + {:else} + not scored + {/if} + {/if} +
+
+ + + + + +
+ {/each} + {#if experiments.length === 0 && !loaded} + + + + + + {:else if experiments.length === 0} + + +
+ No runs yet + + A run answers every case of a dataset and scores the answers. Each one is kept, so the + next has something to be compared against. + + +
+ + + {/if} + +
diff --git a/frontend/src/lib/components/aiEvals/EvalScorers.svelte b/frontend/src/lib/components/aiEvals/EvalScorers.svelte new file mode 100644 index 0000000000..68287b0fbc --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalScorers.svelte @@ -0,0 +1,383 @@ + + +
+
+ Scorers + {scorers.length} +
+ openAdd('agent', 'new') }, + { + displayName: 'Existing AI judge', + icon: Bot, + action: () => openAdd('agent', 'existing') + }, + { displayName: 'New code scorer', icon: Code2, action: () => openAdd('script', 'new') }, + { + displayName: 'Existing code scorer', + icon: Code2, + action: () => openAdd('script', 'existing') + } + ]} + placement="bottom-end" + > + {#snippet buttonReplacement()} + + {/snippet} + +
+ +
+ {#if scorers.length === 0} +
+ A scorer reads one run and returns a number. Every run of this dataset is measured by all of + them, which is what makes two runs comparable. +
+ {:else} +
+ {#each scorers as scorer (scorer.id)} +
+ {#if scorer.kind === 'agent'} + + {:else} + + {/if} +
+ + {scorerLabel(scorer)} + + {scorer.path} +
+ {#if scorer.pass_if != undefined} + + ≥ {scorer.pass_if} + + {/if} +
+ {/each} +
+ {/if} +
+
+ + + scorerDrawer?.closeDrawer()} + > + {#if workspace && datasetPath} + {#key scorerFormGeneration} + + scriptEditorDrawer + ?.openDrawer(hash, onChanged) + .catch((e) => sendUserToast(`Failed to open the scorer: ${e}`, true))} + /> + {/key} + {/if} + {#snippet actions()} + {@const state = addScorerForm?.submitState()} + + {/snippet} + + + + + settingsDrawer?.closeDrawer()}> + {#if settingsScorer} +
+ + + +
+ {/if} + {#snippet actions()} + + {/snippet} +
+
+ + + + + + (removingScorer = undefined)} + on:confirmed={async () => { + const target = removingScorer + removingScorer = undefined + if (!target) return + try { + await saveScorers(scorers.filter((s) => s.id !== target.id)) + } catch (e) { + sendUserToast(`Failed to remove the scorer: ${e}`, true) + } + }} +> + + The column goes from every run of this dataset, the ones already recorded included. Adding it + again starts a new column, which fills from the next run on. + + diff --git a/frontend/src/lib/components/aiEvals/EvalsPane.svelte b/frontend/src/lib/components/aiEvals/EvalsPane.svelte new file mode 100644 index 0000000000..d9e5b16fe4 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalsPane.svelte @@ -0,0 +1,978 @@ + + +
+
+ {#if viewingRun} + + {/if} +
+ {#if viewingRun && experiment?.run_job_id} + + Open the job + + + {/if} + {#if !viewingRun && loaded && datasets.length > 0} + + {#if experiments.length > 0} + + + {/if} + {/if} +
+ +
+ + +
+ {#if loaded && loadError} +
+ Could not load evals + + The datasets or runs could not be read. Check your access to this agent and reload. + +
+ {:else if loaded && datasets.length === 0} +
+ No dataset yet + + A dataset is the set of cases this agent is measured on. Runs are of a dataset, so + it is the first thing to make. + + +
+ {:else if !viewingRun || !loaded} + openRun(e.id)} + onEditDataset={async (path) => { + if (await useDataset(path)) datasetDrawer?.openDrawer('edit') + }} + onNew={() => (runDialogOpen = true)} + /> + {:else} + + + + + {#each scorers as scorer (scorer.id)} + + {/each} + + + + Case + Answer + {#each scorers as scorer, index (scorer.id)} + {@const mean = means.find((m) => m.scorer_id === scorer.id)} + {@const headline = columnHeadline(scorer, mean)} + + +
+ + {#if scorer.kind === 'agent'} + + {:else} + + {/if} + {scorerLabel(scorer)} + + + {#if headline} + + {headline.value} + + {#if headline.delta && headline.direction !== 0} + 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`} + > + {headline.delta} + + {/if} + {/if} + +
+
+ {/each} + + + + {#each displayRows as row (row.case_id)} + {@const status = statusOf(row.status)} + openCase(row)} + > + + {caseLabel(row)} + + + + + {#if row.output != undefined} + {row.output} + {:else if status === STATUS.not_run} + not run + {:else} + {status.label.toLowerCase()} + {/if} + + + {#each scorers as scorer, index (scorer.id)} + {@const cell = row.scores.find((s) => s.scorer_id === scorer.id)} + + {#if cell?.pending} + + + + {:else if cell?.score != undefined} + + {#snippet text()} +
+ {#if cell.reason} + {cell.reason} + {/if} + {#each checksOf(cell) as check (check.name)} + + + {check.passed ? '✓' : '✗'} + + {check.name} + {#if check.detail} + {check.detail} + {/if} + + {/each} +
+ {/snippet} + + {#if cell.passed != undefined} + + {cell.passed ? '✓' : '✗'} + + {/if} + + {formatScore(cell.score)} + + {#if cell.baseline != undefined && cell.score !== cell.baseline} + {@const delta = cell.score - cell.baseline} + 0 ? 'text-green-500' : 'text-red-500'}`} + > + {formatDelta(delta)} + + {/if} + +
+ {:else if cell?.not_applicable} + + {#snippet text()} + {cell.reason} + {/snippet} + + n/a + + + {:else if cell?.error} + + {#snippet text()} + {cell.error} + {/snippet} + failed + + {:else} + + {/if} +
+ {/each} +
+ {/each} + +
+ {/if} +
+
+ {#if selectedRow} + {@const openRow = selectedRow} + +
+
+ + {openRow.input?.user_message ?? caseLabel(openRow)} + +
+ {#if openRow.job_id} + + Open the case job + + + {/if} +
+
+ {#if openRow.expected != undefined && openRow.expected !== ''} + + {/if} + {#if scorers.length > 0 && openRow.scores.length > 0} + + {/if} + {#if experiment && (openRow.job_id || openRow.output != undefined)} +
+
+ + Case result + +
+
+ {#if openRow.output != undefined} +
+ +
+ {:else if openRow.status === 'running'} + + + Running + + {:else} + {statusOf(openRow.status).label} + {/if} +
+
+ {/if} +
+
+
+ {/if} +
+
+
+ + { + if (await useDataset(path)) { + resumeRunDialog = true + datasetDrawer?.openDrawer('edit') + } + }} + onNewDataset={() => { + resumeRunDialog = true + datasetDrawer?.openDrawer('new') + }} +/> + + { + if (!resumeRunDialog) return + resumeRunDialog = false + // On the dataset the drawer was just in: the dialog opens on the pane's own, which + // creating or editing one has already moved to it. + runDialogOpen = true + }} +/> diff --git a/frontend/src/lib/components/aiEvals/evalUtils.test.ts b/frontend/src/lib/components/aiEvals/evalUtils.test.ts new file mode 100644 index 0000000000..d1f65e0d96 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/evalUtils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { EvalExperiment } from '$lib/gen' +import { parseThreshold, subjectLabel } from './evalUtils' + +describe('parseThreshold', () => { + it('keeps 0 as a threshold and reads only empty text as no threshold', () => { + expect(parseThreshold(0)).toEqual({ value: 0, error: false }) + expect(parseThreshold('0')).toEqual({ value: 0, error: false }) + expect(parseThreshold('')).toEqual({ error: false }) + expect(parseThreshold(' ')).toEqual({ error: false }) + expect(parseThreshold(null)).toEqual({ error: false }) + expect(parseThreshold(undefined)).toEqual({ error: false }) + }) + + it('refuses anything outside 0 to 1 or not a number', () => { + expect(parseThreshold('0.5')).toEqual({ value: 0.5, error: false }) + expect(parseThreshold('1')).toEqual({ value: 1, error: false }) + expect(parseThreshold('1.5')).toEqual({ error: true }) + expect(parseThreshold('-0.1')).toEqual({ error: true }) + expect(parseThreshold('abc')).toEqual({ error: true }) + }) +}) + +describe('subjectLabel', () => { + function run(subject: Record): EvalExperiment { + return { subject: { path: 'u/me/agent', ...subject } } as unknown as EvalExperiment + } + + it('names a deployed run and a pinned version by their number', () => { + expect(subjectLabel(run({ kind: 'agent', version: 4 }))).toBe('v4') + expect(subjectLabel(run({ kind: 'agent_version', version: 2 }))).toBe('v2') + }) + + it('says a draft run is edits on top of the version it was an edit of', () => { + expect(subjectLabel(run({ kind: 'agent_draft', version: 4, draft_hash: 'h1' }))).toBe( + 'v4 + edits' + ) + expect(subjectLabel(run({ kind: 'agent_draft', draft_hash: 'h1' }))).toBe('edits') + }) + + it('reads a draft whose configuration is now deployed as the current version', () => { + const draft = run({ kind: 'agent_draft', version: 4, draft_hash: 'h1' }) + expect(subjectLabel(draft, 'h1', 5)).toBe('v5') + expect(subjectLabel(draft, 'other', 5)).toBe('v4 + edits') + }) +}) diff --git a/frontend/src/lib/components/aiEvals/evalUtils.ts b/frontend/src/lib/components/aiEvals/evalUtils.ts new file mode 100644 index 0000000000..fcc5419f48 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/evalUtils.ts @@ -0,0 +1,107 @@ +import type { + EvalCase, + EvalCaseInput, + EvalDataset, + EvalExperiment, + NewEvalCase, + Scorer +} from '$lib/gen' + +/** The case being edited in the drawer, before it is either run or saved to a dataset. */ +export type CaseDraft = NewEvalCase & { id?: string } + +/** A level the evals pane is on, and the way out of it. */ +export type EvalsLocation = { label: string; back: () => void } + +export type ScorerKind = Scorer['kind'] + +export function emptyCase(): CaseDraft { + return { input: { user_message: '' } } +} + +export function fromStoredCase(c: EvalCase): CaseDraft { + const { created_at: _created_at, created_by: _created_by, ...rest } = c + return rest +} + +export function caseLabel(c: { input?: EvalCaseInput }): string { + const message = c.input?.user_message?.trim() + if (message) return message.length > 60 ? message.slice(0, 60) + '…' : message + return 'Untitled case' +} + +export function experimentName(experiment: EvalExperiment): string { + return `Run ${experiment.run_number}` +} + +/** + * What ran: a deployed version, or a version with edits sitting on top of it. + * + * The list and the results endpoint restamp a draft run whose configuration was later deployed, so + * the kind is usually enough; `deployedHash` and `currentVersion` resolve the one still unstamped. + */ +export function subjectLabel( + experiment: EvalExperiment, + deployedHash?: string, + currentVersion?: number +): string { + if (experiment.subject.kind === 'agent_version') { + return experiment.subject.version ? `v${experiment.subject.version}` : 'a past version' + } + const deployed = + experiment.subject.kind === 'agent' || + (experiment.subject.draft_hash != undefined && experiment.subject.draft_hash === deployedHash) + if (deployed) { + const version = + experiment.subject.kind === 'agent' ? experiment.subject.version : currentVersion + return version ? `v${version}` : 'deployed' + } + return experiment.subject.version ? `v${experiment.subject.version} + edits` : 'edits' +} + +/** A scorer keeps its id when renamed, so its name is the column header and nothing else. */ +export function scorerLabel(scorer: Scorer): string { + return scorer.name || scorer.path.split('/').pop() || scorer.path +} + +export function kindLabel(kind: ScorerKind): string { + return kind === 'agent' ? 'Judge agent' : 'Script' +} + +export function formatScore(score: number | undefined): string { + return score == undefined ? '—' : score.toFixed(2) +} + +export function formatDelta(delta: number): string { + if (delta === 0) return '0.00' + return `${delta > 0 ? '+' : '−'}${Math.abs(delta).toFixed(2)}` +} + +/** What a dataset is for, where it says so: the path names it either way. */ +export function datasetSummary(datasets: EvalDataset[], path: unknown): string | undefined { + return datasets.find((d) => d.path === path)?.summary || undefined +} + +/** + * A pass threshold, as a field holds it. Empty is `''` or null, never a number: a number input + * coerces the text, so a valid threshold of 0 would otherwise read as empty and be dropped. The + * server refuses anything outside 0 to 1, caught here so the form blocks instead of the save. + */ +export function parseThreshold(text: string | number | null | undefined): { + value?: number + error: boolean +} { + const trimmed = typeof text === 'string' ? text.trim() : text + if (trimmed === '' || trimmed == undefined) return { error: false } + const value = Number(trimmed) + if (Number.isNaN(value) || value < 0 || value > 1) return { error: true } + return { value, error: false } +} + +export function summaryToName(summary: string): string { + return summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') +} diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css new file mode 100644 index 0000000000..1ac9f8b31c --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css @@ -0,0 +1,26 @@ +/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame + rather than inherit it. */ +.ag-theme-alpine .wm-multiline-cell-editor, +.ag-theme-alpine-dark .wm-multiline-cell-editor { + background-color: var(--ag-background-color); +} +.ag-theme-alpine .wm-multiline-cell-editor textarea, +.ag-theme-alpine-dark .wm-multiline-cell-editor textarea { + display: block; + box-sizing: border-box; + /* Horizontal only: the vertical padding is set by the editor, which knows the height of the row + it is replacing. `line-height` here is what it computes against. */ + padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px); + border: 1px solid var(--ag-input-focus-border-color); + border-radius: 3px; + outline: none; + resize: none; + /* Past this it scrolls rather than growing. */ + max-height: 40vh; + overflow-y: auto; + background-color: var(--ag-background-color); + color: var(--ag-foreground-color); + font: inherit; + line-height: 20px; + white-space: pre-wrap; +} diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts new file mode 100644 index 0000000000..00975955c9 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts @@ -0,0 +1,108 @@ +import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community' +// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule +// added to it is one the next copy of it drops. +import './multilineCellEditor.css' + +/** Kept in step with the `line-height` the stylesheet gives the textarea. */ +const LINE_HEIGHT = 20 + +/** + * A text cell editor that starts the height of the cell and grows as lines are added, for columns + * holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels. + * + * Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so + * growing is only visible if the editor is allowed to paint outside it. + */ +export class MultilineCellEditor implements ICellEditorComp { + private eGui!: HTMLDivElement + private textarea!: HTMLTextAreaElement + private params!: ICellEditorParams + private wasEmpty = false + + init(params: ICellEditorParams) { + this.params = params + this.eGui = document.createElement('div') + this.eGui.className = 'wm-multiline-cell-editor' + + this.wasEmpty = params.value == undefined + + this.textarea = document.createElement('textarea') + this.textarea.rows = 1 + // A keystroke that opened the edit replaces the value, as it does in every other cell; F2 + // and double-click keep it to be edited. + this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '') + this.textarea.style.width = `${params.column.getActualWidth() - 2}px` + // Padded so one line fills the cell it replaces and a second costs a line rather than a row. + // From the row rather than from `--ag-row-height`, which is the theme's figure and not + // necessarily this grid's. + const rowHeight = params.node.rowHeight ?? 28 + const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2) + this.textarea.style.paddingTop = `${padding}px` + this.textarea.style.paddingBottom = `${padding}px` + + this.textarea.addEventListener('input', () => this.resize()) + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Kept from whatever is around the grid: a grid in a drawer or a dialog is under a + // surface that closes on Escape, and leaving an edit is not asking to leave that. + e.preventDefault() + e.stopPropagation() + this.params.api.stopEditing(true) + return + } + if (e.key !== 'Enter' || e.isComposing) return + // Both branches keep the key from the grid, which ends the edit on Enter whether or not + // Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter + // ends the edit here instead. + e.stopPropagation() + if (!e.shiftKey) { + e.preventDefault() + this.params.stopEditing() + } + }) + this.eGui.appendChild(this.textarea) + } + + private resize() { + this.textarea.style.height = 'auto' + this.textarea.style.height = `${this.textarea.scrollHeight}px` + } + + getGui() { + return this.eGui + } + + afterGuiAttached() { + this.resize() + this.textarea.focus() + // At the end rather than selected: a selection is a keystroke away from erasing the cell. + const end = this.textarea.value.length + this.textarea.setSelectionRange(end, end) + } + + getValue() { + // Nothing typed into a cell that held nothing is not an edit: returning '' here would write + // an empty string over a null, which the grid would see as a change and commit. + if (this.wasEmpty && this.textarea.value === '') return this.params.value + return this.textarea.value + } + + isPopup() { + return true + } + + getPopupPosition(): 'over' | 'under' { + return 'over' + } +} + +/** + * What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as + * the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit + * under, so the editor cannot keep Shift+Enter for itself on its own. + */ +export const multilineCellColDef: Pick = { + cellEditor: MultilineCellEditor, + suppressKeyboardEvent: (p) => + p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey +} diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 6a27357cf2..642d1366f6 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -94,6 +94,13 @@ return open } + /** Whether this is the overlay on top, i.e. the one a key press is for. Overlays that keep + * Escape for themselves (`preventEscape`) have to ask, or they answer keys aimed at whatever + * is stacked above them. Same condition the handler below arbitrates on. */ + export function isTopmost() { + return stack.val.length === 0 || stack.val[stack.val.length - 1] === id + } + function handleClickAway(e) { const last = stack.val[stack.val.length - 1] if (last === id) { diff --git a/frontend/src/lib/components/common/modal/Modal.svelte b/frontend/src/lib/components/common/modal/Modal.svelte index 20821a33f9..9cfa11b84e 100644 --- a/frontend/src/lib/components/common/modal/Modal.svelte +++ b/frontend/src/lib/components/common/modal/Modal.svelte @@ -1,5 +1,16 @@ + + -
+
{#if agent} -
-
+
+ +
(showDetail = !showDetail)} + onkeydown={(e) => { + // Keys aimed at the buttons inside the row bubble through here; leave them theirs. + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + showDetail = !showDetail + } + }} + > - Linked to - +
{agent} e.stopPropagation()}>{agent} - - {#snippet text()} - Read-only: the configuration comes from this saved agent, and only the message and - inputs are set in this flow. Edit changes the agent everywhere it's used. Unlink forks - an editable copy into just this step. - {/snippet} - - -
+ {#if version != undefined} + + v{version} + + {/if} +
+
+ {#if brainParams.length > 0 || inheritedTools.length > 0} + + {#if showDetail} + + {:else} + + {/if} + + {/if} +
- {#if brainParams.length > 0 || inheritedTools.length > 0} -
+ {#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)} +
{#each brainParams as param (param.label)}
{param.label}
@@ -478,12 +609,7 @@
Tools
{#each inheritedTools as tool (tool.id)} - - {toolLabel(tool)} - + {toolLabel(tool)} {/each}
@@ -502,14 +628,56 @@ {/if} {:else if editingPath}
- - Editing - {editingPath} -
+
+ +
+
+ {editingPath} + {#if version != undefined} + + v{version} + + {/if} + {#if edited} + + unsaved changes + + {/if} +
+
+ saving updates every flow using it + {#snippet text()} + The edits live in this step until you decide: Evals runs them as they are here, Save + changes writes them to the agent, Cancel drops them and re-links the step. + {/snippet} + +
+
+
+
+ + -
-

- Editing the saved agent. Save changes updates it and re-links this step — the update - propagates to every flow that links to it. Cancel keeps your edits here as a standalone step - instead. -

{#if providerSaveError} -

+

{providerSaveError}

{/if} {:else} -
-
- -
- or - -
+ {/if}
@@ -552,7 +711,8 @@

Save this AI agent's configuration and tools as a reusable resource. Other flows can then - link to it, and updates propagate automatically. + link to it, updates propagate automatically, and it gains a dataset of eval cases of its + own.

+ + + + + + + { + confirmCancel = false + const path = editingPath + if (path) relink(path) + }} + onCanceled={() => (confirmCancel = false)} +> + + The step goes back to {editingPath} as it is deployed, and the edits are not kept anywhere. Save + changes writes them to the agent instead. + + diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index 538bcf26c7..362b7bca37 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -175,52 +175,6 @@ let settingsDrawer: Drawer | undefined = $state() - { - unsavedModalOpen = false - }} - on:confirmed={() => { - console.log('confirmed') - closeAnyway = true - unsavedModalOpen = false - scriptEditorDrawer?.closeDrawer() - }} -> -
- Are you sure you want to discard the changes you have made? - -
-
+ + { + unsavedModalOpen = false + }} + on:confirmed={() => { + closeAnyway = true + unsavedModalOpen = false + scriptEditorDrawer?.closeDrawer() + }} + > +
+ Are you sure you want to discard the changes you have made? + +
+
{ +export async function createAiAgent( + id: string, + agentPath?: string +): Promise<[FlowModule, FlowModuleState]> { const storedConfig = loadStoredConfig() const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' } + // A step linked to a saved agent reads its brain and tools from the resource, so it carries only + // the flow-local inputs: seeding `provider`/`output_type` would leave transforms it never reads. const aiAgentFlowModules: FlowModule = { id, value: { type: 'aiagent', + ...(agentPath ? { agent: agentPath } : {}), tools: [], input_transforms: { - provider: { type: 'static', value: providerValue }, - output_type: { type: 'static', value: 'text' }, + ...(agentPath + ? {} + : { + provider: { type: 'static', value: providerValue }, + output_type: { type: 'static', value: 'text' } + }), user_message: { type: 'static', value: undefined } } } diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index c98c10b1a2..ef2dd38c39 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -165,7 +165,8 @@ kind: InsertKind, wsScript?: { path: string; summary: string; hash: string | undefined }, wsFlow?: { path: string; summary: string }, - inlineScript?: InlineScript + inlineScript?: InlineScript, + agentPath?: string ): Promise { let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow') let state = emptyFlowModuleState() @@ -190,7 +191,7 @@ } else if (kind == 'branchall') { ;[module, state] = await createBranchAll(module.id) } else if (kind == 'aiagent') { - ;[module, state] = await createAiAgent(module.id) + ;[module, state] = await createAiAgent(module.id, agentPath) } else if (inlineScript) { const { language, kind, subkind, summary } = inlineScript ;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary) @@ -751,7 +752,8 @@ detail.kind as InsertKind, detail.script, detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, - detail.inlineScript + detail.inlineScript, + detail.agentPath ) const index = detail.index ?? 0 const extraModules: FlowModule[] = [module] diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 0b3e77a848..3316463bbb 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -10,6 +10,11 @@ import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte' import TopLevelNode from '../pickers/TopLevelNode.svelte' import RefreshButton from '$lib/components/common/button/RefreshButton.svelte' + import Button from '$lib/components/common/button/Button.svelte' + import { ResourceService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import type { FlowEditorContext } from '../types' + import { BotIcon, Loader2, Plus } from 'lucide-svelte' const dispatch = createEventDispatcher() interface Props { @@ -42,11 +47,44 @@ | 'approval' | 'flow' | 'failure' - | 'aisandbox' = $state(untrack(() => kind)) + | 'aisandbox' + | 'aiagent' = $state(untrack(() => kind)) let preFilter: 'all' | 'workspace' | 'hub' = $state('all') let loading = $state(false) let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure')) + // Optional: this picker also renders outside the flow editor's context (the triggers wrapper). + const flowEditorContext = getContext('FlowEditorContext') + let ws = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore) + + let savedAgents = $state<{ path: string; description?: string }[]>([]) + let savedAgentsLoading = $state(false) + let savedAgentsWs: string | undefined = undefined + async function loadSavedAgents() { + if (!ws || savedAgentsWs === ws) { + return + } + savedAgentsLoading = true + try { + const rs = await ResourceService.listResource({ + workspace: ws, + resourceType: 'ai_agent', + perPage: 1000 + }) + savedAgents = rs.map((r) => ({ path: r.path, description: r.description })) + savedAgentsWs = ws + } catch { + savedAgents = [] + } finally { + savedAgentsLoading = false + } + } + let filteredAgents = $derived( + funcDesc + ? savedAgents.filter((a) => a.path.toLowerCase().includes(funcDesc.toLowerCase())) + : savedAgents + ) + let height = $state(0) let owners = $state([]) // Only the content-sized host (TriggersWrapper) grows past this. The fixed-height hosts top out @@ -81,6 +119,10 @@ {loading} onClick={() => { refreshCount.val += 1 + if (selectedKind === 'aiagent') { + savedAgentsWs = undefined + loadSavedAgents() + } }} />
@@ -184,9 +226,10 @@ {#if customUi?.aiAgent != false} { - dispatch('close') - dispatch('new', { kind: 'aiagent' }) + selectedKind = 'aiagent' + loadSavedAgents() }} /> {/if} @@ -203,7 +246,52 @@
{/if} - {#if selectedKind === 'aisandbox'} + {#if selectedKind === 'aiagent'} +
+ + {#if savedAgentsLoading} +
+ Loading saved agents +
+ {:else if filteredAgents.length > 0} +
Saved agents
+ {#each filteredAgents as agent (agent.path)} + + {/each} + {:else} +
+ {savedAgents.length > 0 + ? 'No saved agent matches this search' + : 'No saved agent in this workspace yet. Configure a blank one, then Save as reusable agent to reuse it.'} +
+ {/if} +
+ {:else if selectedKind === 'aisandbox'}
Promise diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 450debbef4..3491cd3126 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -58,6 +58,8 @@ export type GraphEventHandlers = { inlineScript?: InlineScript script?: PathScript flow?: { path: string; summary: string } + /** Saved `ai_agent` resource the inserted agent step links to, for `kind: 'aiagent'`. */ + agentPath?: string isPreprocessor?: boolean }) => void deleteBranch: (detail: { id: string; index: number }, label: string) => void diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 89e7c88c94..5678621062 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -192,7 +192,8 @@ branch: data.branch, index: data.index, kind: e.detail.kind, - inlineScript: e.detail.inlineScript + inlineScript: e.detail.inlineScript, + agentPath: e.detail.agentPath }) }} on:pickScript={(e) => { diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index d9fc2dcf70..8dd952e5bf 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -145,17 +145,21 @@ }} > {@render startSnippet?.({ item, close: () => (open = false) })} - - {item.label || '\xa0'} - + +
+ + {item.label || '\xa0'} + + {#if item.subtitle} +
{item.subtitle}
+ {/if} +
{#if item.__is_create} {:else} {@render endSnippet?.({ item, close: () => (open = false) })} {/if} - {#if item.subtitle} -
{item.subtitle}
- {/if} {/each} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 5dbe7378fe..6f3fd1a6dc 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -68,6 +68,7 @@ Plus, RotateCw, Save, + FlaskConical, SearchX, Shield, Trash, @@ -82,6 +83,7 @@ assetCanBeExplored } from '../../../../lib/components/ExploreAssetButton.svelte' import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte' + import AgentEvalModal from '$lib/components/aiEvals/AgentEvalModal.svelte' type ResourceW = ListableResource & { canWrite: boolean; marked?: string } type ResourceTypeW = ResourceType & { canWrite: boolean } @@ -133,6 +135,8 @@ let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) let deleteIsLinked = $state(false) let deletePath = $state('') + let evalsOpen = $state(false) + let evalsAgentPath = $state(undefined) let loading = $state({ resources: true, types: true @@ -1262,6 +1266,18 @@ { + evalsAgentPath = path + evalsOpen = true + } + } + ] + : []), { displayName: 'Permissions', icon: Shield, @@ -1462,6 +1478,8 @@ + + Date: Mon, 24 Aug 2026 22:29:44 +0200 Subject: [PATCH 185/192] fix: patch sqlx so a cancelled BEGIN cannot poison a pooled connection (#10823) * fix: patch sqlx so a cancelled BEGIN cannot poison a pooled connection Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzqmh52NU8fB9RBQNNkJGt * test: drop the migration run and fixed sleep from the sqlx patch guard Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzqmh52NU8fB9RBQNNkJGt * test: ignore the sqlx patch guard by default and point at it from where sqlx is changed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzqmh52NU8fB9RBQNNkJGt --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/Cargo.lock | 21 ++---- backend/Cargo.toml | 20 ++++++ .../tests/sqlx_begin_cancel_safe.rs | 72 +++++++++++++++++++ docs/validation.md | 1 + 4 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 backend/windmill-common/tests/sqlx_begin_cancel_safe.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 43ff602ec1..6b76f995f9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11849,8 +11849,7 @@ dependencies = [ [[package]] name = "sqlx" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "sqlx-core", "sqlx-macros", @@ -11862,8 +11861,7 @@ dependencies = [ [[package]] name = "sqlx-core" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -11901,8 +11899,7 @@ dependencies = [ [[package]] name = "sqlx-macros" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "proc-macro2", "quote", @@ -11914,8 +11911,7 @@ dependencies = [ [[package]] name = "sqlx-macros-core" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "dotenvy", "either", @@ -11939,8 +11935,7 @@ dependencies = [ [[package]] name = "sqlx-mysql" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "atoi", "base64 0.22.1", @@ -11984,8 +11979,7 @@ dependencies = [ [[package]] name = "sqlx-postgres" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "atoi", "base64 0.22.1", @@ -12025,8 +12019,7 @@ dependencies = [ [[package]] name = "sqlx-sqlite" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" dependencies = [ "atoi", "chrono", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0bfa52d3e6..0e08b0f060 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -214,6 +214,26 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin "windmill-git-sync/all_sqlx_features"] [patch.crates-io] +# v0.8.6 plus one commit: `Pool::begin` is not cancel-safe on Postgres. sqlx raises the +# transaction depth its rollback-on-drop guard keys on only *after* the BEGIN round trip, so +# a cancelled caller (a disconnecting API client, a `timeout`, an aborted task) leaves the +# session in a transaction nothing will end, and the pool hands that connection out again — +# every later query on it fails with 25P02 until max_lifetime recycles it 30 minutes on. +# Reported upstream in 2022 (launchbadge/sqlx#2054), fixed for SQLite only, and still present +# in 0.9.0. Drop this the moment upstream carries the fix. +# The whole family has to move together: `sqlx-postgres` depends on `sqlx-core` by path +# inside the sqlx workspace, so patching it alone leaves two incompatible `sqlx-core`s and +# `Postgres` stops implementing the `Database` the macros expect. +# Changing any of this — a bump, a rebase of the fork, dropping these lines — still compiles +# clean, so run the guard that actually checks the behaviour is still there: +# cargo test -p windmill-common --test sqlx_begin_cancel_safe -- --ignored +sqlx = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-core = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-macros = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-macros-core = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-postgres = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-mysql = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx-sqlite = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" } # Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343) tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" } diff --git a/backend/windmill-common/tests/sqlx_begin_cancel_safe.rs b/backend/windmill-common/tests/sqlx_begin_cancel_safe.rs new file mode 100644 index 0000000000..1f387984b0 --- /dev/null +++ b/backend/windmill-common/tests/sqlx_begin_cancel_safe.rs @@ -0,0 +1,72 @@ +//! Guards the `sqlx` entries in `[patch.crates-io]` — `backend/Cargo.toml` carries the why. +//! Dropping the patch still compiles, so a test is what notices. +//! +//! Ignored by default: it only has something to say when the sqlx dependency moves, and it +//! spends a couple of seconds waiting on a deliberately slow round trip. Run it whenever you +//! touch sqlx — a version bump, a change to the patch entries, a fork rebase: +//! +//! ```text +//! cargo test -p windmill-common --test sqlx_begin_cancel_safe -- --ignored +//! ``` + +use sqlx::{Connection, PgConnection, Pool, Postgres}; +use std::time::{Duration, Instant}; + +#[sqlx::test] +#[ignore = "run with --ignored after any sqlx bump or change to [patch.crates-io]"] +async fn begin_cancelled_mid_round_trip_leaves_no_open_transaction(db: Pool) { + // One connection, so the session inspected below is the one the cancelled begin used. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .min_connections(0) + .connect_with((*db.connect_options()).clone()) + .await + .expect("failed to build pool"); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&pool) + .await + .unwrap(); + + // A plain `BEGIN` answers in well under a millisecond, which is too narrow to cancel + // reliably; appending a sleep widens the round trip and runs through the same + // `PgTransactionManager::begin` the patch fixes. + let cancelled = tokio::time::timeout( + Duration::from_millis(300), + pool.begin_with("BEGIN; SELECT pg_sleep(2);"), + ) + .await; + assert!(cancelled.is_err(), "the begin must not have completed"); + + let mut admin = PgConnection::connect_with(&(*db.connect_options()).clone()) + .await + .expect("failed to open an observing connection"); + + // sqlx only flushes the queued ROLLBACK once the abandoned statement has answered, so + // wait for the session to stop running rather than sleeping a fixed time a loaded runner + // could overshoot. + let deadline = Instant::now() + Duration::from_secs(30); + let state = loop { + let state: String = sqlx::query_scalar("SELECT state FROM pg_stat_activity WHERE pid = $1") + .bind(pid) + .fetch_optional(&mut admin) + .await + .unwrap() + .flatten() + .unwrap_or_default(); + if state != "active" || Instant::now() >= deadline { + break state; + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + + assert!( + !state.starts_with("idle in transaction"), + "connection returned to the pool still inside a transaction (state {state:?}) — is \ + the sqlx patch in backend/Cargo.toml still applied?" + ); + + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&pool) + .await + .expect("pool must still serve queries"); +} diff --git a/docs/validation.md b/docs/validation.md index 050eacd75b..74e9cf5677 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -16,6 +16,7 @@ After making changes, run the appropriate checks and fix all errors before consi | Multiple gated modules | `cargo check --features enterprise,parquet` | Combine only the flags you need | | API route changes | `cargo check` | Then update `openapi.yaml` and run `npm run generate-backend-client` | | Database migrations | `cargo check` | Test migration applies cleanly with `sqlx migrate run` | +| The `sqlx` dependency (version bump, `[patch.crates-io]` entries, fork rebase) | `cargo test -p windmill-common --test sqlx_begin_cancel_safe -- --ignored` | Windmill runs a patched `sqlx`: upstream's `Pool::begin` is not cancel-safe on Postgres, and a cancelled one poisons the pooled connection for 30 minutes. Losing the patch still compiles, so this ignored test is the only thing that notices. `backend/Cargo.toml` has the detail | **Never** use `--features all_sqlx_features` — it compiles everything and is very slow. Check `backend/Cargo.toml` `[features]` to find the right flags. From 541b6c849657d13fed3580407a00a996e891ad9e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 24 Aug 2026 22:30:45 +0200 Subject: [PATCH 186/192] fix: keep ai chat messages when leaving the page mid-generation (#10809) * fix: persist ai chat turns mid-generation so leaving the page keeps them * fix: stop chat checkpoints once the turn commits, keep streamed text visible * fix: checkpoint streamed answers as they grow and keep half-run tool batches * fix: checkpoint text as received so a backgrounded tab keeps capturing * fix: keep buffered tool screenshots in mid-batch chat checkpoints * fix: decide committed-text at the flush site, condense checkpoint comments * fix: checkpoint only live streamed text, never text the parser owns * fix: don't swap the chat transcript out from under a running turn * fix: close the pre-loading window in the conversation-switch guard --- .../copilot/chat/AIChatDisplay.svelte | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 181 ++++++++- .../copilot/chat/AIChatManager.test.ts | 362 ++++++++++++++++++ .../components/copilot/chat/chatLoop.test.ts | 26 +- .../lib/components/copilot/chat/chatLoop.ts | 32 ++ .../src/lib/components/copilot/chat/shared.ts | 25 +- .../copilot/chat/typewriterReveal.test.ts | 20 + .../copilot/chat/typewriterReveal.ts | 9 + 8 files changed, 635 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 2dd8f7bef7..9b55bd9217 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -611,7 +611,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}