From 7363d2c217cb04391f03b2f9958be70a9d0b5325 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 23:29:53 +0200 Subject: [PATCH 01/76] fix(forks): require admin of both sides for the compare visibility guard (#9869) The blast-radius guard added in #9866 forced `all_ahead_items_visible` true for any fork/target admin. But `filter_visible_diffs` keeps a modified/conflict row (one that exists in the source AND the fork) only when the caller can see it on both sides, so an ahead diff can be dropped for a source-side visibility gap even when the caller is a fork admin. Forcing the flag on fork-admin alone then wrongly reported "all ahead items visible", letting the UI enable deployment from an incomplete comparison. Gate the guard on admin of BOTH the source and the fork (superadmin satisfies both), which is what actually guarantees full visibility of every item on every side. Adds a regression test where a fork admin who is only a plain member of the parent (no access to the item's folder) must still get `all_ahead_items_visible = false`, plus the superadmin sanity path. Also restores the SQLx offline cache entry for the phantom-trigger test INSERT that #9866 landed without (CI/`SQLX_OFFLINE=true` builds failed on it), and adds entries for the new test's all-literal queries. Co-authored-by: Claude Opus 4.8 (1M context) --- ...3b22cb831e91026dcd59d362a6d4382dc240b.json | 12 ++ ...4676237a3b37ffa59ff6c9134df79eed3b680.json | 12 ++ ...d4aac2dbb2ef7596d819db222fb87187d9c4b.json | 12 ++ ...481e28c360367f144f172b746cd11abfa67a2.json | 12 ++ ...55e8365dbd4766f273ef3f68e734972a96cd6.json | 12 ++ ...9f6f24e83e019d6cd1590c42b14524c00f4d3.json | 12 ++ .../tests/workspace_comparison.rs | 127 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 33 ++--- 8 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 backend/.sqlx/query-0ff0b7abad7717d7de398f629853b22cb831e91026dcd59d362a6d4382dc240b.json create mode 100644 backend/.sqlx/query-3211c7631f46975fc0127f106e84676237a3b37ffa59ff6c9134df79eed3b680.json create mode 100644 backend/.sqlx/query-3535965aed37a1cc853b229e062d4aac2dbb2ef7596d819db222fb87187d9c4b.json create mode 100644 backend/.sqlx/query-80f526a401d5f26021b5eb424e7481e28c360367f144f172b746cd11abfa67a2.json create mode 100644 backend/.sqlx/query-b55b0f11b8d73ff9fd19da3bc5555e8365dbd4766f273ef3f68e734972a96cd6.json create mode 100644 backend/.sqlx/query-d3334846d5928e9b260d17e7ca99f6f24e83e019d6cd1590c42b14524c00f4d3.json diff --git a/backend/.sqlx/query-0ff0b7abad7717d7de398f629853b22cb831e91026dcd59d362a6d4382dc240b.json b/backend/.sqlx/query-0ff0b7abad7717d7de398f629853b22cb831e91026dcd59d362a6d4382dc240b.json new file mode 100644 index 0000000000..c43b9e53a1 --- /dev/null +++ b/backend/.sqlx/query-0ff0b7abad7717d7de398f629853b22cb831e91026dcd59d362a6d4382dc240b.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('test-workspace', 'f/restricted/item', 314159, 'def main(): return 1', '', '', 'python3', 'test-user', NOW(), false, false, false, false, '{}'::jsonb)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "0ff0b7abad7717d7de398f629853b22cb831e91026dcd59d362a6d4382dc240b" +} diff --git a/backend/.sqlx/query-3211c7631f46975fc0127f106e84676237a3b37ffa59ff6c9134df79eed3b680.json b/backend/.sqlx/query-3211c7631f46975fc0127f106e84676237a3b37ffa59ff6c9134df79eed3b680.json new file mode 100644 index 0000000000..0624b86e0c --- /dev/null +++ b/backend/.sqlx/query-3211c7631f46975fc0127f106e84676237a3b37ffa59ff6c9134df79eed3b680.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, exists_in_source, exists_in_fork)\n VALUES ('test-workspace', 'wm-fork-guard-test', 'f/restricted/item', 'script', 1, 0, true, true, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3211c7631f46975fc0127f106e84676237a3b37ffa59ff6c9134df79eed3b680" +} diff --git a/backend/.sqlx/query-3535965aed37a1cc853b229e062d4aac2dbb2ef7596d819db222fb87187d9c4b.json b/backend/.sqlx/query-3535965aed37a1cc853b229e062d4aac2dbb2ef7596d819db222fb87187d9c4b.json new file mode 100644 index 0000000000..b814575234 --- /dev/null +++ b/backend/.sqlx/query-3535965aed37a1cc853b229e062d4aac2dbb2ef7596d819db222fb87187d9c4b.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-guard-test')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3535965aed37a1cc853b229e062d4aac2dbb2ef7596d819db222fb87187d9c4b" +} diff --git a/backend/.sqlx/query-80f526a401d5f26021b5eb424e7481e28c360367f144f172b746cd11abfa67a2.json b/backend/.sqlx/query-80f526a401d5f26021b5eb424e7481e28c360367f144f172b746cd11abfa67a2.json new file mode 100644 index 0000000000..384ea81940 --- /dev/null +++ b/backend/.sqlx/query-80f526a401d5f26021b5eb424e7481e28c360367f144f172b746cd11abfa67a2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('test-workspace', 'restricted', 'restricted', ARRAY['u/test-user']::varchar[], '{\"u/test-user\": true}'::jsonb, '', 'test-user')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "80f526a401d5f26021b5eb424e7481e28c360367f144f172b746cd11abfa67a2" +} diff --git a/backend/.sqlx/query-b55b0f11b8d73ff9fd19da3bc5555e8365dbd4766f273ef3f68e734972a96cd6.json b/backend/.sqlx/query-b55b0f11b8d73ff9fd19da3bc5555e8365dbd4766f273ef3f68e734972a96cd6.json new file mode 100644 index 0000000000..c6022d3013 --- /dev/null +++ b/backend/.sqlx/query-b55b0f11b8d73ff9fd19da3bc5555e8365dbd4766f273ef3f68e734972a96cd6.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, exists_in_source, exists_in_fork)\n VALUES\n ('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost', 'http_trigger', 1, 0, true, false, true),\n ('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost_behind', 'http_trigger', 0, 1, true, true, false)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b55b0f11b8d73ff9fd19da3bc5555e8365dbd4766f273ef3f68e734972a96cd6" +} diff --git a/backend/.sqlx/query-d3334846d5928e9b260d17e7ca99f6f24e83e019d6cd1590c42b14524c00f4d3.json b/backend/.sqlx/query-d3334846d5928e9b260d17e7ca99f6f24e83e019d6cd1590c42b14524c00f4d3.json new file mode 100644 index 0000000000..fe92251342 --- /dev/null +++ b/backend/.sqlx/query-d3334846d5928e9b260d17e7ca99f6f24e83e019d6cd1590c42b14524c00f4d3.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, email, username, is_admin, role)\n VALUES ('wm-fork-guard-test', 'test2@windmill.dev', 'test-user-2', true, 'Admin')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "d3334846d5928e9b260d17e7ca99f6f24e83e019d6cd1590c42b14524c00f4d3" +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index f0e77d2038..e98ee07a61 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -1642,3 +1642,130 @@ async fn test_compare_workspaces_phantom_trigger_shortfuse( Ok(()) } + +/// Regression: the "sees everything" guard must require admin of BOTH sides, not +/// just the fork. `filter_visible_diffs` keeps a modified/conflict row (one that +/// exists in the source AND the fork) only when the caller can see it on both +/// sides, so an ahead diff can be dropped for a *source-side* visibility gap even +/// when the caller is a fork admin. If the guard cleared the ahead flag on +/// fork-admin alone, the UI would report "all ahead visible" and let the user +/// deploy from an incomplete comparison. Here test-user-2 is admin of the fork +/// but only a plain member of the parent with no access to folder `restricted`, +/// so the parent copy of the modified script is hidden from them and the ahead +/// diff is (correctly) dropped — the flag must stay false. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_workspaces_fork_admin_source_hidden_ahead( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{port}/api"); + let admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + let fork_admin_user = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN_2".to_string(), + ); + + // Parent folder `restricted` owned by test-user (the admin), NOT test-user-2, + // and a script inside it (access flows through the folder). test-user-2 is a + // plain member of the parent, so RLS hides this script from them. + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by) + VALUES ('test-workspace', 'restricted', 'restricted', ARRAY['u/test-user']::varchar[], '{\"u/test-user\": true}'::jsonb, '', 'test-user')" + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms) + VALUES ('test-workspace', 'f/restricted/item', 314159, 'def main(): return 1', '', '', 'python3', 'test-user', NOW(), false, false, false, false, '{}'::jsonb)" + ) + .execute(&db) + .await?; + + // Fork (clones the folder + script into the fork). + let resp = admin + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({"id": "wm-fork-guard-test", "name": "Guard Fork", "color": "#0000ff"})) + .send() + .await?; + assert!( + resp.status().is_success(), + "fork creation failed: {}", + resp.status() + ); + + // test-user-2 is ADMIN of the fork (so they see the fork copy via RLS bypass) + // but only a plain member of the parent. + sqlx::query!( + "INSERT INTO usr (workspace_id, email, username, is_admin, role) + VALUES ('wm-fork-guard-test', 'test2@windmill.dev', 'test-user-2', true, 'Admin')" + ) + .execute(&db) + .await?; + + // A confirmed modified/ahead diff on the script that exists on both sides. + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork) + VALUES ('test-workspace', 'wm-fork-guard-test', 'f/restricted/item', 'script', 1, 0, true, true, true)" + ) + .execute(&db) + .await?; + sqlx::query!( + "DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-guard-test')" + ) + .execute(&db) + .await?; + + let comparison: serde_json::Value = fork_admin_user + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-guard-test" + )) + .send() + .await? + .json() + .await?; + + // The parent copy is hidden from test-user-2, so the ahead diff is dropped. + // Fork-admin alone must NOT clear the flag — the comparison is incomplete. + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(false), + "fork admin without source-side visibility must not be reported as seeing all ahead items: {comparison}" + ); + assert!( + !comparison["diffs"] + .as_array() + .unwrap() + .iter() + .any(|d| d["path"] == "f/restricted/item"), + "source-hidden item must not appear in the fork admin's diff list: {comparison}" + ); + + // Sanity: a superadmin (admin of both sides) still sees everything. + let comparison: serde_json::Value = admin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-guard-test" + )) + .send() + .await? + .json() + .await?; + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "superadmin must see all ahead items: {comparison}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 072029d401..955a4d95a6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7293,22 +7293,25 @@ async fn compare_workspaces( .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); // Blast-radius guard for the "changes not visible to your user" warning - // (which hides the deploy button entirely). The flag is a pure visibility - // guarantee — the actual deploy/update is separately authorized against the - // target workspace's create/update endpoints — so it must be forced true for - // anyone who, as a rule, sees every item on the relevant side (RLS is bypassed - // for admins); any diff the visibility filter dropped for them is provably a - // stale/phantom row, never a permission gap. Crucially the two sides live in - // different workspaces: ahead items are the fork's own changes (gated by the - // TARGET/fork admin), behind items physically live in the parent (gated by the - // SOURCE/parent admin). A target admin does NOT see the parent side, so it must - // not clear the behind flag, and vice versa. `target_admin` already folds in - // superadmin; `source_admin` (parent side) ORs it in explicitly. + // (which hides the deploy button). The flag is a pure visibility guarantee — + // the deploy re-authorizes each item against the target workspace's + // create/update endpoints — so it is safe to force true for a caller who sees + // every item on BOTH sides, for whom any diff the filter dropped is provably a + // stale/phantom row, never a permission gap. + // + // It must be BOTH sides, not per-side: `filter_visible_diffs` keeps a modified + // or conflict row (one that exists in the source AND the fork) only when the + // caller can see it on both sides, so an ahead/conflict diff can be dropped for + // a source-side visibility gap even when the caller is a fork admin. Gating the + // ahead flag on fork-admin alone would then wrongly report "all ahead visible" + // and let the UI deploy from an incomplete comparison. So require admin of the + // source AND the fork (superadmin satisfies both), which guarantees full + // visibility of every item on every side. `fork_authed.is_admin` already folds + // in superadmin; `authed.is_admin` (source side) does not, so OR it in. let is_super_admin = windmill_common::auth::is_super_admin_email(&db, &authed.email).await?; - let target_admin = fork_authed.is_admin; - let source_admin = is_super_admin || authed.is_admin; - let all_ahead_items_visible = all_ahead_items_visible || target_admin; - let all_behind_items_visible = all_behind_items_visible || source_admin; + let sees_all_items = is_super_admin || (authed.is_admin && fork_authed.is_admin); + let all_ahead_items_visible = all_ahead_items_visible || sees_all_items; + let all_behind_items_visible = all_behind_items_visible || sees_all_items; return Ok(Json(WorkspaceComparison { all_ahead_items_visible, From a73b14d902d759226d0af2f2faf9bdd6588e358c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 1 Jul 2026 23:49:25 +0200 Subject: [PATCH 02/76] fix(cli): correct misleading delete-fork command description (#9870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): correct misleading delete-fork command description The `wmill workspace delete-fork` description claimed it deletes "a forked workspace and git branch", but the implementation only deletes the Windmill workspace via the backend API and removes the local workspace profile. No git operations are performed, so the remote branch is left untouched. Drop the "and git branch" clause and regenerate the derived guidance/system-prompt files. Fixes WIN-2120 Co-Authored-By: Claude Opus 4.8 (1M context) * test(cli): permanently delete temp workspaces in folder test cleanup The isolated-workspace test helper archived each temp workspace on teardown. After #9865 added a CE cap of 1 archived workspace, the second archive-cleanup is refused, so temp workspaces leak into the active set and hit the 2-workspace CE cap — failing every subsequent create/fork across the shared test backend. Permanently delete the workspace instead (DELETE /api/workspaces/delete), which frees the slot without occupying the archived quota. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ruben Fiszel --- cli/src/commands/workspace/workspace.ts | 2 +- cli/src/guidance/skills.gen.ts | 2 +- cli/test/folder_missing_meta.test.ts | 10 ++++++---- system_prompts/auto-generated/cli/cli-commands.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/cli-commands/SKILL.md | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 5628213b75..1e6030a44f 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -819,7 +819,7 @@ const command = new Command() .option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.") .action(createWorkspaceFork as any) .command("delete-fork") - .description("Delete a forked workspace and git branch") + .description("Delete a forked workspace") .arguments("") .option("-y --yes", "Skip confirmation prompt") .action(deleteWorkspaceFork as any) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 62ac23464e..cdfb45db63 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -7262,7 +7262,7 @@ workspace related commands - \`--datatable-behavior \` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt) - \`--from-branch \` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch \`wmill workspace fork\` offers this interactively; from a base branch it creates a fresh fork branch. - \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for. -- \`workspace delete-fork \` - Delete a forked workspace and git branch +- \`workspace delete-fork \` - Delete a forked workspace - \`-y --yes\` - Skip confirmation prompt - \`workspace merge\` - Compare and deploy changes between a fork and its parent workspace - \`--direction \` - Deploy direction: to-parent or to-fork diff --git a/cli/test/folder_missing_meta.test.ts b/cli/test/folder_missing_meta.test.ts index 429ef83b94..37e8ee3a14 100644 --- a/cli/test/folder_missing_meta.test.ts +++ b/cli/test/folder_missing_meta.test.ts @@ -70,11 +70,13 @@ async function withIsolatedWorkspace( } finally { if (workspaceCreated) { try { - const archiveResponse = await backend.apiRequest!( - `/api/w/${workspaceId}/workspaces/archive`, - { method: "POST" } + // Permanently delete (not archive): CE caps archived workspaces at 1, so archiving + // temp workspaces would leak them back into the active set and blow the 2-workspace cap. + const deleteResponse = await backend.apiRequest!( + `/api/workspaces/delete/${workspaceId}`, + { method: "DELETE" } ); - await archiveResponse.text(); + await deleteResponse.text(); } catch { // Best-effort cleanup to avoid exceeding non-enterprise workspace limits. } diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 0a64c3d8e6..9827a7b12a 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -764,7 +764,7 @@ workspace related commands - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt) - `--from-branch ` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch. - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for. -- `workspace delete-fork ` - Delete a forked workspace and git branch +- `workspace delete-fork ` - Delete a forked workspace - `-y --yes` - Skip confirmation prompt - `workspace merge` - Compare and deploy changes between a fork and its parent workspace - `--direction ` - Deploy direction: to-parent or to-fork diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 7d096af860..dcd413b39a 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3432,7 +3432,7 @@ workspace related commands - \`--datatable-behavior \` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt) - \`--from-branch \` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch \`wmill workspace fork\` offers this interactively; from a base branch it creates a fresh fork branch. - \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for. -- \`workspace delete-fork \` - Delete a forked workspace and git branch +- \`workspace delete-fork \` - Delete a forked workspace - \`-y --yes\` - Skip confirmation prompt - \`workspace merge\` - Compare and deploy changes between a fork and its parent workspace - \`--direction \` - Deploy direction: to-parent or to-fork diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 7c34e85706..16ec440df4 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -769,7 +769,7 @@ workspace related commands - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt) - `--from-branch ` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch. - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for. -- `workspace delete-fork ` - Delete a forked workspace and git branch +- `workspace delete-fork ` - Delete a forked workspace - `-y --yes` - Skip confirmation prompt - `workspace merge` - Compare and deploy changes between a fork and its parent workspace - `--direction ` - Deploy direction: to-parent or to-fork From 20cd1a02d582c0715bedacce52cc5c1e1e8d70ca Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 23:52:34 +0200 Subject: [PATCH 03/76] feat(forks): partial-visibility deploy + surface hidden items (#9868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(forks): let partial-visibility users deploy the visible subset The fork Compare & Deploy page hid the deploy button entirely whenever the comparison reported any item not visible to the user (all_ahead/all_behind flags), telling them to hand the deploy to someone with full access. But the non-visible items are already filtered out of the diff list, and the UI already supports deploying an arbitrary subset via per-item selection — so blocking everything was inconsistent and, for stale/phantom rows, blocked on items that don't even exist. Show the deploy footer regardless; the user acts on the visible/selected items (the per-item disabled conditions are unchanged). The hidden-items notice is kept but downgraded to a non-blocking, direction-scoped banner that explains the excluded items instead of removing the action. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(forks): surface hidden-item counts by kind + admin path list WIP: expose items dropped by the visibility filter (hidden_ahead/hidden_behind in the compare response): by-kind counts for everyone, kind+path only for admins. * fix(forks): don't close the deployment request on a partial (hidden-items) deploy Making the deploy button reachable in the partial-visibility case exposed a bug: a clean merge-into-parent deploy unconditionally closed any open fork deployment request as "merged" — marking its comments obsolete and notifying the requester and assignees of a merge — even when hidden ahead changes were excluded from the list and left undeployed. Only close the request as merged when the full ahead set was visible (all_ahead_items_visible); otherwise leave it open (with a toast) so someone with full access can finish it. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 62 +++++++ backend/windmill-api/openapi.yaml | 42 +++++ .../lib/components/CompareWorkspaces.svelte | 158 +++++++++++------- 3 files changed, 205 insertions(+), 57 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 955a4d95a6..bb77a50973 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6948,6 +6948,27 @@ pub struct WorkspaceComparison { pub skipped_comparison: bool, pub diffs: Vec, pub summary: CompareSummary, + /// Items that exist in the diff but were dropped from `diffs` because they + /// are not visible to the caller (excluded from the partial deploy). Split + /// by direction: `hidden_ahead` lives in the fork, `hidden_behind` in the + /// parent. `by_kind`/`total` are always populated (aggregate, no names); + /// `items` (kind+path) is only filled for a caller who is admin of that side + /// — never leak the paths of items the ACL is hiding from a regular user. + pub hidden_ahead: HiddenItemsSummary, + pub hidden_behind: HiddenItemsSummary, +} + +#[derive(Serialize, Default)] +pub struct HiddenItemsSummary { + pub total: usize, + pub by_kind: std::collections::BTreeMap, + pub items: Vec, +} + +#[derive(Serialize)] +pub struct HiddenItem { + pub kind: String, + pub path: String, } #[derive(Serialize, Default)] @@ -7032,6 +7053,8 @@ async fn compare_workspaces( skipped_comparison, diffs: vec![], summary: Default::default(), + hidden_ahead: Default::default(), + hidden_behind: Default::default(), })); } @@ -7313,12 +7336,51 @@ async fn compare_workspaces( let all_ahead_items_visible = all_ahead_items_visible || sees_all_items; let all_behind_items_visible = all_behind_items_visible || sees_all_items; + // Items dropped by the visibility filter (in confirmed_diffs but not in the + // returned visible_diffs). Surface what the partial deploy excludes: aggregate + // counts by kind for everyone, but kind+path only to a caller who `sees_all_items` + // (superadmin, or admin of the source AND the fork). For them a dropped item is + // provably a phantom/stale row, not an ACL-hidden secret, so no path leaks — + // fork-admin alone is not enough (a fork-deleted ahead item lives only in the + // parent, whose path a non-parent-admin must not see). + let visible_keys: HashSet<(&str, &str)> = visible_diffs + .iter() + .map(|d| (d.kind.as_str(), d.path.as_str())) + .collect(); + let mut hidden_ahead = HiddenItemsSummary::default(); + let mut hidden_behind = HiddenItemsSummary::default(); + for d in &confirmed_diffs { + if visible_keys.contains(&(d.kind.as_str(), d.path.as_str())) { + continue; + } + if d.ahead > 0 { + hidden_ahead.total += 1; + *hidden_ahead.by_kind.entry(d.kind.clone()).or_default() += 1; + if sees_all_items { + hidden_ahead + .items + .push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() }); + } + } + if d.behind > 0 { + hidden_behind.total += 1; + *hidden_behind.by_kind.entry(d.kind.clone()).or_default() += 1; + if sees_all_items { + hidden_behind + .items + .push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() }); + } + } + } + return Ok(Json(WorkspaceComparison { all_ahead_items_visible, all_behind_items_visible, skipped_comparison: false, diffs: visible_diffs, summary, + hidden_ahead, + hidden_behind, })); } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ea6459c9bd..f316bc8e23 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -29330,6 +29330,8 @@ components: - skipped_comparison - diffs - summary + - hidden_ahead + - hidden_behind properties: all_ahead_items_visible: type: boolean @@ -29348,6 +29350,46 @@ components: summary: $ref: "#/components/schemas/CompareSummary" description: Summary statistics of the comparison + hidden_ahead: + $ref: "#/components/schemas/HiddenItemsSummary" + description: Ahead items excluded from `diffs` because they are not visible to the caller + hidden_behind: + $ref: "#/components/schemas/HiddenItemsSummary" + description: Behind items excluded from `diffs` because they are not visible to the caller + + HiddenItemsSummary: + type: object + required: + - total + - by_kind + - items + properties: + total: + type: integer + description: Total number of hidden items on this side + by_kind: + type: object + additionalProperties: + type: integer + description: Count of hidden items keyed by item kind (always populated) + items: + type: array + description: Kind and path of each hidden item; only populated when the caller is an admin of the relevant side (empty otherwise) + items: + $ref: "#/components/schemas/HiddenItem" + + HiddenItem: + type: object + required: + - kind + - path + properties: + kind: + type: string + description: Type of the hidden item + path: + type: string + description: Path of the hidden item WorkspaceItemDiff: type: object diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index a9d36cd6f7..669805b49f 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -703,21 +703,32 @@ deselectAll() // If every selected item deployed cleanly and the direction was - // merge-into-parent, close any open deployment request for this fork. + // merge-into-parent, resolve any open deployment request for this fork. if (!anyFailed && mergeIntoParent) { try { const open = await WorkspaceService.getOpenDeploymentRequest({ workspace: currentWorkspaceId }) if (open) { - await WorkspaceService.closeDeploymentRequestMerged({ - workspace: currentWorkspaceId, - id: open.id - }) - deploymentRequestPanel?.refresh() + if (comparison?.all_ahead_items_visible) { + await WorkspaceService.closeDeploymentRequestMerged({ + workspace: currentWorkspaceId, + id: open.id + }) + deploymentRequestPanel?.refresh() + } else { + // Hidden ahead changes remain: those items are excluded from the + // list and stay undeployed, so this deploy is only partial. Closing + // the request as "merged" (which marks its comments obsolete and + // notifies requester/assignees of a merge) would be a lie — leave it + // open so someone with full access can finish it. + sendUserToast( + 'Deployed the changes visible to you. The deployment request stays open because some ahead changes are hidden from you and were not deployed.' + ) + } } } catch (e) { - console.error('Failed to close open deployment request after merge', e) + console.error('Failed to resolve open deployment request after merge', e) } } @@ -897,6 +908,28 @@ azure_trigger: 'Azure trigger', email_trigger: 'Email trigger' } + + // Human label for a diff kind, lowercased for inline use in the hidden-items + // summary ("2 scripts, 1 http route"). + function hiddenKindLabel(kind: string): string { + const base: Record = { + script: 'script', + flow: 'flow', + app: 'app', + raw_app: 'app', + resource: 'resource', + variable: 'variable', + resource_type: 'resource type', + folder: 'folder' + } + return base[kind] ?? KIND_DISPLAY_NAMES[kind]?.toLowerCase() ?? kind + } + + function formatHiddenByKind(byKind: Record): string { + return Object.entries(byKind) + .map(([kind, n]) => `${n} ${hiddenKindLabel(kind)}${n !== 1 ? 's' : ''}`) + .join(', ') + } {#if $workspaceStore != currentWorkspaceId} @@ -1085,22 +1118,31 @@ {/if} - {#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible} - - {#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible} - This fork is ahead and behind its parent - {:else if !comparison.all_behind_items_visible} - This fork is behind of its parent - {:else if !comparison.all_ahead_items_visible} - This fork is ahead of its parent - {/if} - and some of the changes are not visible by you. Only a user with access to the whole context - may deploy or update this fork. You can share the link to this page to someone with proper - permissions to get it deployed. + {@const hiddenDir = mergeIntoParent ? comparison.hidden_ahead : comparison.hidden_behind} + {#if hiddenDir.items.length > 0} + + + {hiddenDir.items.length} + {mergeIntoParent ? 'ahead' : 'behind'} item{hiddenDir.items.length !== 1 ? 's' : ''} + {hiddenDir.items.length !== 1 ? 'are' : 'is'} excluded from the list below — they are not + resolvable as live items and are most likely stale/phantom diff rows: +
    + {#each hiddenDir.items as it} +
  • {hiddenKindLabel(it.kind)} · {it.path}
  • + {/each} +
+
+ {:else if mergeIntoParent ? !comparison.all_ahead_items_visible : !comparison.all_behind_items_visible} + + {hiddenDir.total} + {mergeIntoParent ? 'ahead' : 'behind'} item{hiddenDir.total !== 1 ? 's' : ''} + ({formatHiddenByKind(hiddenDir.by_kind)}) + {hiddenDir.total !== 1 ? 'are' : 'is'} not visible to your user and + {hiddenDir.total !== 1 ? 'are' : 'is'} excluded from the list below. You can still + {mergeIntoParent ? 'deploy' : 'update'} the items you can see — share this page with someone + who has full access to include the rest. {/if} {/snippet} @@ -1285,44 +1327,46 @@
- {#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} -
- {#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()} - - {/if} + +
+ {#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()} -
- {#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf} - - You must set the "on behalf of" user for all items before deploying - - The "run on behalf of" field defines which user's permissions will be - applied during execution. Make sure this is set to an appropriate user - before deploying. - - {/if} + +
+ {#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf} + + You must set the "on behalf of" user for all items before deploying + + The "run on behalf of" field defines which user's permissions will be applied + during execution. Make sure this is set to an appropriate user before + deploying. + + {/if} {#if deploymentErrorMessage != ''} From 1a9debb689f756f38db085d1360c8fe7691ada48 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 00:09:56 +0200 Subject: [PATCH 04/76] fix(jobs): give flow dynselect a path and its worker tag, like scripts (#9867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetching options for a `dynselect`/`dynmultiselect` input was inconsistent between deployed scripts and deployed flows: - scripts ran through `push_script_job_by_path_into_queue` — a `script` job with the script's path, tag, lock and codebase resolution; - flows ran their schema dyn-select code as an anonymous `preview` with no path and no tag (always the language default), and reported access failures as a raw `SqlErr: no rows`. Deployed scripts are left exactly as they were (that path already handles tag/lock/codebase/on-behalf-of correctly). The flow branch now: - carries the flow path on the preview job, - reads the flow's `tag` under RLS and routes the job to it (falling back to the language default when unset), matching the script's worker group, and - runs `check_tag_available_for_workspace` on that tag — the same gate a normal flow run and the script path apply — so a caller who can read the flow but is not allowed to use its (custom/scoped) worker tag is rejected consistently. The flow's tag read runs on every request, so it also serves as the per-request access check, replacing the raw error with a clean `NotAuthorized` / `NotFound`. Entrypoint-name validation now covers all branches (it is interpolated into the generated wrapper). Inline is unchanged: a `preview` with no path on the language default, blocked for operators. Fixes WIN-2118 Co-authored-by: Claude Opus 4.8 (1M context) --- ...ce48f63e4d6d3e2d4fa9247b50963112f47ad.json | 23 +++++ ...30365210bba4bff0b7e32d06c53ed13572360.json | 23 +++++ backend/windmill-api/src/jobs.rs | 91 ++++++++++++++----- 3 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 backend/.sqlx/query-919bee5afcaba361f23760b5f64ce48f63e4d6d3e2d4fa9247b50963112f47ad.json create mode 100644 backend/.sqlx/query-ad07e40790b947d1e8da17dd4ae30365210bba4bff0b7e32d06c53ed13572360.json diff --git a/backend/.sqlx/query-919bee5afcaba361f23760b5f64ce48f63e4d6d3e2d4fa9247b50963112f47ad.json b/backend/.sqlx/query-919bee5afcaba361f23760b5f64ce48f63e4d6d3e2d4fa9247b50963112f47ad.json new file mode 100644 index 0000000000..34c18e2633 --- /dev/null +++ b/backend/.sqlx/query-919bee5afcaba361f23760b5f64ce48f63e4d6d3e2d4fa9247b50963112f47ad.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT tag FROM flow WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "tag", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "919bee5afcaba361f23760b5f64ce48f63e4d6d3e2d4fa9247b50963112f47ad" +} diff --git a/backend/.sqlx/query-ad07e40790b947d1e8da17dd4ae30365210bba4bff0b7e32d06c53ed13572360.json b/backend/.sqlx/query-ad07e40790b947d1e8da17dd4ae30365210bba4bff0b7e32d06c53ed13572360.json new file mode 100644 index 0000000000..e543db2a30 --- /dev/null +++ b/backend/.sqlx/query-ad07e40790b947d1e8da17dd4ae30365210bba4bff0b7e32d06c53ed13572360.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ad07e40790b947d1e8da17dd4ae30365210bba4bff0b7e32d06c53ed13572360" +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 5192d27724..14cbb76c91 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7602,26 +7602,32 @@ async fn run_dynamic_select( )); } + if !is_valid_entrypoint_name(&request.entrypoint_function) { + return Err(error::Error::BadRequest(format!( + "Invalid entrypoint_function {:?}: must match \ + ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \ + not starting with a digit)", + request.entrypoint_function + ))); + } + + // Deployed scripts keep their normal deployed-run path (a `script` job, resolved below by + // push_script_job_by_path_into_queue). Deployed flows and inline snippets have no deployed + // runnable, so they run their dyn-select code as a `preview`; the flow carries its path and + // worker tag so its option-fetching job lines up with the script run. let dynamic_input: DynamicInput; + let runnable_path: Option; + let mut tag: Option = None; match request.runnable_ref { DynamicSelectRunnableRef::Deployed { path, runnable_kind } => match runnable_kind { RunnableKind::Script => { - if !is_valid_entrypoint_name(&request.entrypoint_function) { - return Err(error::Error::BadRequest(format!( - "Invalid entrypoint_function {:?}: must match \ - ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \ - not starting with a digit)", - request.entrypoint_function - ))); - } let mut script_args = request.args.unwrap_or_default(); script_args.insert( - "_ENTRYPOINT_OVERRIDE".to_string(), + ENTRYPOINT_OVERRIDE.to_string(), serde_json::value::to_raw_value(&request.entrypoint_function)?, ); - - let push_args = PushArgsOwned { extra: None, args: script_args.clone() }; + let push_args = PushArgsOwned { extra: None, args: script_args }; let (uuid, _, _) = push_script_job_by_path_into_queue( authed.clone(), @@ -7631,7 +7637,7 @@ async fn run_dynamic_select( w_id.clone(), StripPath(path), run_query.clone(), - push_args.clone(), + push_args, None, ) .await?; @@ -7639,13 +7645,40 @@ async fn run_dynamic_select( return Ok((StatusCode::CREATED, uuid.to_string()).into_response()); } RunnableKind::Flow => { - // Runs the deployed flow's dynamic-select code. Enforce the same - // path-scoped check the script branch gets via - // push_script_job_by_path_into_queue, so a token not scoped to this - // flow cannot trigger its code through dynamic select. + // Runs the deployed flow's dynamic-select code. Path-scoped so a token not + // scoped to this flow cannot trigger its code through dynamic select. check_scopes(&authed, || format!("jobs:run:flows:{path}"))?; let mut conn = user_db.clone().begin(&authed).await?; + // Read the flow's tag under RLS. This runs on every request (including the + // cache hit below), so it doubles as the access check and routes the + // option-fetching preview to the flow's worker group, matching the script branch. + let Some(flow_tag) = sqlx::query_scalar!( + "SELECT tag FROM flow WHERE workspace_id = $1 AND path = $2", + &w_id, + &path + ) + .fetch_optional(&mut *conn) + .await? + else { + conn.commit().await?; + let exists = sqlx::query_scalar!( + "SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 LIMIT 1", + &w_id, + &path + ) + .fetch_optional(&db) + .await? + .is_some(); + if exists { + return Err(error::Error::NotAuthorized(format!( + "You are not authorized to access this flow: {path}" + ))); + } + return Err(Error::NotFound(format!("Flow not found at path {path}"))); + }; + tag = flow_tag; + let dynamic_input_res = match DYNAMIC_INPUT_CACHE.get(&format!("{}:{}", w_id, path)) { Some(cached) => cached.as_ref().clone(), @@ -7688,21 +7721,34 @@ async fn run_dynamic_select( conn.commit().await?; dynamic_input = dynamic_input_res; + runnable_path = Some(path); } }, DynamicSelectRunnableRef::Inline { code, lang: language } => { // Inline dynamic select runs arbitrary, request-supplied code; require the broad // jobs:run scope so a narrowly-scoped token cannot escape its scope. The Deployed - // branches are path-scoped instead (scripts via push_script_job_by_path_into_queue, - // flows via the check_scopes above). + // branches are path-scoped instead. check_scopes(&authed, || format!("jobs:run"))?; dynamic_input = DynamicInput { x_windmill_dyn_select_code: code, x_windmill_dyn_select_lang: language.unwrap_or_default(), }; + runnable_path = None; } } + // Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue): + // a caller allowed to read the flow must still be allowed to use its worker tag. No-op for + // inline (tag is None); the script branch checked this inside its helper and returned above. + check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + + // Invoke the dyn-select entrypoint instead of `main`. + let mut args = request.args.unwrap_or_default(); + args.insert( + ENTRYPOINT_OVERRIDE.to_string(), + serde_json::value::to_raw_value(&request.entrypoint_function)?, + ); + let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); @@ -7713,7 +7759,7 @@ async fn run_dynamic_select( JobPayload::Code(RawCode { hash: None, content: dynamic_input.x_windmill_dyn_select_code, - path: None, + path: runnable_path, language: dynamic_input.x_windmill_dyn_select_lang, lock: None, cache_ttl: None, @@ -7722,9 +7768,11 @@ async fn run_dynamic_select( concurrency_settings: ConcurrencySettings::default().into(), debouncing_settings: DebouncingSettings::default(), modules: None, + // RawCode.tag is ignored by the queue path (`JobPayload::Code` destructures it as + // `tag: _`); the effective tag is the `tag` argument to `push` below. tag: None, }), - PushArgs::from(&request.args.unwrap_or_default()), + PushArgs::from(&args), authed.display_username(), &authed.email, username_to_permissioned_as(&authed.username), @@ -7739,7 +7787,8 @@ async fn run_dynamic_select( false, None, true, - None, + // Deployed flow → the flow's worker tag; inline → `None` (language default). + tag, run_query.timeout, None, None, From 9a24cd2beffbc3fd0169f6724ff9113b41072985 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 00:15:58 +0200 Subject: [PATCH 05/76] chore(main): release 1.745.0 (#9858) * chore(main): release 1.745.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 ++ backend/Cargo.lock | 170 +++++++++--------- 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 +- 17 files changed, 144 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1bc3ea10f..a42f727fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.745.0](https://github.com/windmill-labs/windmill/compare/v1.744.0...v1.745.0) (2026-07-01) + + +### Features + +* **forks:** partial-visibility deploy + surface hidden items ([#9868](https://github.com/windmill-labs/windmill/issues/9868)) ([20cd1a0](https://github.com/windmill-labs/windmill/commit/20cd1a02d582c0715bedacce52cc5c1e1e8d70ca)) +* **frontend:** add zoom and download to Mermaid graphs ([#9859](https://github.com/windmill-labs/windmill/issues/9859)) ([289017b](https://github.com/windmill-labs/windmill/commit/289017bcb28c049c8258b2ffd7da0ec3e6ef120b)) +* use derived username instead of email for non-member superadmins ([#9857](https://github.com/windmill-labs/windmill/issues/9857)) ([76a9523](https://github.com/windmill-labs/windmill/commit/76a95230095ca3f43c9dc9eecde0e9de6520242f)) + + +### Bug Fixes + +* **cli:** correct misleading delete-fork command description ([#9870](https://github.com/windmill-labs/windmill/issues/9870)) ([a73b14d](https://github.com/windmill-labs/windmill/commit/a73b14d902d759226d0af2f2faf9bdd6588e358c)) +* **folders:** allow dots and at-signs in folder owner validation ([#9856](https://github.com/windmill-labs/windmill/issues/9856)) ([383c705](https://github.com/windmill-labs/windmill/commit/383c70523bf81c5c07784a4379ef6b1c93ff86e5)) +* **forks:** require admin of both sides for the compare visibility guard ([#9869](https://github.com/windmill-labs/windmill/issues/9869)) ([7363d2c](https://github.com/windmill-labs/windmill/commit/7363d2c217cb04391f03b2f9958be70a9d0b5325)) +* **forks:** reset diff tally on trigger delete + guard compare visibility for admins ([#9866](https://github.com/windmill-labs/windmill/issues/9866)) ([6a6f129](https://github.com/windmill-labs/windmill/commit/6a6f12960e29c314d11ad541519c71412f42567b)) +* **jobs:** give flow dynselect a path and its worker tag, like scripts ([#9867](https://github.com/windmill-labs/windmill/issues/9867)) ([1a9debb](https://github.com/windmill-labs/windmill/commit/1a9debb689f756f38db085d1360c8fe7691ada48)) +* **offboarding:** make global reassignment per-workspace and optional ([#9863](https://github.com/windmill-labs/windmill/issues/9863)) ([3586164](https://github.com/windmill-labs/windmill/commit/35861641f807a02b5c205608fb592e20ee7cad7f)) + ## [1.744.0](https://github.com/windmill-labs/windmill/compare/v1.743.0...v1.744.0) (2026-07-01) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a275ef72c0..77850c7dc6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6627,14 +6627,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -9309,9 +9309,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ "bitflags 2.13.0", ] @@ -13735,7 +13735,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-nats", @@ -13817,7 +13817,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.744.0" +version = "1.745.0" dependencies = [ "async-stream", "async-trait", @@ -13850,7 +13850,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13863,7 +13863,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "argon2", @@ -14001,7 +14001,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14024,7 +14024,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14039,7 +14039,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14065,7 +14065,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.744.0" +version = "1.745.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14075,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14092,7 +14092,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14114,7 +14114,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14137,7 +14137,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14153,7 +14153,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14174,7 +14174,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14195,7 +14195,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14209,7 +14209,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-nats", @@ -14244,7 +14244,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14269,7 +14269,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14287,7 +14287,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14309,7 +14309,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14329,7 +14329,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14366,7 +14366,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14394,7 +14394,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.744.0" +version = "1.745.0" dependencies = [ "lazy_static", "serde", @@ -14406,7 +14406,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.744.0" +version = "1.745.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14431,7 +14431,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14445,7 +14445,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.744.0" +version = "1.745.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14478,7 +14478,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.744.0" +version = "1.745.0" dependencies = [ "chrono", "lazy_static", @@ -14492,7 +14492,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14511,7 +14511,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.744.0" +version = "1.745.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14613,7 +14613,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.744.0" +version = "1.745.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14632,7 +14632,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.744.0" +version = "1.745.0" dependencies = [ "regex", "serde", @@ -14647,7 +14647,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14671,7 +14671,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "futures", @@ -14688,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.744.0" +version = "1.745.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14704,7 +14704,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -14725,7 +14725,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -14756,7 +14756,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "arc-swap", @@ -14781,7 +14781,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-stream", @@ -14815,7 +14815,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "futures", @@ -14833,7 +14833,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.744.0" +version = "1.745.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14842,7 +14842,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "gosyn", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "nu-parser", @@ -14913,7 +14913,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14924,7 +14924,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14947,7 +14947,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-recursion", @@ -14969,7 +14969,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -14995,7 +14995,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -15025,7 +15025,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15071,7 +15071,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15087,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-recursion", @@ -15137,7 +15137,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "const_format", @@ -15176,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.744.0" +version = "1.745.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15187,7 +15187,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-recursion", @@ -15221,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15245,7 +15245,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15278,7 +15278,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15311,7 +15311,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15331,7 +15331,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15424,7 +15424,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-nats", @@ -15472,7 +15472,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15507,7 +15507,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15535,7 +15535,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-trait", @@ -15560,7 +15560,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15579,7 +15579,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-once-cell", @@ -15689,7 +15689,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.744.0" +version = "1.745.0" dependencies = [ "bytes", "futures", @@ -16507,9 +16507,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ff0a51ec15..0b3fa06521 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.744.0" +version = "1.745.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.744.0" +version = "1.745.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 8e059ddf5a..4e39f3b0c8 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.744.0" +version = "1.745.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.744.0" +version = "1.745.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.744.0" +version = "1.745.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.744.0" +version = "1.745.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8b88c201bd..1048b9f6ba 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.744.0" +version = "1.745.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f316bc8e23..0fcbb9132e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.744.0 + version: 1.745.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index d1dad43038..f5a0a6564d 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.744.0"; +export const VERSION = "v1.745.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 a05f8b100a..457f4cc51e 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.744.0"; +export const VERSION = "1.745.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35a2a45fb9..483c4766b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.744.0", + "version": "1.745.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.744.0", + "version": "1.745.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 4f1fe6ad5c..4aa51acb00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.744.0", + "version": "1.745.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 3dd22bc33a..535f538d26 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.744.0" +wmill = ">=1.745.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 74be13d66b..dfee4df377 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.744.0 + version: 1.745.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index a716b14f17..92a1a6628a 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.744.0' + ModuleVersion = '1.745.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 0612e5a2dd..45b7f56a17 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.744.0" +version = "1.745.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 ca1b6c98ae..ebf0669fc2 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.744.0", + "version": "1.745.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 1b8efd04d8..763196a9cf 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.744.0", + "version": "1.745.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 48207fbc7d..977091ab28 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.744.0 +1.745.0 From 79897950e7646b00d92a28a009174d91c705b251 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 07:37:53 +0200 Subject: [PATCH 06/76] fix(frontend): stack cron field and cron builder button on narrow screens (#9871) The cron schedule row placed the input next to a shrink-0 button group, so on narrow layouts the buttons kept their width and squeezed the input to near-zero. Make the row wrap and give the input a min width so the buttons drop below it, keeping both visible. Fixes WIN-2121 Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/CronInput.svelte | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/CronInput.svelte b/frontend/src/lib/components/CronInput.svelte index b7febcdaca..d2b629d33e 100644 --- a/frontend/src/lib/components/CronInput.svelte +++ b/frontend/src/lib/components/CronInput.svelte @@ -204,18 +204,20 @@
-
- +
+
+ +
{#if !disabled}
{@render cronBuilder()} From e77b7523a50ed665f4014af4c8971a4545abd58c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 06:01:17 +0000 Subject: [PATCH 07/76] nit enterprise implies license feature --- backend/Cargo.toml | 2 +- backend/windmill-api-settings/Cargo.toml | 2 +- backend/windmill-api/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0b3fa06521..bf4b4fc022 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -112,7 +112,7 @@ strip = "none" default = [] private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-dep-map/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"] agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"] -enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-object-store/enterprise"] +enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-object-store/enterprise", "license"] local_reports = ["windmill-common/local_reports"] enterprise_saml = ["windmill-api/enterprise_saml", "oauth2"] stripe = ["windmill-api/stripe"] diff --git a/backend/windmill-api-settings/Cargo.toml b/backend/windmill-api-settings/Cargo.toml index 5c4f429d9e..bc5078bce4 100644 --- a/backend/windmill-api-settings/Cargo.toml +++ b/backend/windmill-api-settings/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [features] default = [] -enterprise = [] +enterprise = ["license"] private = ["windmill-common/private"] parquet = ["windmill-common/parquet", "windmill-object-store/parquet"] license = ["dep:rsa"] diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 6ce463a5aa..56893a8737 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] stripe = [] run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"] agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] From b883adbc0011073da592dc5b39e1b79db492c83c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 10:35:39 +0200 Subject: [PATCH 08/76] fix(duckdb): auto-declare partition arg for `// partitioned` scripts (#9878) * fix(duckdb): auto-declare the partition arg for // partitioned scripts Co-Authored-By: Claude Fable 5 * feat(cli): pipeline run --arg to pass plain run args to cascade scripts Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../parsers/windmill-parser-sql/src/lib.rs | 82 +++++++++++++++++++ .../windmill-worker/src/duckdb_executor.rs | 33 ++++++++ cli/src/commands/pipeline/pipeline.ts | 70 ++++++++++++---- cli/src/commands/pipeline/pipelineUpload.ts | 36 +++++++- cli/src/guidance/skills.gen.ts | 1 + cli/test/pipeline_upload_unit.test.ts | 45 ++++++++++ .../auto-generated/cli/cli-commands.md | 1 + system_prompts/auto-generated/prompts.ts | 1 + .../skills/cli-commands/SKILL.md | 1 + 9 files changed, 252 insertions(+), 18 deletions(-) diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 931f470d3c..dc094a0a7b 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -826,6 +826,29 @@ fn parse_duckdb_file(code: &str) -> anyhow::Result>> { } args.append(&mut parse_sql_sanitized_interpolation(code)); + + // A `// partitioned` script receives its resolved partition as a job arg + // named `partition` (windmill_common::partition::PARTITION_ARG), and duckdb + // binds named parameters only when they appear in the parsed signature — + // so auto-declare it (as `-- $partition (text)` would) to make `$partition` + // usable without a manual declaration. An explicit declaration wins. + // `has_default` keeps the field optional: the platform resolves the value + // at run start when it is not passed explicitly. + if !args.iter().any(|arg| arg.name == "partition") + && windmill_parser::asset_parser::parse_pipeline_annotations(code) + .partition + .is_some() + { + args.push(Arg { + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + otyp: Some("text".to_string()), + has_default: true, + oidx: None, + otyp_inferred: false, + }); + } Ok(Some(args)) } @@ -1985,4 +2008,63 @@ SELECT x Ok(()) } + + #[test] + fn test_parse_duckdb_partitioned_auto_declares_partition() -> anyhow::Result<()> { + let code = r#"// partitioned daily +// materialize ducklake://main/sales_daily +SELECT $partition AS day, count(*) AS n FROM sales WHERE day = $partition +"#; + let args = parse_duckdb_sig(code)?.args; + assert_eq!( + args, + vec![Arg { + otyp: Some("text".to_string()), + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + has_default: true, + oidx: None, + otyp_inferred: false, + }] + ); + + // `--`-style annotation headers auto-declare too. + let dash_code = "-- partitioned hourly\nSELECT $partition AS h\n"; + assert_eq!(parse_duckdb_sig(dash_code)?.args, args); + + Ok(()) + } + + #[test] + fn test_parse_duckdb_partitioned_explicit_declaration_wins() -> anyhow::Result<()> { + let code = r#"// partitioned daily +-- $partition (text) +-- $limit (int) = 10 +SELECT * FROM sales WHERE day = $partition LIMIT $limit +"#; + let args = parse_duckdb_sig(code)?.args; + // No duplicate: the explicit (required) declaration is kept as-is. + assert_eq!(args.iter().filter(|a| a.name == "partition").count(), 1); + assert_eq!( + args[0], + Arg { + otyp: Some("text".to_string()), + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + has_default: false, + oidx: None, + otyp_inferred: false, + } + ); + Ok(()) + } + + #[test] + fn test_parse_duckdb_unpartitioned_does_not_declare_partition() -> anyhow::Result<()> { + let code = "SELECT 1 AS partition_count\n"; + assert_eq!(parse_duckdb_sig(code)?.args, vec![]); + Ok(()) + } } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index a932be102d..c0574a8f1d 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1534,6 +1534,39 @@ mod tests { assert!(!rewritten.contains("-- $file")); } + // A `// partitioned` script referencing `$partition` needs no manual + // `-- $partition (text)` declaration: the parser auto-declares the arg, so + // the executor binds the injected `partition` job arg instead of failing + // with duckdb's "Wrong number of parameters" at prepare time. + #[test] + fn partitioned_auto_declares_partition_arg() { + let script = "// partitioned daily\n\ + // materialize ducklake://main/sales_daily\n\ + SELECT $partition AS day, count(*) AS n FROM dl.sales WHERE day = $partition"; + + let sig = parse_duckdb_sig(script).expect("sig parses").args; + let partition_arg = sig + .iter() + .find(|a| a.name == "partition") + .expect("`partition` auto-declared"); + assert_eq!(partition_arg.otyp.as_deref(), Some("text")); + + // The wrapped query keeps the `$partition` references so the parsed sig + // binds them at run time. + let (rewritten, _) = build_materialized_query( + script, + Some("2026-07-02"), + &std::collections::HashMap::new(), + ) + .expect("materialize builds") + .expect("materialize present"); + let rewritten = rewritten.expect("managed mode rewrites the query"); + assert!( + rewritten.contains("$partition"), + "wrapped query must keep the `$partition` reference, got:\n{rewritten}" + ); + } + // SCD2 managed mode wraps the SELECT into the diff → close-old → open-new // shape (unit-covered in the parser's codegen tests); here we pin the // executor-level wiring: the natural key flows through and the wrap is diff --git a/cli/src/commands/pipeline/pipeline.ts b/cli/src/commands/pipeline/pipeline.ts index 6c10c09add..fd9be73d2e 100644 --- a/cli/src/commands/pipeline/pipeline.ts +++ b/cli/src/commands/pipeline/pipeline.ts @@ -40,6 +40,7 @@ import { generatePipelineDocs } from "./docs.ts"; import { type UploadBinding, devUploadKey, + parseArgBinding, parseS3Uri, parseUploadBinding, s3Arg, @@ -469,6 +470,7 @@ async function run( json?: boolean; local?: boolean; upload?: string[]; + arg?: string[]; defaultTs?: "bun" | "deno"; }, folder: string, @@ -524,6 +526,24 @@ async function run( await enrichDeployedNonAutorunTriggers(workspace.workspaceId, graph); } + // Resolve a `--upload`/`--arg` script token to its graph node, with an + // actionable error when the short name is ambiguous or matches nothing. + const resolveScriptTokenOrThrow = (tok: string, flag: string): string => { + const id = resolveToken(graph, tok); + if (!id || !id.startsWith("script:")) { + const matches = graph.runnables.filter( + (r) => r.usage_kind === "script" && (r.path.split("/").pop() ?? r.path) === tok, + ); + if (matches.length > 1) { + throw new Error( + `${flag} '${tok}' matches multiple scripts (${matches.map((r) => r.path).sort().join(", ")}) — use the full path.`, + ); + } + throw new Error(`${flag} '${tok}' matched no script in f/${f}.`); + } + return id; + }; + // `--upload
@@ -118,70 +180,108 @@
- {#each items as item (item.key)} - {@const isSelectable = selectablePredicate(item)} - {@const isSelected = selectedItems.includes(item.key)} - {@const status = deploymentStatus[item.key]} - {@const isDeployed = status?.status === 'deployed'} - {@const blockedReason = - !isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined} - - handleSelect(item)} - path={item.kind !== 'resource' && - item.kind !== 'variable' && - item.kind !== 'resource_type' - ? item.path - : ''} - marked={undefined} - kind={item.kind} - triggerKind={item.triggerKind} - canFavorite={false} - workspaceId="" - > - {#snippet customSummary()} - {#if itemSummary} - {@render itemSummary(item)} - {:else} - {item.path} + {#each groups as group (group.key)} + {#if showGroupHeaders} + {@const selectable = groupSelectable(group)} + {@const selectedCount = selectable.filter((i) => selectedItems.includes(i.key)).length} + +
+ 0 && selectedCount === selectable.length} + indeterminate={selectedCount > 0 && selectedCount < selectable.length} + disabled={selectable.length === 0} + title={selectedCount === selectable.length + ? `Deselect all in ${group.label}` + : `Select all in ${group.label}`} + onChange={() => toggleGroup(group)} + /> + {#if group.groupKind === 'folder'} + + {:else if group.groupKind === 'user'} + {/if} - {/snippet} - {#snippet pathDisplay()} - {#if itemPath} - {@render itemPath(item)} - {:else} - {item.kind !== 'resource' && - item.kind !== 'variable' && - item.kind !== 'resource_type' - ? item.path + {group.label} + + {group.items.length} item{group.items.length !== 1 ? 's' : ''}{selectedCount > 0 + ? ` · ${selectedCount} selected` : ''} + + {#if groupActions} +
+ {@render groupActions(group.items)} +
{/if} - {/snippet} - {#snippet actions()} - {#if itemActions} - {@render itemActions(item)} - {/if} - - {#if status} - {#if status.status === 'loading'} - - {:else if status.status === 'deployed'} - Deployed - {:else if status.status === 'failed'} -
- Failed - {status.error} -
+
+ {/if} + {#each group.items as item (item.key)} + {@const isSelectable = selectablePredicate(item)} + {@const isSelected = selectedItems.includes(item.key)} + {@const status = deploymentStatus[item.key]} + {@const isDeployed = status?.status === 'deployed'} + {@const blockedReason = + !isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined} + {@const showPath = + item.kind !== 'resource' && item.kind !== 'variable' && item.kind !== 'resource_type'} + + {@const subPath = + group.key && item.path.startsWith(group.key + '/') + ? item.path.slice(group.key.length + 1) + : item.path} + + handleSelect(item)} + path={showPath ? item.path : ''} + marked={undefined} + kind={item.kind} + triggerKind={item.triggerKind} + canFavorite={false} + workspaceId="" + > + {#snippet customSummary()} + {#if itemSummary} + {@render itemSummary(item)} + {:else} + {item.path} {/if} - {/if} - {/snippet} - + {/snippet} + {#snippet pathDisplay()} + {#if itemPath} + {@render itemPath(item)} + {:else} + {showPath ? (showGroupHeaders ? subPath : item.path) : ''} + {/if} + {/snippet} + {#snippet actions()} + {#if itemActions} + {@render itemActions(item)} + {/if} + + {#if status} + {#if status.status === 'loading'} + + {:else if status.status === 'deployed'} + Deployed + {:else if status.status === 'failed'} +
+ Failed + {status.error} +
+ {/if} + {/if} + {/snippet} +
+ {/each} {/each}
diff --git a/frontend/src/lib/components/common/checkbox/Checkbox.svelte b/frontend/src/lib/components/common/checkbox/Checkbox.svelte index 0c0fcef268..49e13e9519 100644 --- a/frontend/src/lib/components/common/checkbox/Checkbox.svelte +++ b/frontend/src/lib/components/common/checkbox/Checkbox.svelte @@ -4,6 +4,9 @@ interface Props { /** Controlled checked state. */ checked?: boolean + /** Tri-state display (e.g. a group header with only part of its items + * selected). Purely visual — `checked` still drives the value. */ + indeterminate?: boolean disabled?: boolean /** Native title attribute (hover hint). */ title?: string | undefined @@ -15,6 +18,7 @@ let { checked = false, + indeterminate = false, disabled = false, title = undefined, class: className = undefined, @@ -25,6 +29,7 @@ Date: Thu, 2 Jul 2026 11:14:19 +0200 Subject: [PATCH 11/76] feat(frontend): pipelines index page and sql editor hint (#9881) * feat(frontend): surface pipelines in sidebar nav, index page and sql editor hint Co-Authored-By: Claude Fable 5 * feat(frontend): remove pipelines sidebar nav item Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/lib/components/ScriptBuilder.svelte | 55 +++++++++ .../AssetGraph/PipelineFolderList.svelte | 111 ++++++++++++++++++ .../AssetGraph/PipelinePickerModal.svelte | 93 +-------------- .../(root)/(logged)/pipeline/+page.svelte | 63 +++++----- 4 files changed, 204 insertions(+), 118 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 36ac67559b..fbfa4f1c1b 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -56,6 +56,7 @@ Code, DiffIcon, EllipsisVertical, + Network, Plus, Rocket, Settings, @@ -63,6 +64,9 @@ Tag, X } from 'lucide-svelte' + import { base } from '$lib/base' + import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + import { parsePipelineAnnotations } from './assets/AssetGraph/parsePipelineAnnotations' import DropdownV2 from './DropdownV2.svelte' import { type Item } from '$lib/utils' import { sendUserToast } from '$lib/toast' @@ -314,6 +318,21 @@ const enterpriseLangs = ['bigquery', 'snowflake', 'mssql', 'oracledb'] + // Languages the pipeline editor treats as warehouse/dataset transforms — + // the ones where a `-- pipeline` annotation is a natural next step. + const pipelineHintLangs = ['duckdb', 'postgresql', 'bigquery', 'snowflake', 'mysql', 'mssql'] + const pipelineHintDismissed = useLocalStorageValue( + 'pipelineScriptHintDismissed', + false, + 'boolean' + ) + let showPipelineHint = $derived( + !pipelineHintDismissed.val && + (script.kind === 'script' || script.kind === undefined) && + pipelineHintLangs.includes(script.language ?? '') && + !parsePipelineAnnotations(script.content ?? '').inPipeline + ) + export function setCode(code: string): void { editor?.setCode(code) } @@ -1993,6 +2012,42 @@
+ {#if showPipelineHint} +
+ + + This script can become a data pipeline step: annotate it with + -- pipeline + and + + -- materialize + + to materialize its result, or build it in the + pipeline editor. + + Learn more + + +
+
+
+ {/if} + + import { userStore, workspaceStore } from '$lib/stores' + import { base } from '$lib/base' + import { goto } from '$app/navigation' + import Button from '$lib/components/common/button/Button.svelte' + import FolderPicker from '$lib/components/FolderPicker.svelte' + import { AssetService, type ListPipelineFoldersResponse } from '$lib/gen' + import { resource } from 'runed' + import { ArrowRight, Loader2 } from 'lucide-svelte' + + interface Props { + // When provided, the current folder is dropped from the existing list + // so users don't "switch" to the folder they're already on. + currentFolder?: string | undefined + // Carried over to the target URL so switching folders from the + // editor keeps the user in edit mode; the default view mode needs + // no param. + mode?: 'view' | 'edit' + // Called right before navigating — lets a wrapping modal close itself. + onOpen?: () => void + } + let { currentFolder = undefined, mode = 'view', onOpen = undefined }: Props = $props() + + let pipelines = resource( + () => $workspaceStore, + async (ws) => { + if (!ws) return [] as ListPipelineFoldersResponse + return await AssetService.listPipelineFolders({ workspace: ws }) + } + ) + + let pickedFolder = $state('') + + let visiblePipelines = $derived( + (pipelines.current ?? []).filter((p) => p.folder !== currentFolder) + ) + + async function openExistingPipeline(folder: string) { + onOpen?.() + const modeQuery = mode !== 'view' ? `?mode=${mode}` : '' + await goto(`${base}/pipeline/${encodeURIComponent(folder)}${modeQuery}`) + } + + async function openPicked() { + const name = pickedFolder.trim() + if (!name) return + await openExistingPipeline(name) + } + + +
+
+

+ Existing pipelines +

+ {#if pipelines.loading && !pipelines.current} +
+ + Loading… +
+ {:else if pipelines.error} +
Failed: {pipelines.error.message}
+ {:else if visiblePipelines.length === 0} +
+ {currentFolder + ? 'No other pipelines in this workspace.' + : 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'} +
+ {:else} +
+ {#each visiblePipelines as p (p.folder)} + + {/each} +
+ {/if} +
+ + {#if !$userStore?.operator} + +
+

+ Pick or create a folder +

+
+
+ +
+ +
+
+ {/if} +
diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte index ded1013e25..b2183eec77 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte @@ -1,13 +1,6 @@ -
- {#if visiblePipelines.length > 0 || pipelines.loading} -
-

- Existing pipelines -

- {#if pipelines.loading && !pipelines.current} -
- - Loading… -
- {:else if pipelines.error} -
Failed: {pipelines.error.message}
- {:else} -
- {#each visiblePipelines as p} - - {/each} -
- {/if} -
- {/if} - - {#if !$userStore?.operator} - -
-

- Pick or create a folder -

-
-
- -
- -
-
- {/if} -
+ (open = false)} />
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte index 98f598165e..ab36b4c1f7 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte @@ -1,24 +1,20 @@ - Pipeline editor — Windmill + Pipelines — Windmill
@@ -45,28 +40,42 @@ >
-

- {$userStore?.operator ? 'Pipelines' : 'Pipeline editor'} -

- · no folder selected +

Pipelines

+ + Alpha +
-
-
- - Pick a folder to open its pipeline. - +
+
+
+

Data pipelines

+

+ Chain ingestion, transformation and materialization steps into an asset-aware graph. + {#if !$userStore?.operator} + Each pipeline lives in a folder: open one below, or pick a folder to start a new + pipeline. + {:else} + Open a pipeline below to view its graph and runs. + {/if} +

+ + + Pipelines documentation + +
+ +
- From b92a86b8b3a60b877540c3a7f0ffefe36ccbb053 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 2 Jul 2026 11:48:11 +0200 Subject: [PATCH 12/76] fix: polish pipeline graph view (layout, viewport, minimap, lineage, timestamps) (#9883) * fix(frontend): keep pipeline graph layered when lineage has cycles Co-Authored-By: Claude Fable 5 * fix(frontend): fit pipeline graph to visible canvas on initial load Co-Authored-By: Claude Fable 5 * fix(frontend): style pipeline minimap so it reads as a minimap Co-Authored-By: Claude Fable 5 * fix(parser): don't infer s3 reads from bare string-literal mentions in sql Co-Authored-By: Claude Fable 5 * fix(duckdb): render temporal values as ISO strings in job results Co-Authored-By: Claude Fable 5 * fix(frontend): key pipeline viewport fit on the loaded graph's folder Co-Authored-By: Claude Fable 5 * fix(parser): treat list/named read-fn arguments as definitive s3 reads Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/asset_parser.rs | 220 ++++++++++++++++-- .../windmill-duckdb-ffi-internal/src/lib.rs | 114 ++++++++- .../assets/AssetGraph/AssetGraphCanvas.svelte | 50 +++- .../assets/AssetGraph/InitialFitView.svelte | 30 +++ .../AssetGraph/PipelineGraphEditor.svelte | 9 + .../AssetGraph/assetGraphLayout.test.ts | 40 +++- .../assets/AssetGraph/assetGraphLayout.ts | 37 ++- .../sessions/PipelineEditorView.svelte | 10 + .../(logged)/pipeline/[folder]/+page.svelte | 11 + 9 files changed, 482 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte diff --git a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs index f7c578a274..c4f93efdb7 100644 --- a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs +++ b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs @@ -83,18 +83,56 @@ pub fn parse_assets(input: &str) -> anyhow::Result { // Body-inferred column lineage, with `// column` annotations taking // precedence per output column (explicit declaration overrides inference). pipeline.column_lineage = merge_column_lineage(inferred, pipeline.column_lineage); - Ok(ParseAssetsOutput::new( - merge_assets(collector.assets), - Vec::new(), - pipeline, - )) + // A bare string literal in query position is only weak read evidence: a + // summary `SELECT 's3:///out.csv' AS target` after `COPY … TO + // 's3:///out.csv'` must not turn the write into rw (which draws a + // spurious read edge and an asset⇄script cycle in the pipeline graph). + // Surface weak reads only for assets with no other recorded usage, so a + // path that is *merely* mentioned still shows up linked to the script. + let mut assets = merge_assets(collector.assets); + for weak in merge_assets(collector.weak_string_reads) { + if !assets + .iter() + .any(|a| a.kind == weak.kind && a.path == weak.path) + { + assets.push(weak); + } + } + assets.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(ParseAssetsOutput::new(assets, Vec::new(), pipeline)) +} + +/// Provenance of the innermost access context. The access type alone can't +/// tell a definitive read apart from a mere mention: a bare string literal in +/// generic query position (`QueryRead`) is only *weak* read evidence — e.g. a +/// summary `SELECT 's3:///out.csv' AS target` echoing a path — while the same +/// literal as a read-function argument or a `COPY` target is definitive. +#[derive(Clone, Copy, PartialEq, Eq)] +enum AccessCtx { + QueryRead, + ReadFn, + CopyWrite, +} + +impl AccessCtx { + fn access_type(self) -> AssetUsageAccessType { + match self { + AccessCtx::QueryRead | AccessCtx::ReadFn => R, + AccessCtx::CopyWrite => W, + } + } } /// Visitor that collects S3 asset literals from SQL statements struct AssetCollector { assets: Vec, - // e.g set to Read when we are inside a SELECT ... FROM ... statement - current_access_type_stack: Vec, + // Bare string literals seen in generic query position — weak read + // evidence, surfaced by `parse_assets` only when the script has no other + // recorded usage of the same asset (a real write must not gain a spurious + // read edge from a mention). + weak_string_reads: Vec, + // e.g set to QueryRead when we are inside a SELECT ... FROM ... statement + current_access_type_stack: Vec, // e.g ATTACH 'ducklake://a' AS dl; => { "dl": (Ducklake, "a") } var_identifiers: BTreeMap, // e.g USE dl; @@ -119,6 +157,7 @@ impl AssetCollector { fn new() -> Self { Self { assets: Vec::new(), + weak_string_reads: Vec::new(), current_access_type_stack: Vec::with_capacity(8), var_identifiers: BTreeMap::new(), currently_used_asset: None, @@ -162,7 +201,11 @@ impl AssetCollector { name: &ObjectName, access_type: Option, ) -> Option { - let access_type = access_type.or_else(|| self.current_access_type_stack.last().copied()); + let access_type = access_type.or_else(|| { + self.current_access_type_stack + .last() + .map(|c| c.access_type()) + }); if let Some((kind, path)) = &self.currently_used_asset { // We don't want to infer that any simple identifier refers to an asset if // we are not in a known R/W context @@ -301,12 +344,18 @@ impl AssetCollector { // Check if the string matches our asset syntax patterns if let Some((kind, path)) = parse_asset_syntax(s, false) { if kind == AssetKind::S3Object { - self.assets.push(ParseAssetsResult { + let ctx = self.current_access_type_stack.last().copied(); + let result = ParseAssetsResult { kind, path: path.to_string(), - access_type: self.current_access_type_stack.last().copied(), + access_type: ctx.map(AccessCtx::access_type), columns: None, - }); + }; + if ctx == Some(AccessCtx::QueryRead) { + self.weak_string_reads.push(result); + } else { + self.assets.push(result); + } } } } @@ -314,7 +363,7 @@ impl AssetCollector { fn handle_obj_name_pre(&mut self, name: &ObjectName) { if let Some(fname) = get_trivial_obj_name(name) { if is_read_fn(fname) { - self.current_access_type_stack.push(R); + self.current_access_type_stack.push(AccessCtx::ReadFn); } } if let Some(str_lit) = get_str_lit_from_obj_name(name) { @@ -691,9 +740,20 @@ impl Visitor for AssetCollector { match table_factor { TableFactor::Table { name, args, .. } => { if args.is_none() { - // Avoid Table Functions - self.handle_obj_name_pre(name); + // FROM 's3:///…' is a definitive read — record it directly + // so it isn't demoted to a weak in-query mention. + if let Some(asset) = self.get_s3_asset_from_str_literal_table(table_factor) { + self.assets.push(asset); + } } + // For a read-function table factor this pushes ReadFn, making + // every literal inside its arguments a definitive read — the + // direct form (read_csv('s3:///…')) but also list and named + // arguments (read_parquet(['s3:///…'])). Must run for BOTH the + // plain-table and table-function branches: post_visit_table_factor + // pops via handle_obj_name_post unconditionally, so skipping the + // push here would unbalance the stack. + self.handle_obj_name_pre(name); } _ => {} } @@ -719,6 +779,13 @@ impl Visitor for AssetCollector { Expr::Value(ValueWithSpan { value: Value::DoubleQuotedString(s), .. }) => { self.handle_string_literal(s); } + // Read-function call in expression position: its argument literals + // are definitive reads. Balances the pop in `post_visit_expr`. + Expr::Function(func) => { + if get_trivial_obj_name(&func.name).is_some_and(is_read_fn) { + self.current_access_type_stack.push(AccessCtx::ReadFn); + } + } _ => {} } std::ops::ControlFlow::Continue(()) @@ -946,7 +1013,7 @@ impl Visitor for AssetCollector { } sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => { - self.current_access_type_stack.push(W); + self.current_access_type_stack.push(AccessCtx::CopyWrite); self.handle_string_literal(filename); self.current_access_type_stack.pop(); } @@ -1013,7 +1080,7 @@ impl Visitor for AssetCollector { &mut self, query: &sqlparser::ast::Query, ) -> std::ops::ControlFlow { - self.current_access_type_stack.push(R); + self.current_access_type_stack.push(AccessCtx::QueryRead); self.cte_name_stack.push(collect_cte_names(query)); std::ops::ControlFlow::Continue(()) } @@ -1098,6 +1165,127 @@ mod tests { ); } + #[test] + fn test_copy_target_echoed_in_select_stays_write_only() { + // The trailing summary SELECT merely mentions the COPY target — it + // must not add a read (rw would draw an asset⇄script cycle). + let input = r#" + COPY (SELECT 1 AS x) TO 's3:///out.csv'; + SELECT 's3:///out.csv' AS target, 42 AS rows_written; + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/out.csv".to_string(), + access_type: Some(W), + columns: None + }]) + ); + } + + #[test] + fn test_bare_string_mention_without_other_usage_is_a_read() { + let input = r#" + SELECT 's3:///referenced.csv' AS path; + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/referenced.csv".to_string(), + access_type: Some(R), + columns: None + }]) + ); + } + + #[test] + fn test_self_refresh_read_fn_plus_copy_stays_rw() { + // A definitive read (read_csv) of the same file the script rewrites + // is a real rw — only *bare-literal* mentions are demoted. + let input = r#" + CREATE TABLE tmp AS SELECT * FROM read_csv('s3:///data.csv'); + COPY (SELECT * FROM tmp) TO 's3:///data.csv'; + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/data.csv".to_string(), + access_type: Some(RW), + columns: None + }]) + ); + } + + #[test] + fn test_read_fn_list_arg_plus_copy_stays_rw() { + // read_parquet's list form is as definitive as the direct literal — + // it must not be demoted to a weak mention when the file is rewritten. + let input = r#" + CREATE TABLE tmp AS SELECT * FROM read_parquet(['s3:///data.parquet']); + COPY (SELECT * FROM tmp) TO 's3:///data.parquet'; + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/data.parquet".to_string(), + access_type: Some(RW), + columns: None + }]) + ); + } + + #[test] + fn test_read_fn_list_arg_multiple_files_are_reads() { + let input = r#" + SELECT * FROM read_parquet(['s3:///a.parquet', 's3:///b.parquet']); + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/a.parquet".to_string(), + access_type: Some(R), + columns: None + }, + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/b.parquet".to_string(), + access_type: Some(R), + columns: None + } + ]) + ); + } + + #[test] + fn test_from_string_literal_of_written_file_stays_rw() { + // FROM-position string literal is likewise a definitive read. + let input = r#" + CREATE TABLE tmp AS SELECT * FROM 's3:///data.parquet'; + COPY (SELECT * FROM tmp) TO 's3:///data.parquet'; + "#; + let s = parse_assets(input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/data.parquet".to_string(), + access_type: Some(RW), + columns: None + }]) + ); + } + #[test] fn test_sql_asset_parser_attach_no_usage_is_registered_as_unknown() { let input = r#" diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index f3ee59cab7..054f892220 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -635,7 +635,9 @@ fn duckdb_value_to_json_value( .ok_or_else(|| "Could not convert to f64".to_string())?, ), duckdb::types::Value::Decimal(d) => serde_json::Value::String(d.to_string()), - duckdb::types::Value::Timestamp(_, ts) => serde_json::Value::String(ts.to_string()), + duckdb::types::Value::Timestamp(unit, ts) => { + serde_json::Value::String(duckdb_timestamp_to_iso(unit, ts)) + } duckdb::types::Value::Text(s) if type_alias.as_deref().unwrap_or_default() == "JSON" => { serde_json::from_str(&s) .map_err(|e| format!("Error parsing JSON text: {}", e.to_string()))? @@ -646,8 +648,15 @@ fn duckdb_value_to_json_value( .map(|byte| serde_json::Value::Number(byte.into())) .collect(), ), - duckdb::types::Value::Date32(d) => serde_json::Value::Number(d.into()), - duckdb::types::Value::Time64(_, t) => serde_json::Value::String(t.to_string()), + 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()), + None => serde_json::Value::Number(d.into()), + } + } + duckdb::types::Value::Time64(unit, t) => { + serde_json::Value::String(duckdb_time_to_iso(unit, t).unwrap_or_else(|| t.to_string())) + } duckdb::types::Value::Interval { months, days, nanos } => serde_json::json!({ "months": months, "days": days, @@ -688,6 +697,105 @@ fn duckdb_value_to_json_value( Ok(json_value) } +// DuckDB surfaces TIMESTAMP[_S/_MS/_NS] values as a raw count since the epoch; +// stringifying that count leaks values like "1782974022218435" into job +// results. Render ISO-8601 instead (the Postgres executor's +// "2024-01-15T10:30:00" shape); a count outside chrono's representable range +// falls back to the raw number. +fn duckdb_timestamp_to_iso(unit: duckdb::types::TimeUnit, ts: i64) -> String { + let dt = match unit { + duckdb::types::TimeUnit::Second => chrono::DateTime::from_timestamp(ts, 0), + duckdb::types::TimeUnit::Millisecond => chrono::DateTime::from_timestamp_millis(ts), + duckdb::types::TimeUnit::Microsecond => chrono::DateTime::from_timestamp_micros(ts), + duckdb::types::TimeUnit::Nanosecond => Some(chrono::DateTime::from_timestamp_nanos(ts)), + }; + dt.map(|dt| dt.naive_utc().format("%Y-%m-%dT%H:%M:%S%.f").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +// Same story for TIME: a raw count since midnight. None when out of range +// (caller falls back to the raw number). +fn duckdb_time_to_iso(unit: duckdb::types::TimeUnit, t: i64) -> Option { + let (secs, nanos) = match unit { + duckdb::types::TimeUnit::Second => (t, 0), + duckdb::types::TimeUnit::Millisecond => (t / 1_000, (t % 1_000) * 1_000_000), + duckdb::types::TimeUnit::Microsecond => (t / 1_000_000, (t % 1_000_000) * 1_000), + duckdb::types::TimeUnit::Nanosecond => (t / 1_000_000_000, t % 1_000_000_000), + }; + chrono::NaiveTime::from_num_seconds_from_midnight_opt( + u32::try_from(secs).ok()?, + u32::try_from(nanos).ok()?, + ) + .map(|t| t.format("%H:%M:%S%.f").to_string()) +} + +#[cfg(test)] +mod temporal_json_tests { + use super::*; + use duckdb::types::TimeUnit; + + #[test] + fn timestamp_micros_renders_iso() { + // 2026-07-01 23:13:42.218435 UTC + assert_eq!( + duckdb_timestamp_to_iso(TimeUnit::Microsecond, 1_782_947_622_218_435), + "2026-07-01T23:13:42.218435" + ); + } + + #[test] + fn timestamp_seconds_renders_iso_without_subseconds() { + assert_eq!( + duckdb_timestamp_to_iso(TimeUnit::Second, 1_735_689_600), + "2025-01-01T00:00:00" + ); + } + + #[test] + fn out_of_range_timestamp_falls_back_to_raw() { + assert_eq!( + duckdb_timestamp_to_iso(TimeUnit::Second, i64::MAX), + i64::MAX.to_string() + ); + } + + #[test] + fn time_micros_renders_iso() { + assert_eq!( + duckdb_time_to_iso(TimeUnit::Microsecond, 37_800_500_000).as_deref(), + Some("10:30:00.500") + ); + } + + #[test] + fn temporal_values_render_iso_through_real_query() { + let conn = duckdb::Connection::open_in_memory().unwrap(); + let mut stmt = conn + .prepare( + "SELECT TIMESTAMP '2026-07-01 23:13:42.218435' AS ts, + TIMESTAMPTZ '2026-07-01 23:13:42+00' AS tstz, + TIMESTAMP_NS '2026-07-01 23:13:42.218435678' AS ts_ns, + DATE '2026-07-01' AS d, + TIME '10:30:00' AS t", + ) + .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!("2026-07-01T23:13:42.218435")); + assert_eq!(json_of(1), serde_json::json!("2026-07-01T23:13:42")); + assert_eq!( + json_of(2), + serde_json::json!("2026-07-01T23:13:42.218435678") + ); + assert_eq!(json_of(3), serde_json::json!("2026-07-01")); + assert_eq!(json_of(4), serde_json::json!("10:30:00")); + } +} + fn json_value_to_duckdb_value( json_value: &serde_json::Value, arg_type: &str, diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 153d7c2965..6851fc843a 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -16,6 +16,7 @@ import DataTestNode from './DataTestNode.svelte' import AssetGraphEdge from './AssetGraphEdge.svelte' import PanToNode from './PanToNode.svelte' + import InitialFitView from './InitialFitView.svelte' import { layoutAssetGraph } from './assetGraphLayout' import { buildDownstreamMap } from './graphTraversal' import { buildLineageDownstreamMap } from './boundedCascade' @@ -162,6 +163,10 @@ /** Hide the minimap when the canvas is too narrow for it to be worth the * space (e.g. stacked layout in a side panel). Defaults to shown. */ showMinimap?: boolean + /** Identity of the displayed graph (e.g. the pipeline folder). The + * initial viewport fit re-arms when it changes, so switching folders + * in-place gets a fresh fit. */ + viewportFitKey?: string } let { graph, @@ -188,7 +193,8 @@ onStartBoundedRun, boundPick, onPickEnd, - showMinimap = true + showMinimap = true, + viewportFitKey = '' }: Props = $props() // `${kind}:${path}` ids for the hovered / pinned runs (both script and flow @@ -677,11 +683,27 @@ // edges + paths → same layout. Renames *do* change the layout for // the renamed entry, since its id moves in the sort, but that's // expected: a rename is a path change, which is part of the input. + // A script with rw access to an asset yields both a write (script → asset) + // and a read/trigger (asset → script) edge — a 2-cycle. The layout resolves + // it producer-above-asset by omitting the backward direction from its + // input; the rendered edges are untouched (both arrows still drawn). + let writeEdgePairs = $derived( + new Set( + model.edges.filter((e) => e.kind === 'lineage-write').map((e) => `${e.source}\n${e.target}`) + ) + ) let layoutInput = $derived({ nodes: model.nodes .map((n) => ({ id: n.id, data: n.data })) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), edges: model.edges + .filter( + (e) => + !( + (e.kind === 'lineage-read' || e.kind === 'trigger-asset') && + writeEdgePairs.has(`${e.target}\n${e.source}`) + ) + ) .map((e) => ({ source: e.source, target: e.target })) .sort((a, b) => a.source === b.source @@ -1023,21 +1045,35 @@ --background-color={false} >
+ {#if showMinimap} + n.type === 'asset' - ? 'rgb(96 165 250 / 0.5)' + ? 'rgb(59 130 246 / 0.3)' : n.type === 'trigger' - ? 'rgb(251 191 36 / 0.5)' - : 'rgb(52 211 153 / 0.5)'} - nodeStrokeColor="transparent" - maskColor="rgb(0 0 0 / 0.2)" + ? 'rgb(245 158 11 / 0.3)' + : 'rgb(148 163 184 / 0.15)'} + nodeStrokeColor={(n) => + n.type === 'asset' + ? 'rgb(59 130 246 / 0.8)' + : n.type === 'trigger' + ? 'rgb(245 158 11 / 0.8)' + : 'rgb(100 116 139 / 0.7)'} + maskColor="rgb(100 116 139 / 0.12)" + maskStrokeColor="rgb(59 130 246 / 0.5)" + maskStrokeWidth={4} /> {/if} diff --git a/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte b/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte new file mode 100644 index 0000000000..eea1fb306d --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte @@ -0,0 +1,30 @@ + diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte index 33d44eb7a4..8dca18bd8e 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte @@ -31,6 +31,7 @@ mode, workspace, folder, + viewportFitKey = undefined, stackBelow = 680, persistDrafts = false, pathPrefix, @@ -95,6 +96,13 @@ workspace: string | undefined /** Folder the pipeline is scoped to — drives the autosave draft path. */ folder: string + /** Folder whose graph is actually *loaded* (not the route param). On an + * in-place folder switch the stale graph stays rendered while the new + * fetch is in flight, so keying the canvas's one-shot initial fit on + * `folder` would consume the new folder's fit on the old graph. Pages + * that stale-while-revalidate should pass the folder captured when the + * fetch resolved; defaults to `folder`. */ + viewportFitKey?: string /** Below this container width (px) the graph/details split stacks * vertically instead of side-by-side — for narrow side panels / AI * session previews. Defaults to 680. */ @@ -433,6 +441,7 @@ {onPickEnd} {panToNodeId} showMinimap={!stacked} + viewportFitKey={viewportFitKey ?? folder} /> {#if boundBar}{@render boundBar()}{/if} {#if mode === 'edit'} diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts index a5a92f61c2..24e7d4c2da 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts @@ -38,7 +38,14 @@ describe('layoutAssetGraph (tidy-tree with join breaks)', () => { // stays strictly left of every node of the right branch. const pos = layoutAssetGraph({ nodes: [n('root'), n('a'), n('b'), n('a1'), n('a2'), n('a3'), n('b1')], - edges: [e('root', 'a'), e('root', 'b'), e('a', 'a1'), e('a', 'a2'), e('a', 'a3'), e('b', 'b1')] + edges: [ + e('root', 'a'), + e('root', 'b'), + e('a', 'a1'), + e('a', 'a2'), + e('a', 'a3'), + e('b', 'b1') + ] }) const leftMax = Math.max(...['a', 'a1', 'a2', 'a3'].map((id) => pos.get(id)!.x)) const rightMin = Math.min(...['b', 'b1'].map((id) => pos.get(id)!.x)) @@ -83,14 +90,37 @@ describe('layoutAssetGraph (tidy-tree with join breaks)', () => { } }) - it('falls back to a grid on cyclic input', () => { + it('lays a 2-cycle out as a chain (feedback edge dropped, no grid)', () => { const pos = layoutAssetGraph({ nodes: [n('a'), n('b')], edges: [e('a', 'b'), e('b', 'a')] }) - expect(pos.size).toBe(2) - expect(pos.get('a')).toBeDefined() - expect(pos.get('b')).toBeDefined() + // First-in-input wins the top slot; the b→a feedback edge is ignored. + expect(pos.get('a')!.y).toBeLessThan(pos.get('b')!.y) + expect(pos.get('a')!.x).toBe(pos.get('b')!.x) + }) + + it('keeps the acyclic part of a graph layered when one cycle exists', () => { + // root → a ⇄ b → leaf: the a⇄b cycle must not degrade root/leaf layering. + const pos = layoutAssetGraph({ + nodes: [n('root'), n('a'), n('b'), n('leaf')], + edges: [e('root', 'a'), e('a', 'b'), e('b', 'a'), e('b', 'leaf')] + }) + expect(pos.get('root')!.y).toBeLessThan(pos.get('a')!.y) + expect(pos.get('a')!.y).toBeLessThan(pos.get('b')!.y) + expect(pos.get('b')!.y).toBeLessThan(pos.get('leaf')!.y) + // A linear chain stays in one column. + expect(new Set(['root', 'a', 'b', 'leaf'].map((id) => pos.get(id)!.x)).size).toBe(1) + }) + + it('handles a longer cycle without dropping nodes', () => { + const pos = layoutAssetGraph({ + nodes: [n('a'), n('b'), n('c')], + edges: [e('a', 'b'), e('b', 'c'), e('c', 'a')] + }) + expect(pos.size).toBe(3) + const ys = ['a', 'b', 'c'].map((id) => pos.get(id)!.y) + expect(new Set(ys).size).toBe(3) }) it('packs disjoint components side by side without overlap', () => { diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts index cf55db6e0a..c9d7d60355 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts @@ -53,8 +53,8 @@ interface Band { // // y comes from longest-path layering (same top-down orientation as before: // producers above, assets in the middle, consumers below). Returns positions -// (band centers) normalized so the component's min x,y = 0. Throws on cyclic -// input (caller falls back to a grid for the whole graph). +// (band centers) normalized so the component's min x,y = 0. Cyclic input is +// handled by dropping feedback edges (see the Kahn step below). function layoutComponent( nodes: GraphInput['nodes'], edges: GraphInput['edges'] @@ -76,21 +76,42 @@ function layoutComponent( if (!parents.get(e.target)!.includes(e.source)) parents.get(e.target)!.push(e.source) } - // Kahn topological order — also the cycle guard. + // Kahn topological order. Cycles don't abort the layout: when the queue + // drains with nodes left, the unplaced node with the fewest outstanding + // parents (first in input order on ties) is forced into the order and its + // not-yet-placed parent edges are dropped as feedback edges — layering and + // tree-building then operate on the resulting DAG while the rendered graph + // keeps every edge. (The caller already resolves write⇄read 2-cycles by + // omitting the read direction; this handles any longer cycle.) const indeg = new Map() for (const n of nodes) indeg.set(n.id, parents.get(n.id)!.length) const queue = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id) + const placed = new Set() const topo: string[] = [] - while (queue.length) { + while (topo.length < nodes.length) { + if (queue.length === 0) { + let pick: string | undefined + for (const n of nodes) { + if (placed.has(n.id)) continue + if (pick === undefined || indeg.get(n.id)! < indeg.get(pick)!) pick = n.id + } + parents.set( + pick!, + parents.get(pick!)!.filter((p) => placed.has(p)) + ) + queue.push(pick!) + } const cur = queue.shift()! + if (placed.has(cur)) continue + placed.add(cur) topo.push(cur) for (const c of children.get(cur)!) { + if (placed.has(c)) continue const d = indeg.get(c)! - 1 indeg.set(c, d) if (d === 0) queue.push(c) } } - if (topo.length !== nodes.length) throw new Error('cyclic asset graph') // Longest-path layering: a node sits one layer below its lowest parent. const layer = new Map() @@ -141,8 +162,7 @@ function layoutComponent( out.set(id, { x: left + w / 2, y: layer.get(id)! * LAYER_H }) const kids = treeChildren.get(id)! if (kids.length === 0) return - const kidsW = - kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * (kids.length - 1) + const kidsW = kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * (kids.length - 1) let cursor = left + (w - kidsW) / 2 for (const k of kids) { placeTree(k, cursor) @@ -221,7 +241,8 @@ function layoutComponent( // disjoint components, so it's excluded from component detection and instead // re-placed centered one layer above the whole packed graph. // -// Falls back to a stable grid if the component layout throws (cyclic inputs). +// Falls back to a stable grid if the component layout throws (defensive — +// cycles are already absorbed by feedback-edge dropping in layoutComponent). export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map { const byId = new Map() if (graph.nodes.length === 0) return byId diff --git a/frontend/src/lib/components/sessions/PipelineEditorView.svelte b/frontend/src/lib/components/sessions/PipelineEditorView.svelte index e0578ae139..0e3be64e36 100644 --- a/frontend/src/lib/components/sessions/PipelineEditorView.svelte +++ b/frontend/src/lib/components/sessions/PipelineEditorView.svelte @@ -75,6 +75,15 @@ workspace && folder ? await AssetService.getAssetsGraph({ workspace, folder }) : EMPTY_GRAPH ) + // Folder whose graph is actually rendered — `graphRes.current` is stale- + // while-revalidate on a folder retarget, so the canvas's one-shot initial + // fit is keyed on the folder captured when a graph lands, not on `path` + // (same rationale as the pipeline route page). + let viewportFitFolder = $state('') + $effect(() => { + if (graphRes.current) untrack(() => (viewportFitFolder = path)) + }) + // Deployed graph + the in-flight draft overlay (AI-built nodes render as plain // dashed unsaved drafts, same as manual drafts). The session // skips the route page's folder-wide asset prefetch (empty inferred maps); the @@ -304,6 +313,7 @@ { + if (graphRes.current) untrack(() => (viewportFitFolder = folder)) + }) + // Body / inferred-assets prefetch sweep. Watches `g.runnables`; for any // non-draft path we haven't fetched yet, fetches `getScriptByPath` and // `inferAssets`, and stores both in their respective only-add caches. @@ -2121,6 +2131,7 @@ Date: Thu, 2 Jul 2026 11:58:31 +0200 Subject: [PATCH 13/76] reserve more fixed height for unsaved-changes banner to avoid content shift (#9873) * fix(frontend): reserve fixed height for unsaved-changes banner to avoid content shift Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): remove border around reserved banner slot and shrink it Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): tighten top padding under the unsaved-changes banner Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): give unsaved-changes banner buttons minimal vertical breathing room Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): drop redundant Metadata section title in trigger and script editors Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): tuck schedule editor labels under summary to match convention Co-Authored-By: Claude Opus 4.8 (1M context) * revert(frontend): keep Metadata section title in ScriptBuilder for a separate PR Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): drop leftover header-content margin on headless Section Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): drop top padding above resource editor first field Co-Authored-By: Claude Opus 4.8 (1M context) * style(frontend): match variable editor bottom padding to resource Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): add Path label in new resource form to match edit Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): reserve half the banner height to halve the idle gap Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): reserve a third of the banner height when idle Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't reserve banner slot or tighten top for new entities Gate the reserved-height slot and the tight content top padding on the banner's baseline (bannerReserved) instead of merely on the banner snippet being present, so new-entity drawers keep normal top spacing and add no empty slot. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(frontend): trim banner comments to the 4-line invariant limit Co-Authored-By: Claude Opus 4.8 (1M context) * docs(frontend): describe partial-reserve banner behavior accurately Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/AppConnectInner.svelte | 32 ++++--- .../lib/components/LocalDraftBanner.svelte | 91 +++++++++++-------- .../src/lib/components/ResourceEditor.svelte | 2 +- .../components/ResourceEditorDrawer.svelte | 2 + frontend/src/lib/components/Section.svelte | 2 +- .../src/lib/components/VariableEditor.svelte | 4 +- .../common/drawer/DrawerContent.svelte | 44 ++++++--- .../azure/AzureTriggerEditorInner.svelte | 2 + .../email/EmailTriggerEditorInner.svelte | 4 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 2 + .../triggers/http/RouteEditorInner.svelte | 4 +- .../kafka/KafkaTriggerEditorInner.svelte | 2 + .../mqtt/MqttTriggerEditorInner.svelte | 2 + .../native/NativeTriggerEditor.svelte | 2 +- .../nats/NatsTriggerEditorInner.svelte | 2 + .../PostgresTriggerEditorInner.svelte | 2 + .../schedules/ScheduleEditorInner.svelte | 6 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 2 + .../triggers/useTriggerDraftSync.svelte.ts | 12 +++ .../WebsocketTriggerEditorInner.svelte | 2 + 20 files changed, 148 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 9edd80dd54..ef632f5738 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -991,13 +991,15 @@
{:else if step == 2 && manual}
- + {#if deployTo}