From 772fafec8316a1e0c0e76b9a0737cc41d40a9a8c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 00:57:44 +0200 Subject: [PATCH 01/31] feat: make the home Build with AI composer dismissible, quiet the rest of the home page (#10930) * feat: let the home Build with AI composer be dismissed, and hide it in locked workspaces Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QjJhxHHqRqyEX7HsbPjetn * style: quiet the home tutorial banner down to an inline row Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QjJhxHHqRqyEX7HsbPjetn * style: enlarge the empty home page state Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QjJhxHHqRqyEX7HsbPjetn --------- Co-authored-by: Claude Opus 5 (1M context) --- .../lib/components/common/CloseButton.svelte | 5 +- .../src/lib/components/home/HomeAIChat.svelte | 177 ++++++++++++------ .../lib/components/home/NoItemFound.svelte | 2 +- .../lib/components/home/TutorialBanner.svelte | 62 +++--- .../src/routes/(root)/(logged)/+page.svelte | 11 +- 5 files changed, 150 insertions(+), 107 deletions(-) diff --git a/frontend/src/lib/components/common/CloseButton.svelte b/frontend/src/lib/components/common/CloseButton.svelte index d7d0a58a7b..264e478721 100644 --- a/frontend/src/lib/components/common/CloseButton.svelte +++ b/frontend/src/lib/components/common/CloseButton.svelte @@ -10,10 +10,12 @@ Icon?: any | undefined class?: string id?: string | undefined + /** Names the button: it has no text, so without this it reads as "button" to a screen reader. */ + title?: string | undefined onClick?: () => void | undefined | any } - let { noBg = false, small = false, Icon, class: className, id, onClick }: Props = $props() + let { noBg = false, small = false, Icon, class: className, id, title, onClick }: Props = $props() const dispatch = createEventDispatcher() @@ -22,6 +24,7 @@ on:click={() => (dispatch('close'), onClick?.())} on:pointerdown={(e) => e.stopPropagation()} {id} + {title} startIcon={{ icon: Icon ?? X }} iconOnly unifiedSize="sm" diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index d0e75538ca..41c2930596 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -20,24 +20,46 @@ -
+
- {#if showComposer} -
-
-

Build with AI

- Beta + {#if showComposer && !collapsed} + {#if !disabled} + +
+ setCollapsed(true)} />
- -
+ {/if} +
+

Build with AI

+ Beta +
+ +
+
+ {#if disabled} + +
+

+ {#if $aiUserDisabled} + Windmill AI is disabled in your account settings + {:else if freeTierExhausted} + You have used all of your free Windmill AI tokens + {:else} + No AI provider is configured + {/if} +

+
+ {#if $aiUserDisabled} + + + {:else} + + {/if} + +
+
+ {/if}
{/if}
- {#if showComposer} -
+ {#if showComposer && !collapsed} +
{#each homeAIExamples as example (example.label)}
+ {:else if showComposer} + + {:else}
{/if} - -
+ +
- {#if showComposer && disabled} -
-

- {#if $aiUserDisabled} - Windmill AI is disabled in your account settings - {:else if freeTierExhausted} - You have used all of your free Windmill AI tokens - {:else} - No AI provider is configured - {/if} -

- {#if $aiUserDisabled} - - - {:else} - - {/if} -
- {/if}
diff --git a/frontend/src/lib/components/home/NoItemFound.svelte b/frontend/src/lib/components/home/NoItemFound.svelte index ea90c8dd0d..94d6dfa1b8 100644 --- a/frontend/src/lib/components/home/NoItemFound.svelte +++ b/frontend/src/lib/components/home/NoItemFound.svelte @@ -30,7 +30,7 @@ {:else}
-
+
Get started by creating your first script, flow, or app
diff --git a/frontend/src/lib/components/home/TutorialBanner.svelte b/frontend/src/lib/components/home/TutorialBanner.svelte index 14d8b750c1..ef7b30c27e 100644 --- a/frontend/src/lib/components/home/TutorialBanner.svelte +++ b/frontend/src/lib/components/home/TutorialBanner.svelte @@ -1,6 +1,7 @@ {#if !isDismissed} -
-
- -
-
- {#if hasCompletedAny} - New tutorial available! - {:else} - Learn with interactive tutorials - {/if} -
-
- {#if hasCompletedAny} - Continue your learning journey and master new Windmill skills. - {:else} - Get started quickly with step-by-step guides on building flows, scripts, and more. - {/if} -
-
-
-
- - -
+ +
+ + {#if hasCompletedAny} + New tutorial available! + {:else} + First time? + {/if} + + +
{/if} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 38189c22ce..5e9bda4e2c 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -263,13 +263,14 @@
+ + + status and on the workspace inside the component, which owns its own vertical spacing + because the hero and the bare connect row want different amounts of it. --> {#if isGlobalAiEnabled()} -
- -
+ {/if} {#if $workspaceStore == 'admins'} @@ -280,8 +281,6 @@
{/if} - - (showCreateButtons = v)} /> {#if tab == 'hub'} From 74c1813f983f12d9cb4093b93685ee3a30163aaa Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 01:01:01 +0200 Subject: [PATCH 02/31] chore(main): release 1.801.0 (#10921) * chore(main): release 1.801.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 ++ backend/Cargo.lock | 179 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 20 files changed, 151 insertions(+), 133 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ab80d05f6b..238a566433 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.800.1" + ".": "1.801.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc8ecb481..2a7512989f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.801.0](https://github.com/windmill-labs/windmill/compare/v1.800.1...v1.801.0) (2026-09-01) + + +### Features + +* **ai-chat:** make reusable skills ai_skill resources you select per workspace ([#10914](https://github.com/windmill-labs/windmill/issues/10914)) ([cfcfe29](https://github.com/windmill-labs/windmill/commit/cfcfe298dd9ab50196bd64926ef78c4563f58c2c)) +* **ai-sessions:** show a running session across tabs and reload finished turns ([#10916](https://github.com/windmill-labs/windmill/issues/10916)) ([816dc9d](https://github.com/windmill-labs/windmill/commit/816dc9dcd2c310e499d2d210a0abcd403469f29c)) +* edit folders and groups in a drawer that saves once ([#10873](https://github.com/windmill-labs/windmill/issues/10873)) ([5d5ad4e](https://github.com/windmill-labs/windmill/commit/5d5ad4e8974e076ef53a26a5584e4209255a2248)) +* make the home Build with AI composer dismissible, quiet the rest of the home page ([#10930](https://github.com/windmill-labs/windmill/issues/10930)) ([772fafe](https://github.com/windmill-labs/windmill/commit/772fafec8316a1e0c0e76b9a0737cc41d40a9a8c)) + + +### Bug Fixes + +* let a principal without a login account own a draft ([#10925](https://github.com/windmill-labs/windmill/issues/10925)) ([94af8d0](https://github.com/windmill-labs/windmill/commit/94af8d0fb5aceebe83936fd6761c6c1c02c75323)) +* resolve chat path links against the session's operating workspace ([#10924](https://github.com/windmill-labs/windmill/issues/10924)) ([9074de2](https://github.com/windmill-labs/windmill/commit/9074de25ea730ca02653c9a2e2b8b99eda6f3137)) +* tolerate string app_id in GHES app config deserialization ([#10923](https://github.com/windmill-labs/windmill/issues/10923)) ([af8ff38](https://github.com/windmill-labs/windmill/commit/af8ff3868748412cb658c803ebc8a71edc3cd8fb)) + ## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c79abcb78e..913e0eda75 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -970,9 +970,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -981,9 +981,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -7262,9 +7262,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "bitflags 2.13.1", "libc", @@ -7870,10 +7870,11 @@ dependencies = [ [[package]] name = "mysql_async" -version = "0.37.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2" +checksum = "40d11da0e2d9fad4640c9f9198ee431c6d68444568f83ef1f10f3367270071e4" dependencies = [ + "arc-swap", "bytes", "crossbeam-queue", "crossbeam-utils", @@ -11753,9 +11754,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", ] @@ -14746,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-nats", @@ -14831,7 +14832,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.800.1" +version = "1.801.0" dependencies = [ "async-stream", "async-trait", @@ -14864,7 +14865,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14877,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "argon2", @@ -15017,7 +15018,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15040,7 +15041,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15057,7 +15058,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15083,7 +15084,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.800.1" +version = "1.801.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15093,7 +15094,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15110,7 +15111,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15132,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15155,7 +15156,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15171,7 +15172,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15193,7 +15194,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15214,7 +15215,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15228,7 +15229,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-nats", @@ -15263,7 +15264,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15288,7 +15289,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15316,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15338,7 +15339,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15358,7 +15359,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15396,7 +15397,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15424,7 +15425,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.800.1" +version = "1.801.0" dependencies = [ "lazy_static", "serde", @@ -15436,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.800.1" +version = "1.801.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15460,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15474,7 +15475,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.800.1" +version = "1.801.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15509,7 +15510,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.800.1" +version = "1.801.0" dependencies = [ "chrono", "lazy_static", @@ -15523,7 +15524,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15542,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.800.1" +version = "1.801.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15646,7 +15647,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.800.1" +version = "1.801.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15665,7 +15666,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.800.1" +version = "1.801.0" dependencies = [ "regex", "serde", @@ -15680,7 +15681,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15707,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "futures", @@ -15724,7 +15725,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.800.1" +version = "1.801.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15740,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -15761,7 +15762,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -15792,7 +15793,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "arc-swap", @@ -15817,7 +15818,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-stream", @@ -15851,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "futures", @@ -15869,7 +15870,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.800.1" +version = "1.801.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15878,7 +15879,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -15890,7 +15891,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -15902,7 +15903,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "gosyn", @@ -15914,7 +15915,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -15926,7 +15927,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -15938,7 +15939,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "nu-parser", @@ -15949,7 +15950,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15960,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15972,7 +15973,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15983,7 +15984,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-recursion", @@ -16005,7 +16006,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -16017,7 +16018,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -16031,7 +16032,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16048,7 +16049,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -16061,7 +16062,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde", @@ -16073,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -16091,7 +16092,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16107,7 +16108,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16123,7 +16124,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -16137,7 +16138,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-recursion", @@ -16176,7 +16177,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "const_format", @@ -16216,7 +16217,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.800.1" +version = "1.801.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16227,7 +16228,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-recursion", @@ -16262,7 +16263,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16286,7 +16287,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16319,7 +16320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16346,7 +16347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16379,7 +16380,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16399,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16434,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16469,7 +16470,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16492,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16516,7 +16517,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-nats", @@ -16540,7 +16541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16575,7 +16576,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16603,7 +16604,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-trait", @@ -16628,7 +16629,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16647,7 +16648,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-once-cell", @@ -16764,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.800.1" +version = "1.801.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 491f3cb48a..ef62595562 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.800.1" +version = "1.801.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.800.1" +version = "1.801.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 1b09928903..15a288cb24 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.800.1" +version = "1.801.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.800.1" +version = "1.801.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.800.1" +version = "1.801.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.800.1" +version = "1.801.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 752d80a1e3..4aae8cad13 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.800.1" +version = "1.801.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3daa0b6133..29266603da 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.800.1 + version: 1.801.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9085d6a365..db9077b9cc 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.800.1"; +export const VERSION = "v1.801.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 52b000d4c2..3d92b31a36 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.800.1"; +export const VERSION = "1.801.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 536b178852..66bb47f9ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.800.1", + "version": "1.801.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.800.1", + "version": "1.801.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ed8c18768f..ab3768066a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.800.1", + "version": "1.801.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 cf1bb1ec17..1fcca67d5f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.800.1" +wmill = ">=1.801.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0350536df4..c69767b582 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.800.1 + version: 1.801.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index df980c46a0..d656f7f8a1 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.800.1' + ModuleVersion = '1.801.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 00582eecd2..ce2cf1b813 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.800.1" +version = "1.801.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 122221df80..f660ecd864 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.800.1", + "version": "1.801.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 802d99056a..5a14a8d431 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.800.1", + "version": "1.801.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 16cbd6c8ab..a0a936e59b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.800.1 +1.801.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 197da5a8a8..3005098884 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.800.1", + "version": "1.801.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.800.1", + "version": "1.801.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index eb1eded0cc..5b5d6d84c0 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.800.1", + "version": "1.801.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 337154b8304a5969f35216add627b5c1153c0f6c Mon Sep 17 00:00:00 2001 From: "Nathan A. Ferch" Date: Wed, 2 Sep 2026 02:51:38 -0400 Subject: [PATCH 03/31] fix: connect to dev server instead of localhost (#10912) * fix: connect to dev server instead of localhost * fix: derive WebSocket scheme from location.protocol Mirror the protocol-aware pattern used by initSqlWebSocket in dev.ts so the WebSocket connects over wss:// when the dev server is reached through an HTTPS proxy/tunnel, avoiding mixed-content blocking. * refactor: drop now-unused port parameter of wmillTsDev Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HsfdN82yP88qyQ3h8Lwv2v --------- Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 5 (1M context) --- cli/src/commands/app/dev.ts | 2 +- cli/src/commands/app/wmillTsDev.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index b45666a1e1..2dac8639e2 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -603,7 +603,7 @@ async function dev(opts: DevOptions, appFolder?: string) { build.onLoad( { filter: /.*/, namespace: "wmill-virtual" }, (args: any) => { - const contents = wmillTs(port); + const contents = wmillTs(); log.info( colors.yellow( `[wmill-virtual] Loading virtual module: ${args.path}`, diff --git a/cli/src/commands/app/wmillTsDev.ts b/cli/src/commands/app/wmillTsDev.ts index 7cb2ea328b..821060d8e4 100644 --- a/cli/src/commands/app/wmillTsDev.ts +++ b/cli/src/commands/app/wmillTsDev.ts @@ -1,5 +1,5 @@ //comment this line and last to dev -export function wmillTsDev(port: number) { return ` +export function wmillTsDev() { return ` let reqs: Record = {} let ws: WebSocket | null = null let wsReady: Promise @@ -10,7 +10,7 @@ function initWebSocket() { wsReadyResolve = resolve }) - ws = new WebSocket('ws://localhost:${port}') + ws = new WebSocket((window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host) ws.onopen = () => { console.log('[wmill] WebSocket connected') @@ -157,4 +157,4 @@ export function streamJob( ws?.send(JSON.stringify({ jobId, type: 'streamJob', reqId })) }) } -`} \ No newline at end of file +`} From 95b6bbd46ada11d96a914ae5b0e92aba4dd02530 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 13:49:31 +0200 Subject: [PATCH 04/31] fix: preselect first row of AI agent and AI sandbox insert panes (#10937) * fix: preselect first row of AI agent and AI sandbox insert panes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019vjSnhnewkUbx6mR9iCeK8 * fix: keep Enter for focused controls in the AI insert panes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019vjSnhnewkUbx6mR9iCeK8 --- .../components/copilot/StepGenQuick.svelte | 4 + .../flows/content/FlowInputsQuick.svelte | 12 +- .../flows/map/InsertModuleInner.svelte | 134 ++++++++++++++---- 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/copilot/StepGenQuick.svelte b/frontend/src/lib/components/copilot/StepGenQuick.svelte index d09e554240..22858c98c9 100644 --- a/frontend/src/lib/components/copilot/StepGenQuick.svelte +++ b/frontend/src/lib/components/copilot/StepGenQuick.svelte @@ -46,6 +46,10 @@ let input: TextInput | undefined = $state() + export function focus() { + input?.focus() + } + $effect(() => { preFilter && setTimeout(() => { diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index cf9d008a27..dd72dc95af 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -15,12 +15,11 @@ workspaceStore } from '$lib/stores' import type { SupportedLanguage } from '$lib/common' - import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte' + import { createEventDispatcher, getContext, untrack } from 'svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import { type Script, type ScriptLang, type HubScriptKind } from '$lib/gen' import ListFiltersQuick from '$lib/components/home/ListFiltersQuick.svelte' import { ExternalLink, Folder, User, X } from 'lucide-svelte' - import type { FlowEditorContext } from '../../flows/types' import { fade } from 'svelte/transition' import { flip } from 'svelte/animate' import { Button } from '$lib/components/common' @@ -87,8 +86,6 @@ let hubCompletions: HubCompletion[] = $state([]) - const { insertButtonOpen } = getContext('FlowEditorContext') - let selected: { kind: 'owner' | 'integrations'; name: string | undefined } | undefined = $state(undefined) @@ -221,13 +218,6 @@ selectedByKeyboard = index } - onMount(() => { - $insertButtonOpen = true - }) - - onDestroy(() => { - $insertButtonOpen = false - }) let langs = $derived( processInlineLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) .map((l) => [defaultScriptLanguages[l], l]) diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index d1e65de1ea..7c75d97fab 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -3,7 +3,7 @@ +
dispatch('close')} {disableAi} on:insert @@ -230,7 +307,10 @@ selected={selectedKind === 'aiagent'} onSelect={() => { selectedKind = 'aiagent' + selectedByKeyboard = 0 loadSavedAgents() + // Clicking leaves focus on this button, where Enter would only re-select it. + stepGen?.focus() }} /> {/if} @@ -240,6 +320,8 @@ selected={selectedKind === 'aisandbox'} onSelect={() => { selectedKind = 'aisandbox' + selectedByKeyboard = 0 + stepGen?.focus() }} /> {/if} @@ -248,19 +330,25 @@ {/if} {#if selectedKind === 'aiagent'} -
+
{#if savedAgentsLoading}
@@ -268,21 +356,23 @@
{:else if filteredAgents.length > 0}
Saved agents
- {#each filteredAgents as agent (agent.path)} + {#each filteredAgents as agent, i (agent.path)} {/each} {:else} @@ -297,17 +387,11 @@
{ - dispatch('close') - dispatch('new', { - kind: 'script', - inlineScript: { - language: 'bun', - kind: 'script', - subkind: 'claudesandbox' - } - }) - }} + neutral + returnIcon + selected={aiSelected === 0} + onSelect={newClaudeSandbox} + onHover={() => (selectedByKeyboard = 0)} />
{:else} From d3747d62555ebcb09c78cfabcaa3b6177758d6ea Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 17:17:58 +0200 Subject: [PATCH 05/31] feat(sessions): offer the item you came from when starting a new session (#10940) * fix: connect to dev server instead of localhost * fix: derive WebSocket scheme from location.protocol Mirror the protocol-aware pattern used by initSqlWebSocket in dev.ts so the WebSocket connects over wss:// when the dev server is reached through an HTTPS proxy/tunnel, avoiding mixed-content blocking. * refactor: drop now-unused port parameter of wmillTsDev Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HsfdN82yP88qyQ3h8Lwv2v * feat(sessions): offer the item you came from when starting a new session Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm * docs(sessions): state the new-session seed latch's real lifetime Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm * fix(sessions): let Enter act on the focused answer of the new-session offer Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm * feat(sessions): start on the item instead of resuming a stale session from the rail Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm * fix(sessions): hand the rail's item entry through the editor's own hand-off Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm * fix(sessions): snap the rail toggle back when a session switch does not navigate Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm --------- Co-authored-by: Nathan A. Ferch Co-authored-by: Claude Opus 5 (1M context) --- .../sessions/SessionModeSwitch.svelte | 23 ++- .../components/sessions/SessionPicker.svelte | 74 +++++++- .../sessions/openInSessionContext.ts | 36 +++- .../sessions/sessionSwitch.svelte.ts | 104 +++++++++-- .../components/sessions/sessionSwitch.test.ts | 163 +++++++++++++++++- 5 files changed, 374 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte index d721b6f16f..9a7fb20bd6 100644 --- a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte +++ b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte @@ -2,8 +2,9 @@ import { Building, MessagesSquare } from 'lucide-svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { enterSessionMode, exitSessionMode } from './sessionSwitch.svelte' + import { enterSessionModeFromNav, exitSessionMode } from './sessionSwitch.svelte' import { goto } from '$lib/navigation' + import { sendUserToast } from '$lib/toast' import { page } from '$app/state' import { base } from '$lib/base' @@ -17,11 +18,25 @@ onToggle }: { mode: 'nav' | 'session'; isCollapsed?: boolean; onToggle?: () => void } = $props() + // The group's highlighted side. Melt moves it on click, before the navigation + // that would change `mode`, so it is derived from the route (which wins once + // a switch navigates) and pushed back when one does not, or the rail would + // read "AI Sessions" on an editor page with the clicked side inert until + // "Workspace" was pressed first. + let selected: string | string[] | null | undefined = $derived(mode) + function onSelected(next: 'nav' | 'session') { if (next === mode) return onToggle?.() - if (next === 'session') void enterSessionMode() - else void exitSessionMode() + if (next === 'session') { + // An editor whose draft could not be persisted keeps the user on the + // page, as its own "Open in AI session" button does, rather than open a + // session on an older draft than the one on screen. + void enterSessionModeFromNav().catch((e) => { + selected = mode + sendUserToast(e instanceof Error ? e.message : String(e), true) + }) + } else void exitSessionMode() } // Pressing the already-active "Workspace" side goes home, so the toggle doubles @@ -41,7 +56,7 @@ child of the group's track — so the buttons fill the rail width only if those wrappers grow. `[&>*]:flex-1` makes every direct child split the track evenly. --> *]:w-full' : 'w-full [&>*]:flex-1'} > diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index 87462e301b..e5b62da0eb 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -1,5 +1,6 @@ {#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0} -
+
{#each displayedFilters as filter (filter)}
- {#if resourceType} + {#if icon} + {@const Icon = icon} + + {:else if resourceType} {@const SvelteComponent = appIconComponent(filter)} {:else if filter.startsWith('u/')} @@ -123,7 +140,7 @@
(expanded = !expanded)} From fdd3b36423344a2e1a464674179406581074e926 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 17:21:10 +0200 Subject: [PATCH 07/31] feat: workspace setting to hide the AI assistant, agent steps unaffected (#10941) * feat: workspace setting to hide the AI assistant, agent steps unaffected Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: load workspace AI config on cold /sessions load and say hidden, not disabled Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: follow workspace switches on /sessions gate and drop deprecated button size Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: key the /sessions hidden-assistant gate on the acting workspace's own config Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: tag the /sessions hidden-assistant verdict with its workspace and drop superseded reads Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: overlay the /sessions hidden-assistant gate so warm sessions survive workspace switches Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: hide the pipeline insert menu AI prompt and refuse chat turns where the assistant is hidden Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: shrink the home Build with AI / CLI / Hub line to a flush hint row Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL * fix: frame the workspace toggle as hide AI sessions at the bottom of the AI settings Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL --------- Co-authored-by: Claude Fable 5.1 --- .../tests/workspaces.rs | 62 ++- backend/windmill-api/openapi.yaml | 7 + backend/windmill-api/src/ai.rs | 6 + backend/windmill-api/src/workspaces.rs | 38 +- frontend/src/lib/aiStore.test.ts | 18 + frontend/src/lib/aiStore.ts | 14 +- .../AssetGraph/PipelineInsertMenu.svelte | 45 +- .../components/copilot/AIFormAssistant.svelte | 79 +-- .../components/copilot/AIFormSettings.svelte | 67 +-- .../src/lib/components/copilot/CronGen.svelte | 124 +++-- .../lib/components/copilot/RegexGen.svelte | 48 +- .../lib/components/copilot/ResourceGen.svelte | 130 ++--- .../lib/components/copilot/ScriptFix.svelte | 2 +- .../lib/components/copilot/ScriptGen.svelte | 2 +- .../components/copilot/StepGenQuick.svelte | 6 +- .../components/copilot/StepInputsGen.svelte | 2 +- .../components/copilot/chat/AIButton.svelte | 2 +- .../lib/components/copilot/chat/AIChat.svelte | 12 +- .../copilot/chat/AIChatManager.svelte.ts | 13 + .../copilot/chat/AiChatLayout.svelte | 9 + .../flows/content/FlowInputsQuick.svelte | 1 + .../src/lib/components/home/HomeAIChat.svelte | 17 +- .../raw_apps/RawAppTemplatePicker.svelte | 121 ++-- .../search/GlobalSearchModal.svelte | 25 +- .../sessions/OpenInSessionButton.svelte | 10 +- .../workspaceSettings/AISettings.svelte | 46 +- .../src/routes/(root)/(logged)/+layout.svelte | 45 +- .../(root)/(logged)/sessions/+page.svelte | 521 ++++++++++-------- 28 files changed, 860 insertions(+), 612 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 65fe4241cc..25312cae27 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -889,6 +889,53 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row( Ok(()) } +/// A workspace with no provider of its own is served the instance config, but the +/// `copilot_disabled` flag must still come from the workspace's own row. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_copilot_info_keeps_workspace_copilot_disabled_over_instance_fallback( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2") + .bind(json!({ "copilot_disabled": true })) + .bind("test-workspace") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) \ + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind("ai_config") + .bind(json!({ + "providers": { + "openai": { + "resource_path": "u/test-user/openai_instance", + "models": ["gpt-4o-mini"] + } + } + })) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_info"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert_eq!( + settings["providers"]["openai"]["models"][0], "gpt-4o-mini", + "instance providers are still served" + ); + assert_eq!(settings["copilot_disabled"], true); + + Ok(()) +} + #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_error_handler_instance_alerts_fallback(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; @@ -941,7 +988,12 @@ async fn test_error_handler_instance_alerts_fallback(db: Pool) -> anyh .send() .await .unwrap(); - assert_eq!(resp.status(), 200, "disable on fork: {}", resp.text().await?); + assert_eq!( + resp.status(), + 200, + "disable on fork: {}", + resp.text().await? + ); assert!(!stored().await?); Ok(()) @@ -1044,9 +1096,11 @@ async fn test_create_service_account_drops_orphaned_group_memberships( .await?; // Same username, different workspace, and very much alive — must not be touched. - sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')") - .execute(&db) - .await?; + sqlx::query( + "INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')", + ) + .execute(&db) + .await?; sqlx::query( "INSERT INTO group_ (workspace_id, name, summary) VALUES ('other-workspace', 'all', 'All users'), diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 29266603da..2878797e69 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -27135,6 +27135,13 @@ components: type: object additionalProperties: $ref: "#/components/schemas/ModelPriceOverride" + copilot_disabled: + type: boolean + description: >- + Hides the Windmill AI assistant (chat, sessions, code generation, completion, + fixes) from the workspace UI. Read from the workspace's own settings even when + the providers served fall back to the instance config. AI agent steps and the + AI sandbox in flows are unaffected. FreeTierInfo: type: object diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index c684830b27..e70cc19544 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -445,6 +445,12 @@ pub struct AIConfig { /// Only models whose rates differ from the built-in table are stored. #[serde(skip_serializing_if = "Option::is_none")] pub model_pricing: Option>, + /// Hides the Windmill AI assistant (chat, sessions, generation, completion, fixes) from + /// the workspace UI. Only the workspace's own row is consulted: the flag holds even when + /// the providers served come from the instance config or the free tier. AI agent steps + /// and the AI sandbox are unaffected, so the providers stay in force. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub copilot_disabled: bool, } /// Negotiated rates in USD per million tokens. An unset cache rate is read as the diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 76378e335a..da8532e9d1 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -146,6 +146,7 @@ async fn edit_copilot_config( .await?; let workspace_has_config = ai_config.has_providers(); + let copilot_disabled = ai_config.copilot_disabled; let instance_ai_config = sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") .fetch_optional(&db) @@ -158,7 +159,7 @@ async fn edit_copilot_config( .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) .filter(|c| c.has_providers()); - let effective_ai_config = if workspace_has_config { + let mut effective_ai_config = if workspace_has_config { ai_config } else if let Some(instance_config) = instance_config_with_providers { instance_config @@ -172,6 +173,7 @@ async fn edit_copilot_config( } else { AIConfig::default() }; + effective_ai_config.copilot_disabled = copilot_disabled; Ok(Json(EditCopilotConfigResponse { effective_ai_config, @@ -207,6 +209,9 @@ async fn get_copilot_info( )) })?; + let copilot_disabled = workspace_ai_config + .as_ref() + .is_some_and(|c| c.0.copilot_disabled); let instance_config = sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") .fetch_optional(&db) @@ -215,20 +220,23 @@ async fn get_copilot_info( // A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the // free-tier fallback, matching the proxy and edit_copilot_config paths. .filter(|c| c.has_providers()); - if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) { - Ok(Json(workspace_ai_config.0)) - } else if let Some(instance_config) = instance_config { - Ok(Json(instance_config)) - } else if let Some(free_config) = - crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await? - { - // Nothing configured: fall back to Windmill's free tier (EE-only). The config - // carries a `free_tier` marker even once the user's grant is spent — with no - // providers, but telling the client *why* AI is off. - Ok(Json(free_config)) - } else { - Ok(Json(AIConfig::default())) - } + let mut effective = + if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) { + workspace_ai_config.0 + } else if let Some(instance_config) = instance_config { + instance_config + } else if let Some(free_config) = + crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await? + { + // Nothing configured: fall back to Windmill's free tier (EE-only). The config + // carries a `free_tier` marker even once the user's grant is spent — with no + // providers, but telling the client *why* AI is off. + free_config + } else { + AIConfig::default() + }; + effective.copilot_disabled = copilot_disabled; + Ok(Json(effective)) } #[cfg(feature = "enterprise")] diff --git a/frontend/src/lib/aiStore.test.ts b/frontend/src/lib/aiStore.test.ts index fed6147b62..be8980e93a 100644 --- a/frontend/src/lib/aiStore.test.ts +++ b/frontend/src/lib/aiStore.test.ts @@ -35,6 +35,24 @@ describe('setCopilotInfo legacy /thinking migration', () => { expect(info.aiModels.map((m) => m.model)).toEqual(['claude-sonnet-4-6']) }) + it('keeps the models but turns the assistant off when the workspace disabled it', () => { + setCopilotInfo({ + providers: { + anthropic: { + resource_path: 'u/admin/anthropic', + models: ['claude-sonnet-4-6'] + } + }, + copilot_disabled: true + }) + + const info = get(copilotInfo) + expect(info.enabled).toBe(false) + expect(info.workspaceDisabled).toBe(true) + // The providers still describe what AI agent steps can run on. + expect(info.aiModels.map((m) => m.model)).toEqual(['claude-sonnet-4-6']) + }) + it('defaults provider web search on unless explicitly disabled', () => { setCopilotInfo({ providers: { diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 2d6a98c625..6ac98d69e8 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -41,6 +41,10 @@ export const copilotSessionModel = writable( export const copilotInfo = writable<{ enabled: boolean + // The workspace hid the assistant (`ai_config.copilot_disabled`). `enabled` is then false + // whatever the providers say, and the AI entry points that nudge "configure AI" when + // `enabled` is off render nothing at all instead. + workspaceDisabled: boolean codeCompletionModel?: AIProviderModel defaultModel?: AIProviderModel metadataModel?: AIProviderModel @@ -56,6 +60,7 @@ export const copilotInfo = writable<{ freeTier?: FreeTierInfo }>({ enabled: false, + workspaceDisabled: false, codeCompletionModel: undefined, defaultModel: undefined, metadataModel: undefined, @@ -71,7 +76,7 @@ export const copilotInfo = writable<{ aiUserDisabled.subscribe((disabled) => { copilotInfo.update((info) => ({ ...info, - enabled: info.aiModels.length > 0 && !disabled + enabled: info.aiModels.length > 0 && !disabled && !info.workspaceDisabled })) }) @@ -126,9 +131,11 @@ export function setCopilotInfo(aiConfig: AIConfig) { return model }) + const workspaceDisabled = aiConfig.copilot_disabled === true copilotInfo.set({ - // Providers are configured; the per-user opt-out is the only thing that can gate it off. - enabled: !get(aiUserDisabled), + // Providers are configured; only the workspace or per-user opt-outs can gate it off. + enabled: !workspaceDisabled && !get(aiUserDisabled), + workspaceDisabled, // Strip the deprecated /thinking suffix from the configured model slots too, // otherwise a workspace whose default still carries it sends an invalid model id. codeCompletionModel: stripModelSuffix(aiConfig.code_completion_model), @@ -146,6 +153,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { copilotInfo.set({ enabled: false, + workspaceDisabled: aiConfig.copilot_disabled === true, codeCompletionModel: undefined, defaultModel: undefined, metadataModel: undefined, diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte index e3c6c04378..e341707250 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte @@ -28,6 +28,7 @@ -
-
- -

AI can help with these inputs

- - {#snippet fallback()} - - {/snippet} - +

AI can help with these inputs

+ + {#snippet fallback()} + + {/snippet} + +
+
+

+ {instructions + ? 'Instructions: ' + instructions + : 'No AI instructions provided. Click edit to add guidance for AI form filling.'} +

+
-
-

- {instructions - ? 'Instructions: ' + instructions - : 'No AI instructions provided. Click edit to add guidance for AI form filling.'} -

-
-
+{/if} diff --git a/frontend/src/lib/components/copilot/AIFormSettings.svelte b/frontend/src/lib/components/copilot/AIFormSettings.svelte index 44151a01b3..ef627ace4b 100644 --- a/frontend/src/lib/components/copilot/AIFormSettings.svelte +++ b/frontend/src/lib/components/copilot/AIFormSettings.svelte @@ -3,6 +3,7 @@ import Label from '../Label.svelte' import Toggle from '../Toggle.svelte' import Tooltip from '../Tooltip.svelte' + import { copilotInfo } from '$lib/aiStore' interface Props { prompt?: string | undefined @@ -12,35 +13,37 @@ let { prompt = $bindable(undefined), type = 'script' }: Props = $props() -
- { - if (prompt !== undefined) { - prompt = undefined - } else { - prompt = '' - } - }} - options={{ right: `Enable filling ${type} inputs with AI` }} - /> - {#if prompt !== undefined} -
- -
- {/if} -
+{#if !$copilotInfo.workspaceDisabled} +
+ { + if (prompt !== undefined) { + prompt = undefined + } else { + prompt = '' + } + }} + options={{ right: `Enable filling ${type} inputs with AI` }} + /> + {#if prompt !== undefined} +
+ +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/copilot/CronGen.svelte b/frontend/src/lib/components/copilot/CronGen.svelte index 6a8dc6892e..94715a0f18 100644 --- a/frontend/src/lib/components/copilot/CronGen.svelte +++ b/frontend/src/lib/components/copilot/CronGen.svelte @@ -79,66 +79,68 @@ }) - - {#snippet trigger()} -
- {:else} -
-

Enable Windmill AI in the workspace settings

-
- {/if} -
- {/snippet} - + }} + disabled={instructions.length == 0} + startIcon={{ icon: Wand2 }} + /> +
+ {:else} +
+

Enable Windmill AI in the workspace settings

+
+ {/if} +
+ {/snippet} + +{/if} diff --git a/frontend/src/lib/components/copilot/RegexGen.svelte b/frontend/src/lib/components/copilot/RegexGen.svelte index e61ab5cbf2..f399726988 100644 --- a/frontend/src/lib/components/copilot/RegexGen.svelte +++ b/frontend/src/lib/components/copilot/RegexGen.svelte @@ -1,5 +1,5 @@ - - {#snippet trigger()} - +{#if !$copilotInfo.workspaceDisabled} + + {#snippet trigger()}
- - {/snippet} - + {/snippet} + +{/if} diff --git a/frontend/src/lib/components/copilot/ResourceGen.svelte b/frontend/src/lib/components/copilot/ResourceGen.svelte index 34534285b4..2fc3cfa646 100644 --- a/frontend/src/lib/components/copilot/ResourceGen.svelte +++ b/frontend/src/lib/components/copilot/ResourceGen.svelte @@ -119,69 +119,71 @@ }) - - {#snippet trigger()} - -
- {:else} -
-

Enable Windmill AI in the workspace settings

-
- {/if} -
- {/snippet} - + }} + disabled={instructions.length == 0} + startIcon={{ icon: Wand2 }} + > + Generate + +
+ {:else} +
+

Enable Windmill AI in the workspace settings

+
+ {/if} +
+ {/snippet} + +{/if} diff --git a/frontend/src/lib/components/copilot/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index d6ccb26ab8..f586ad1aaf 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -77,7 +77,7 @@ const sessionScopedManager = getContext('aiChatManager') -{#if SUPPORTED_LANGUAGES.has(lang)} +{#if SUPPORTED_LANGUAGES.has(lang) && !$copilotInfo.workspaceDisabled} {#if sessionScopedManager}
diff --git a/frontend/src/lib/components/copilot/StepInputsGen.svelte b/frontend/src/lib/components/copilot/StepInputsGen.svelte index 5b1ddb741b..c9ef616aa5 100644 --- a/frontend/src/lib/components/copilot/StepInputsGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputsGen.svelte @@ -226,7 +226,7 @@ input_name2: expression2 Fill inputs {/if} - {:else} + {:else if !$copilotInfo.workspaceDisabled} togglePanel() })} -{:else} +{:else if !$copilotInfo.workspaceDisabled} {#snippet trigger()} {@render button({ onPress: () => togglePanel() })} diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 414be0a741..fd95613f2e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -69,11 +69,13 @@ : freeTierExhausted ? '' : !hasCopilot - ? $aiUserDisabled - ? 'Windmill AI is disabled in your account settings' - : isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + ? $copilotInfo.workspaceDisabled + ? 'Windmill AI is hidden in this workspace' + : $aiUserDisabled + ? 'Windmill AI is disabled in your account settings' + : isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' : aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 61ed493a66..fb54204fe9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2350,6 +2350,10 @@ export class AIChatManager { } openChat = () => { + // Nothing may open the docked pane in a workspace that hid the assistant. + if (get(copilotInfo).workspaceDisabled) { + return + } chatState.size = this.savedSize > 0 ? this.savedSize : DEFAULT_SIZE localStorage.setItem('ai-chat-open', 'true') } @@ -2361,6 +2365,9 @@ export class AIChatManager { } toggleOpen = () => { + if (chatState.size === 0 && get(copilotInfo).workspaceDisabled) { + return + } if (chatState.size > 0) { this.savedSize = chatState.size } @@ -2880,6 +2887,12 @@ export class AIChatManager { sendUserToast('This action needs the AI chat. Start an AI session to continue.', true) return } + // The workspace hid the assistant: every entry point is gone from the UI, so a turn + // reaching here comes from a path that missed the gate and would stream unseen. + if (!this.isSessionChat && get(copilotInfo).workspaceDisabled) { + sendUserToast('Windmill AI is hidden in this workspace.', true) + return + } // Refused before anything mutates, so there is nothing to unwind: the // draft (already taken by the composer) goes back where the user can see // it, and the turn never starts. Only the message's own send restores it diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a9df4816c..4f3ef368ad 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -7,6 +7,7 @@ import { userStore, workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' + import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' import Button from '$lib/components/common/button/Button.svelte' @@ -66,6 +67,14 @@ } }) + // The pane restores its last open state from localStorage before the config can say + // the workspace hid the assistant; close it as soon as that is known. + $effect(() => { + if ($copilotInfo.workspaceDisabled && chatState.size > 0) { + aiChatManager.closeChat() + } + }) + const historyManager = aiChatManager.historyManager historyManager.init() diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index dd72dc95af..eb875973dc 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -245,6 +245,7 @@ // on indices that render nothing. let showAiRows = $derived( !disableAi && + !$copilotInfo.workspaceDisabled && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 41c2930596..2d57f669b7 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -96,11 +96,18 @@ // The composer hands off to /sessions, which refuses operators — so hide it from them (the // prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. - let showComposer = $derived(prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace) + let showComposer = $derived( + prefersSessionHandoff($userStore?.operator) && + !runOnlyWorkspace && + !$copilotInfo.workspaceDisabled + ) - // The hero's margins are for the full block; around the lone button row left by a collapsed, - // operator or run-only view they would just be empty page. - let outerSpacing = $derived(showComposer && !collapsed ? 'mt-20 mb-16' : 'mt-8 mb-2') + // The hero's margins and centered column are for the full block. The lone button row left + // by a collapsed, operator, run-only or hidden-assistant view is a hint line and should + // cost the page almost nothing: no top margin, and the content column's full width so it + // hugs the right edge instead of floating centered in empty space. + let hero = $derived(showComposer && !collapsed) + let outerSpacing = $derived(hero ? 'mt-20 mb-16' : 'mt-0 mb-1') let starting = $state(false) async function start() { @@ -167,7 +174,7 @@
-
+
{#if showComposer && !collapsed} {#if !disabled} +{:else if show} = $state({}) let usingOpenaiClientCredentialsOauth = $state(false) let workspaceOverrideEditorOpened = $state(false) + let copilotDisabled = $state(false) // --- Initial state for dirty tracking --- let initialAiProviders: Exclude = $state({}) @@ -88,6 +89,7 @@ let initialMaxTokensPerModel: Record = $state({}) let initialModelPricing: Record = $state({}) let initialPrompts: Record = $state({}) + let initialCopilotDisabled = $state(false) let lastLoadedConfigKey = $state(undefined) function clone(v: T): T { @@ -115,6 +117,7 @@ customPrompts = clone(config?.custom_prompts ?? {}) maxTokensPerModel = clone(config?.max_tokens_per_model ?? {}) modelPricing = clone(config?.model_pricing ?? {}) + copilotDisabled = config?.copilot_disabled === true for (const mode of ['edit', 'fix', 'gen']) { if (!(mode in customPrompts)) { customPrompts[mode] = '' @@ -131,6 +134,7 @@ initialMaxTokensPerModel = clone(maxTokensPerModel) initialModelPricing = clone(modelPricing) initialPrompts = clone(customPrompts) + initialCopilotDisabled = copilotDisabled } export function loadFromConfig(config: AIConfig | undefined) { @@ -146,6 +150,7 @@ customPrompts = clone(initialCustomPrompts) maxTokensPerModel = clone(initialMaxTokensPerModel) modelPricing = clone(initialModelPricing) + copilotDisabled = initialCopilotDisabled } $effect(() => { @@ -180,7 +185,8 @@ codeCompletionModel !== initialCodeCompletionModel || JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) || JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel) || - JSON.stringify(modelPricing) !== JSON.stringify(initialModelPricing) + JSON.stringify(modelPricing) !== JSON.stringify(initialModelPricing) || + copilotDisabled !== initialCopilotDisabled ) $effect(() => { @@ -285,6 +291,8 @@ .filter(([_, prompt]) => prompt.trim().length > 0) .reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {}) + // The flag is the one thing a workspace on instance defaults still stores of its own. + const copilot_disabled = copilotDisabled ? true : undefined return Object.keys(aiProviders ?? {}).length > 0 ? { providers: aiProviders, @@ -294,9 +302,10 @@ custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined, max_tokens_per_model: Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined, - model_pricing: Object.keys(modelPricing).length > 0 ? modelPricing : undefined + model_pricing: Object.keys(modelPricing).length > 0 ? modelPricing : undefined, + copilot_disabled } - : {} + : { copilot_disabled } } function isSaveDisabled(): boolean { @@ -633,12 +642,27 @@ {/if} -{#if showWorkspaceOverrideEditor} - +{#if promptScope === 'workspace'} + + { + copilotDisabled = e.detail + }} + options={{ right: 'Hide AI sessions in this workspace' }} + /> + {/if} + + + diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 257af0fbbd..a20c61f2ff 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -83,6 +83,7 @@ import SessionPicker from '$lib/components/sessions/SessionPicker.svelte' import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { copilotInfo } from '$lib/aiStore' import { parsePreviewItemRoute } from '$lib/components/sessions/previewPaths' import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte' import { sessionState } from '$lib/components/sessions/sessionState.svelte' @@ -250,6 +251,10 @@ // so it follows the gate; opted-out users get the legacy Ask-AI pane instead. // The /sessions page has its own gate for direct navigation. const globalAiEnabled = isGlobalAiEnabled() + // A workspace that hid the assistant (`ai_config.copilot_disabled`) loses both entry + // points: the Workspace ⇄ Sessions switch and the legacy Ask-AI button. + const sessionsSwitchShown = $derived(globalAiEnabled && !$copilotInfo.workspaceDisabled) + const askAiShown = $derived(!globalAiEnabled && !$copilotInfo.workspaceDisabled) if (page.status == 404) { goto('/user/login') @@ -999,7 +1004,7 @@
- {#if !embedded && globalAiEnabled} + {#if !embedded && sessionsSwitchShown}
-
+
{/if} @@ -1047,7 +1052,7 @@ class="!text-xs" shortcut={`${getModifierKey()}k`} /> - {#if !globalAiEnabled} + {#if askAiShown}
- {#if !embedded && globalAiEnabled} + {#if !embedded && sessionsSwitchShown}
@@ -1145,7 +1150,7 @@ -
+
{/if} @@ -1184,7 +1189,7 @@ class="!text-xs" shortcut={`${getModifierKey()}k`} /> - {#if !globalAiEnabled} + {#if askAiShown} - aiChatManager.toggleOpen()} - {isCollapsed} - icon={WandSparkles} - iconProps={{ - forceDarkMode: true - }} - label="Ask AI" - class="!text-xs" - iconClasses="!text-ai" - shortcut={`${getModifierKey()}L`} - /> + {#if !$copilotInfo.workspaceDisabled} + aiChatManager.toggleOpen()} + {isCollapsed} + icon={WandSparkles} + iconProps={{ + forceDarkMode: true + }} + label="Ask AI" + class="!text-xs" + iconClasses="!text-ai" + shortcut={`${getModifierKey()}L`} + /> + {/if}
{:else} -
- +
+
- {#if !fullscreen} - - -
- {#each warmSessions as s (s.id)} -
- -
- {/each} -
-
- {/if} + +
+ {#each warmSessions as s (s.id)} +
+ +
+ {/each} +
+
+ {/if} - - -
-
- {#if !fullscreen} - - - {/if} + + {/if} - -
- {#if !activeTabIsArtifact} - + {#if !activeTabIsArtifact} + + + + {/if} + -
+ {#if fullscreen} + + {:else} + + {/if} + +
- - (activeTabPickerOpen = !activeTabPickerOpen)} - onClose={closeTab} - onReorder={reorderTabs} - class="session-preview-tab-strip h-8 border-b border-light bg-surface-secondary/50 {fullscreen - ? 'pl-1.5' - : 'pl-9'} pr-16" - > - {#snippet tabAccessory(_tab, isActive)} - {#if isActive} - - - (e.currentTarget as HTMLElement) - .closest('[role="tab"]') - ?.focus() - }} - > - {#snippet content()} - - {#key activePickerScope?.dir ?? ''} - { - activeTabPickerOpen = false - navigatePreviewTo(t) - }} - /> - {/key} - {/snippet} - - - {/if} - {/snippet} - {#snippet afterTabs()} - - {#snippet trigger()} - - {/snippet} - {#snippet content()} - { - newTabOpen = false - openInNewTab(t) - }} + {#key activePickerScope?.dir ?? ''} + { + activeTabPickerOpen = false + navigatePreviewTo(t) + }} + /> + {/key} + {/snippet} + + - {/snippet} - - {/snippet} - - - -
- {#each warmSessions as s (s.id)} - {@const rt = getRuntime(s.id)} - {@const tabs = rt?.previewTabs} - {#each tabs?.tabs ?? [] as tab (tab.id)} - - - tabs && onTabLoad(tabs, tab, frame)} - /> - {/each} - {/each} - {#if (owner?.tabs.length ?? 0) === 0} - -
- -
- No preview open - Open a page, flow, script or app to preview it alongside the chat. -
+ {/if} + {/snippet} + {#snippet afterTabs()} {#snippet trigger()} - - Open a preview - + {/snippet} {#snippet content()} { - emptyStateNewTabOpen = false + newTabOpen = false openInNewTab(t) }} /> {/snippet} -
- {/if} + {/snippet} + + + +
+ {#each warmSessions as s (s.id)} + {@const rt = getRuntime(s.id)} + {@const tabs = rt?.previewTabs} + {#each tabs?.tabs ?? [] as tab (tab.id)} + + + tabs && onTabLoad(tabs, tab, frame)} + /> + {/each} + {/each} + {#if (owner?.tabs.length ?? 0) === 0} + +
+ +
+ No preview open + Open a page, flow, script or app to preview it alongside the chat. +
+ + {#snippet trigger()} + + Open a preview + + {/snippet} + {#snippet content()} + { + emptyStateNewTabOpen = false + openInNewTab(t) + }} + /> + {/snippet} + +
+ {/if} +
-
- - - {#if previewCollapsed && !fullscreen} - -
- +
+ +
+ {/if} +
+ {#if aiHiddenVerdict === undefined} +
+ +
+ {:else if aiHiddenVerdict} + +
+

AI Sessions are hidden in this workspace

+

A workspace admin hid AI sessions in the workspace settings.

+
{/if}
From 419741e5d226c67c51429094fb6ded9474afed99 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 2 Sep 2026 17:27:18 +0200 Subject: [PATCH 08/31] fix: sandbox script-controlled content types in result_to_response (#10932) * fix: sandbox script-controlled content type in result_to_response Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WFhu2MHsJVMqdMfdgbwNkT * fix: reject hop-by-hop wm_headers so a proxy cannot strip the sandbox Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WFhu2MHsJVMqdMfdgbwNkT * docs: condense sandbox comments and record the surface in the threat model Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WFhu2MHsJVMqdMfdgbwNkT --------- Co-authored-by: Claude Fable 5.1 --- backend/THREAT_MODEL.md | 4 +- backend/windmill-api-jobs/src/execution.rs | 86 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index 0468ebf721..f5725d65a3 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -85,7 +85,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream | | EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation | | EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts | -| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover | +| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers, script-controlled `wm_content_type`/`wm_headers` on `run_wait_result` and sync HTTP-route responses | stored user content → admin browser (same origin) | Admin session, account takeover | | EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings | | EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds | | EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS` now defaults to `true`; can still be overridden to `false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets | @@ -106,7 +106,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | T8 | Unauthenticated RCE via the Debugger WebSocket: `/ws_debug/*` exposed by the gateway/ingress with the debugger service as the auth boundary; signature gate was bypassable via `program`-mode launches (read+exec an arbitrary server-side file path, never signed) even with signing on, and the WS handshake had no Origin check (CSWSH) | remote_unauth | EP15 | Worker host, all assets | critical | possible | partially_mitigated | `program`-mode launches now rejected when `REQUIRE_SIGNED_DEBUG_REQUESTS` is on (signing covers every launch, not just inline `code`); shipped `docker-compose` now defaults `REQUIRE_SIGNED_DEBUG_REQUESTS=true`; opt-in `DEBUG_ALLOWED_ORIGINS` allowlist rejects cross-origin handshakes. Residual: code default is secure but operators can still set `=false`; origin allowlist is opt-in | GHSA-725h-99vx-9xr4 | | T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) | | T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 | -| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 | +| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, S3 download content-type, or a script-chosen `text/html` content type on `run_wait_result` / sync HTTP-route responses (GET-reachable with the `SameSite=Lax` session cookie) | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads and on every `result_to_response` composite result (inserted after `wm_headers`; hop-by-hop names such as `Connection` rejected so a proxy cannot strip them) | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0, WIN-2471 | | T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 | | T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 | | T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d | diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index fbfd656cdf..e897bf5eaf 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -416,11 +416,31 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result let mut headers = HeaderMap::new(); + // A reverse proxy consumes hop-by-hop headers instead of forwarding them and + // drops every header named by `Connection`, so a script could use one to strip + // the sandbox headers this function adds before they reach the browser. + const HOP_BY_HOP_HEADERS: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ]; + if let Some(windmill_headers) = windmill_headers { for (k, v) in windmill_headers { let k = HeaderName::from_str(k.as_str()).map_err(|err| { Error::internal_err(format!("Invalid header name {k}: {err}")) })?; + if HOP_BY_HOP_HEADERS.contains(&k.as_str()) { + return Err(Error::ExecutionErr(format!( + "windmill_headers cannot set the hop-by-hop header \"{k}\"" + ))); + } let v = HeaderValue::from_str(v.as_str()).map_err(|err| { Error::internal_err(format!("Invalid header value {v}: {err}")) })?; @@ -428,6 +448,22 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result } } + // The script controls the content type and body, and run_wait_result and sync + // HTTP routes are reachable by top-level GET navigation with the session cookie: + // sandbox the document into an opaque origin so HTML can never run with the + // viewer's session. Inserted after `wm_headers` so a script cannot override it. + headers.insert( + http::header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + http::header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "sandbox allow-scripts allow-forms allow-popups \ + allow-popups-to-escape-sandbox allow-downloads allow-modals", + ), + ); + if let Some(content_type) = windmill_content_type { let serialized_json_result = result_value .map(|val| val.get().to_owned()) @@ -1104,6 +1140,56 @@ mod result_to_response_tests { resp.headers().get(http::header::CONTENT_TYPE).unwrap(), "text/html" ); + assert_sandboxed(resp.headers()); assert_eq!(body_bytes(resp).await, b"

hi

"); } + + fn assert_sandboxed(headers: &HeaderMap) { + assert_eq!( + headers.get(http::header::X_CONTENT_TYPE_OPTIONS).unwrap(), + "nosniff" + ); + let csp = headers + .get(http::header::CONTENT_SECURITY_POLICY) + .expect("content-security-policy") + .to_str() + .unwrap(); + assert!(csp.starts_with("sandbox "), "csp: {csp}"); + assert!(!csp.contains("allow-same-origin"), "csp: {csp}"); + } + + #[tokio::test] + async fn custom_headers_cannot_override_sandbox() { + // wm_headers is script-controlled: a content-type set there replaces the JSON + // one even without wm_content_type, and the sandbox headers must survive an + // attempt to override them. + let resp = result_to_response( + raw( + r#"{"wm_headers":{"content-type":"text/html","content-security-policy":"default-src *","x-content-type-options":"none"},"result":"

hi

"}"#, + ), + true, + ) + .expect("response"); + + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "text/html" + ); + assert_sandboxed(resp.headers()); + } + + #[tokio::test] + async fn hop_by_hop_custom_headers_are_rejected() { + // A proxy drops every header named by `Connection`, which would strip the + // sandbox headers on the way to the browser. + for name in ["connection", "Connection", "transfer-encoding", "upgrade"] { + let res = result_to_response( + raw(&format!( + r#"{{"wm_content_type":"text/html","wm_headers":{{"{name}":"content-security-policy, x-content-type-options"}},"result":"

hi

"}}"# + )), + true, + ); + assert!(res.is_err(), "hop-by-hop header must be rejected: {name}"); + } + } } From 17ba521c352aec65a8270893752bbadd7f3d6eaa Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 2 Sep 2026 19:20:49 +0200 Subject: [PATCH 09/31] fix: record supplied script lock hashes so importers can skip relocking (#10915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: record supplied script lock hashes so importers can skip relocking Creating a script with a caller-supplied lock — a CLI push, a git-sync deploy, any create carrying a lockfile — stored the lock on `script` but never wrote the matching `lock_hash(workspace_id, path, hash_script(lock))` row. Only worker-generated locks did. `try_skip_relock` treats a missing hash for an imported script as changed, so no importer of such a script could ever satisfy the skip predicate: every deploy of it relocked every importer, forever. The create transaction now records the hash for any lock it accepts, including the empty one a codebase or a language with no lock generation carries — the worker writes `hash_script("")` there, and a path going from a real lock to an empty one has to stop matching what its importers recorded. Only a lock left to a dependency job is skipped, because that job writes it. A workspace clone now carries `lock_hash` too, without which every dependency-map snapshot the clone later recorded held NULL and nothing in it could ever skip. `dependency_map.imported_lockfile_hash` is deliberately not copied: it records what an importer resolved against when it was last locked, the clone runs READ COMMITTED, and a relock landing in the source between the scripts being cloned and that statement would attach a hash the cloned importer's lock was never resolved against — a hash older than the cloned scripts costs one relock, a newer one skips a relock that was needed. Lock generation is untouched, as is everything a relock does once it runs. The only behavior that moves is which relocks are skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * fix: narrow to the create-path lock hash Drop the workspace-clone copy of lock_hash. It sits outside the reported bug, and its double join over `script` can emit a path twice where two versions are live, which the unique key on (workspace_id, path) then rejects, failing the whole fork. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * fix: restore the workspace-clone lock hash copy, guarded against fanout A path can hold two live versions, and both joins match on path alone, so the select can emit it four times against a primary key that admits one. Every such row carries the single hash the path has, so ON CONFLICT DO NOTHING settles it rather than aborting the fork. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * fix: hash a clone's own locks rather than copying the source's rows A source row is only as current as the last write to it, and a supplied lock deployed before this was recorded leaves one naming a lock the path no longer holds. Copying that into a fork hands an importer a hash it never resolved against; hashing what the clone holds cannot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * test: pin the lock hash written on a no-op push Removing that write leaves the assertion with no row, which is the state a script deployed before this shipped would stay in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * refactor: share one lock hash writer between the create and clone paths Both wrote the same upsert with different SQL. The existing writers fold theirs into the statement that writes the lock itself, which is what keeps the two consistent; these two have nothing to fold it into, so they take a shared one instead. The clone walks its pages by path rather than listing them first, dropping a query with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * fix: stream a clone's locks rather than reading them in pages script.lock is unbounded, so a page of them is bounded only by how many it holds. Hashing each as it arrives keeps one in memory at a time and lets the clone site collapse to a single call. Also states on both writers that they check no access to the workspace they write, which their callers are the ones to have established. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx * fix: make the lock hash writer safe to repeat and free when unchanged A path given twice in one call would have Postgres reject the whole statement, so the last hash for each wins. And recording a hash a path already has cut a row version for nothing on every unchanged sync, which is the mode the no-op push runs in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx --------- Co-authored-by: Claude Opus 5 --- ...5bbee155569488352c10b334ce57d83ce1c0a.json | 23 +++++ ...0df68f552e38d4b587839e0e41285a2d55455.json | 28 ++++++ ...e7032f710fe20dd272b7840c9bfbdb92554db.json | 23 +++++ ...656ce9a8b0499d6b9282a3ecfae3164b17c2a.json | 16 ++++ ...e4cad34e540a2cc09c92e262491145a0de05a.json | 15 +++ backend/Cargo.lock | 1 + .../tests/scripts.rs | 94 ++++++++++++++++++- backend/windmill-api-scripts/src/scripts.rs | 23 ++++- .../windmill-api-workspaces/src/workspaces.rs | 12 ++- backend/windmill-dep-map/Cargo.toml | 1 + backend/windmill-dep-map/src/lib.rs | 1 + backend/windmill-dep-map/src/lock_hash.rs | 79 ++++++++++++++++ 12 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 backend/.sqlx/query-1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a.json create mode 100644 backend/.sqlx/query-8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455.json create mode 100644 backend/.sqlx/query-abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db.json create mode 100644 backend/.sqlx/query-cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a.json create mode 100644 backend/.sqlx/query-dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a.json create mode 100644 backend/windmill-dep-map/src/lock_hash.rs diff --git a/backend/.sqlx/query-1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a.json b/backend/.sqlx/query-1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a.json new file mode 100644 index 0000000000..03f11ef137 --- /dev/null +++ b/backend/.sqlx/query-1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "lockfile_hash", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a" +} diff --git a/backend/.sqlx/query-8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455.json b/backend/.sqlx/query-8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455.json new file mode 100644 index 0000000000..610030cffc --- /dev/null +++ b/backend/.sqlx/query-8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path) path, lock FROM script\n WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "lock", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455" +} diff --git a/backend/.sqlx/query-abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db.json b/backend/.sqlx/query-abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db.json new file mode 100644 index 0000000000..c3ef22f973 --- /dev/null +++ b/backend/.sqlx/query-abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db" +} diff --git a/backend/.sqlx/query-cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a.json b/backend/.sqlx/query-cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a.json new file mode 100644 index 0000000000..19dc4781a6 --- /dev/null +++ b/backend/.sqlx/query-cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n SELECT $1, * FROM UNNEST($2::text[], $3::bigint[])\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash\n WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "TextArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a" +} diff --git a/backend/.sqlx/query-dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a.json b/backend/.sqlx/query-dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a.json new file mode 100644 index 0000000000..eba1b0da99 --- /dev/null +++ b/backend/.sqlx/query-dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 913e0eda75..144605d28f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15650,6 +15650,7 @@ name = "windmill-dep-map" version = "1.801.0" dependencies = [ "chrono", + "futures", "itertools 0.14.0", "lazy_static", "serde", diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 8c88c53454..3a146add27 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -38,6 +38,85 @@ fn new_script(path: &str, summary: &str, content: &str) -> serde_json::Value { }) } +/// A supplied lock queues no dependency job, so if the create does not record its hash nothing +/// ever will, and every importer of this script relocks on each of its deploys forever after. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_script_persists_supplied_lock_hash(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let path = "u/test-user/supplied_lock"; + let lock = r#"{"version":"4","remote":{}}"#; + let mut script = new_script( + path, + "Supplied lock", + "export async function main() { return 42; }", + ); + script["lock"] = json!(lock); + + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + ))) + .json(&script) + .send() + .await?; + assert_eq!(resp.status(), 201, "create: {}", resp.text().await?); + + let stored_hash = sqlx::query_scalar!( + "SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2", + "test-workspace", + path, + ) + .fetch_one(&db) + .await?; + assert_eq!(stored_hash, windmill_common::scripts::hash_script(lock)); + + // A script deployed before the create recorded hashes has no row, and pushing it unchanged + // creates no version to hang one off. Without the write on that path it would keep its + // importers relocking until someone edited it. + sqlx::query!( + "DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2", + "test-workspace", + path, + ) + .execute(&db) + .await?; + + // The no-op comparison covers every field, so the push has to carry what the first deploy + // filled in by itself; `auto_parent` both resolves the parent and keeps the hash distinct. + script["auto_parent"] = json!(true); + script["ws_error_handler_muted"] = json!(false); + script["assets"] = json!([]); + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create?skip_if_noop=true" + ))) + .json(&script) + .send() + .await?; + assert_eq!(resp.status(), 201, "no-op push: {}", resp.text().await?); + + let versions: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2", + "test-workspace", + path, + ) + .fetch_one(&db) + .await? + .unwrap_or_default(); + assert_eq!(versions, 1, "no-op push must not create a version"); + + let repaired_hash = sqlx::query_scalar!( + "SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2", + "test-workspace", + path, + ) + .fetch_one(&db) + .await?; + assert_eq!(repaired_hash, windmill_common::scripts::hash_script(lock)); + + Ok(()) +} + #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; @@ -797,10 +876,12 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy( // What a deploy leaves behind: the old head archived, a new one live at the path. // Copied through a temp table so this does not have to restate every column. - sqlx::query("CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1") - .bind(head) - .execute(&mut *winner) - .await?; + sqlx::query( + "CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1", + ) + .bind(head) + .execute(&mut *winner) + .await?; sqlx::query("UPDATE superseding SET hash = $1, archived = false, parent_hashes = ARRAY[$2]") .bind(head + 1) .bind(head) @@ -818,7 +899,10 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy( let resp = tokio::time::timeout(std::time::Duration::from_secs(20), update).await??; let status = resp.status(); let body = resp.text().await?; - assert_eq!(status, 400, "losing the race should not read as success: {body}"); + assert_eq!( + status, 400, + "losing the race should not read as success: {body}" + ); assert!( body.contains("deployed to concurrently"), "the loser must say it was superseded, not that the script is missing: {body}" diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 817ff6fc07..ccc5815aa7 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -39,7 +39,7 @@ use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; -use windmill_dep_map::process_relative_imports; +use windmill_dep_map::{lock_hash::record_lock_hashes, process_relative_imports}; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_common::{ @@ -1073,6 +1073,14 @@ fn modules_eq( } } +/// Recorded for the empty lock a codebase or a language with no lock generation carries as well as +/// for a real one: the worker writes `hash_script("")` in the same situation, and a path going from +/// a real lock to an empty one has to stop matching what its importers recorded, or they wrongly +/// skip rather than merely relock too often. +fn lock_hash_entry(path: &str, lock: &str) -> [(String, i64); 1] { + [(path.to_string(), hash_script(lock))] +} + async fn create_script_internal<'c>( mut ns: NewScript, w_id: String, @@ -1340,6 +1348,12 @@ async fn create_script_internal<'c>( parent_hash = %p_hash.0, "Skipping no-op script deploy (identical to parent)" ); + // The version is unchanged, but the row recording its lock's hash may never have + // been written — nothing else writes it for a supplied lock, and a path only ever + // pushed unchanged would otherwise keep its importers relocking forever. + if let Some(lock) = ps.lock.as_deref() { + record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?; + } return Ok((p_hash.clone(), tx, None, Vec::new())); } @@ -1887,6 +1901,13 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; + // A lock that is not left to a dependency job queues none, so this is the only place its hash + // can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed, + // so leaving the row out makes every importer of this path relock on every deploy of it. + if let Some(lock) = lock.as_deref() { + record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?; + } + // Update ci_test_reference table for test scripts // Delete by both new and old path to handle renames let old_path = parent_hashes_and_perms.as_ref().map(|x| x.p_path.as_str()); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 2aa59b20ff..cb5c639d46 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -12,6 +12,7 @@ use windmill_api_auth::{ }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; +use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; use windmill_common::webhook::WebhookShared; use windmill_common::{BASE_URL, DB}; @@ -7139,7 +7140,16 @@ async fn clone_workspace_runnable_dependencies( .execute(&mut **tx) .await?; - // Clone dependency_map to preserve import relationships + // Recorded so the clone's own relocks have something to match; with no row they record NULL + // and nothing in it ever skips. Hashed from the locks the clone holds rather than copied from + // the source's rows, which are only as current as the last write to them: one left stale by a + // supplied lock deployed before this was recorded names a lock the clone no longer has, and an + // importer that resolved against the real one would then skip a relock it needed. + record_lock_hashes_for_workspace(tx, target_workspace_id).await?; + + // Deliberately without `imported_lockfile_hash`: it records what an importer resolved against + // when it was last locked, which nothing here can establish for the version the clone got. + // Left NULL, every importer relocks once and re-anchors both sides to what the clone holds. sqlx::query!( "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) SELECT $1, importer_path, importer_kind, imported_path, importer_node_id diff --git a/backend/windmill-dep-map/Cargo.toml b/backend/windmill-dep-map/Cargo.toml index 28b8552e8e..a93376d167 100644 --- a/backend/windmill-dep-map/Cargo.toml +++ b/backend/windmill-dep-map/Cargo.toml @@ -26,4 +26,5 @@ tracing.workspace = true lazy_static.workspace = true chrono.workspace = true itertools.workspace = true +futures.workspace = true uuid.workspace = true diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index ee6a3c3fd4..650e0e5f99 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -1,6 +1,7 @@ pub mod ci_tests; #[cfg(feature = "private")] pub mod ci_tests_ee; +pub mod lock_hash; pub mod scoped_dependency_map; pub mod trigger_dependents; pub mod workspace_dependencies; diff --git a/backend/windmill-dep-map/src/lock_hash.rs b/backend/windmill-dep-map/src/lock_hash.rs new file mode 100644 index 0000000000..50bd18a8ee --- /dev/null +++ b/backend/windmill-dep-map/src/lock_hash.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; + +use futures::TryStreamExt; +use sqlx::{Postgres, Transaction}; +use windmill_common::error::Result; +use windmill_common::scripts::hash_script; + +/// Records what the lock now at each path hashes to, which is one half of the comparison a relock +/// skip makes against what each importer resolved against. +/// +/// Writes any path in `w_id` and checks nothing: callers are responsible for having established +/// the caller's access to that workspace. A path repeated in `entries` keeps its last hash. +/// +/// Callers that write the lock itself in the same statement fold the upsert into that statement +/// instead; this is for the ones with nothing to fold it into. +pub async fn record_lock_hashes( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + entries: &[(String, i64)], +) -> Result<()> { + // Postgres rejects a whole statement that resolves a conflict on one key twice, so a path + // given more than once keeps its last hash, as it would if the two were written in order. + let mut deduped: HashMap<&str, i64> = HashMap::with_capacity(entries.len()); + for (path, hash) in entries { + deduped.insert(path.as_str(), *hash); + } + if deduped.is_empty() { + return Ok(()); + } + let (paths, hashes): (Vec, Vec) = deduped + .into_iter() + .map(|(path, hash)| (path.to_string(), hash)) + .unzip(); + // Recording a hash a path already has would still cut a row version, and the no-op push this + // is reached from is the mode a git-sync of an unchanged workspace runs in. + sqlx::query!( + "INSERT INTO lock_hash (workspace_id, path, lockfile_hash) + SELECT $1, * FROM UNNEST($2::text[], $3::bigint[]) + ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash + WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash", + w_id, + &paths[..], + &hashes[..] + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Records the hash of every live lock in `w_id`, for a workspace whose scripts arrived without +/// going through a deploy — a clone, which copies their locks verbatim and so would otherwise hold +/// none of the hashes describing them. +/// +/// Carries the same caller obligation as [`record_lock_hashes`]. +/// +/// `script.lock` is unbounded and a workspace holds one per script, so the rows are streamed and +/// each lock is hashed and dropped before the next arrives; only the hashes accumulate. +pub async fn record_lock_hashes_for_workspace( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, +) -> Result<()> { + let mut entries: Vec<(String, i64)> = Vec::new(); + { + let mut rows = sqlx::query!( + "SELECT DISTINCT ON (path) path, lock FROM script + WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL + ORDER BY path, created_at DESC", + w_id + ) + .fetch(&mut **tx); + + while let Some(row) = rows.try_next().await? { + if let Some(lock) = row.lock { + entries.push((row.path, hash_script(&lock))); + } + } + } + record_lock_hashes(tx, w_id, &entries).await +} From f10ac6c2b3644fb16697e650efbc4f7cd3c6944c Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:30:54 +0200 Subject: [PATCH 10/31] feat: open path links from chat messages in the session preview panel (#10881) A workspace path mentioned in a chat message rendered as a link that always opened a new browser tab. On the sessions page, which hosts a preview panel, a plain click now opens the item in that panel instead. Modifier clicks still reach a new tab, and surfaces with no panel keep their previous behaviour. Scripts, flows and raw apps are supported. Legacy drag-and-drop apps are not: the panel has no editor that can host one, so their links stay outbound. The link pill's kind icon and action icon now cross-fade inside a fixed 12px box, so the pill is the same width at rest and on hover and the surrounding sentence never reflows. `openItemPreviewAction` moves to a new import-free leaf module so a chat message can reach it at runtime without dragging monaco, zod and the openai client into the render path. Claude-Session: https://claude.ai/code/session_01RjbVL7h9NiTLGTgyfiHvXG Co-authored-by: Claude Opus 5 (1M context) --- .../copilot/chat/LinkRenderer.svelte | 52 ++++++++++++++----- .../components/copilot/chat/itemPreview.ts | 30 +++++++++++ .../src/lib/components/copilot/chat/shared.ts | 34 +++--------- .../copilot/chat/workspaceItems.svelte.ts | 31 ++++++++++- .../copilot/chat/workspaceItems.test.ts | 41 +++++++++++++-- .../(root)/(logged)/sessions/+page.svelte | 4 +- 6 files changed, 146 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/itemPreview.ts diff --git a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte index 491c2e11d5..1e885fa304 100644 --- a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte +++ b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte @@ -19,6 +19,7 @@ 'data-wm-kind'?: WindmillItemKind 'data-wm-path'?: string 'data-wm-target-kind'?: WorkspaceItemTargetKind + 'data-wm-raw-app'?: string title?: string } let { @@ -27,15 +28,25 @@ 'data-wm-kind': wmKind, 'data-wm-path': wmPath, 'data-wm-target-kind': wmTargetKind, + 'data-wm-raw-app': wmRawApp, title }: Props = $props() // The drawers ride with the docked chat, so a surface can render this pill with nothing // able to open one. - const drawerAction = $derived.by(() => { - const action = workspaceItemAction(wmKind, wmPath, wmTargetKind) + const available = $derived.by(() => { + const action = workspaceItemAction(wmKind, wmPath, wmTargetKind, wmRawApp === 'true') return action && hasToolDisplayActionHandler(action.type) ? action : undefined }) + // Only the preview panel takes the plain click. A drawer keeps its own button beside an + // outbound link: the docked chat mounts drawer handlers on nearly every page, so claiming + // that click would redirect these pills far outside the sessions page. + const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined) + const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined) + + const hint = $derived( + previewAction ? `Open ${wmPath} in the preview panel` : `Open ${wmPath} in a new tab` + ) async function openDrawer(event?: Event) { event?.preventDefault() @@ -44,6 +55,14 @@ await runToolDisplayAction(drawerAction) } } + + async function onclick(event: MouseEvent) { + // Modifier clicks are the only remaining route to the tab once the plain click is + // spoken for, so leave them to the browser. + if (!previewAction || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + await runToolDisplayAction(previewAction) + } {#if href} @@ -51,20 +70,29 @@ - - + + + + + + + {#if previewAction} + + {:else} + + {/if} + {@render children?.()} - - - {#if drawerAction}
{/if} -
+
{#if showComposer && !collapsed}
{#each homeAIExamples as example (example.label)} From ca8800959aa6a0017cc29bad187c9f49e0d13cc4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 3 Sep 2026 11:23:23 +0200 Subject: [PATCH 18/31] fix: bump git sync hub scripts to cli 1.802.1, test the fork ui pull (#10955) * fix: bump git sync hub scripts to cli 1.802.1, test the fork ui pull Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011yMLnAWdjpCEs5VyGMn9ww * test: guard the ui pull preview shape and pin the pull script ids together Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011yMLnAWdjpCEs5VyGMn9ww --------- Co-authored-by: Claude Fable 5.1 --- .github/workflows/git-sync-test.yml | 4 +- backend/windmill-common/src/workspaces.rs | 4 +- frontend/src/lib/hubPaths.json | 2 +- integration_tests/test/git_sync_test.py | 211 +++++++++++++++++----- 4 files changed, 176 insertions(+), 45 deletions(-) diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index ee732569dd..bd15ec8876 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -9,6 +9,7 @@ on: - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" - "backend/windmill-common/src/workspaces.rs" + - "frontend/src/lib/hubPaths.json" - "backend/windmill-worker/src/result_processor.rs" - "backend/windmill-api-workspaces/**" - "cli/src/commands/sync/**" @@ -22,6 +23,7 @@ on: - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" - "backend/windmill-common/src/workspaces.rs" + - "frontend/src/lib/hubPaths.json" - "backend/windmill-worker/src/result_processor.rs" - "backend/windmill-api-workspaces/**" - "cli/src/commands/sync/**" @@ -59,7 +61,7 @@ jobs: echo "$CHANGED_FILES" # Direct git sync file changes — always relevant. - if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then + if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|frontend/src/lib/hubPaths\.json|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Relevant: direct git sync file changes" exit 0 diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index c1d8fc00bc..697fc69e44 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -175,7 +175,7 @@ pub enum ObjectType { DatatableMigration, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28931/sync-script-to-git-repo-windmill"; /// Hub script that applies a repository's state back into a workspace /// (the repo → Windmill / "pull" direction). Same script the UI runs from @@ -183,7 +183,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28910/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28930/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 2862fc7768..ee60298db4 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,6 +1,6 @@ { "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/28910/git-sync-init-repository-windmill", + "gitInitRepo": "hub/28930/git-sync-init-repository-windmill", "slackErrorHandler": "hub/28794/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/28791/slack/schedule-recovery-handler-slack", diff --git a/integration_tests/test/git_sync_test.py b/integration_tests/test/git_sync_test.py index 3cc54f2796..47ac02351e 100644 --- a/integration_tests/test/git_sync_test.py +++ b/integration_tests/test/git_sync_test.py @@ -1,9 +1,12 @@ +import json import os +import re import shutil import tempfile import time import unittest import uuid +from pathlib import Path import git as gitpython @@ -19,6 +22,21 @@ def unique_name(prefix: str = "git-sync-test") -> str: return f"{prefix}-{uuid.uuid4().hex[:8]}" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def ui_pull_script_path() -> str: + """The hub script the git-sync UI runs for a pull (git → workspace).""" + with open(REPO_ROOT / "frontend/src/lib/hubPaths.json") as f: + return json.load(f)["gitInitRepo"] + + +def backend_pull_script_path() -> str: + """The hub script the backend runs for an auto-pull: `GIT_SYNC_PULL_SCRIPT_PATH`.""" + source = (REPO_ROOT / "backend/windmill-common/src/workspaces.rs").read_text() + return re.search(r'GIT_SYNC_PULL_SCRIPT_PATH: &str = "([^"]+)"', source).group(1) + + class GitSyncTestBase(unittest.TestCase): """Shared fixture + helpers for git sync e2e tests (no tests of its own). @@ -221,6 +239,84 @@ class GitSyncTestBase(unittest.TestCase): ) return matching[0] + def _seed_wmill_yaml( + self, repo_name: str, branch: str = "main", include_schedules: bool = False + ): + """Commit a minimal wmill.yaml: the pull CLI requires one in the repo. + Real setups get it from the init/settings-push flow; pushes alone + don't write it.""" + self._gitea.create_file( + repo_name, + "wmill.yaml", + "defaultTs: bun\n" + "includes:\n" + ' - "**"\n' + "excludes: []\n" + "codebases: []\n" + "skipVariables: true\n" + "skipResources: true\n" + "skipResourceTypes: true\n" + "skipSecrets: true\n" + f"includeSchedules: {'true' if include_schedules else 'false'}\n" + "includeTriggers: false\n", + branch=branch, + ) + + def _create_fork(self, client: WindmillClient) -> tuple: + """Fork `client`'s workspace the way the UI does (branch job first, then + the workspace). Returns (fork_id, fork_branch).""" + fork_id = f"wm-fork-{uuid.uuid4().hex[:8]}" + self._fork_workspaces_to_cleanup.append(fork_id) + job_ids = client.create_workspace_fork_branch(fork_id, f"Fork {fork_id}") + if job_ids: + client.wait_for_jobs_by_ids(job_ids, timeout=90) + time.sleep(3) + client.create_workspace_fork(fork_id, f"Fork {fork_id}") + return fork_id, f"wm-fork/main/{fork_id[len('wm-fork-'):]}" + + def _run_ui_pull( + self, + client: WindmillClient, + resource_path: str, + include_type: list, + clone_ref: str = None, + dry_run: bool = False, + timeout: int = 180, + ) -> dict: + """Run the pull the git-sync UI runs (git → `client`'s workspace) and + return the job's result. `dry_run` is the UI's preview.""" + payload = { + "workspace_id": client._workspace, + "repo_url_resource_path": resource_path, + "dry_run": dry_run, + "pull": True, + "only_wmill_yaml": False, + "settings_json": json.dumps({ + "include_path": ["**"], + "exclude_path": [], + "extra_include_path": [], + "include_type": include_type, + }), + "use_promotion_overrides": False, + **({"clone_ref": clone_ref} if clone_ref else {}), + } + response = client._client.post( + f"/api/w/{client._workspace}/jobs/run/p/{ui_pull_script_path()}", + params={"skip_preprocessor": "true"}, + json=payload, + ) + self.assertEqual( + response.status_code // 100, 2, f"UI pull failed to start: {response.content.decode()}" + ) + job_id = response.content.decode() + client.wait_for_jobs_by_ids([job_id], timeout=timeout) + job = client._client.get(f"/api/w/{client._workspace}/jobs_u/get/{job_id}").json() + self.assertTrue( + job.get("success"), + f"UI pull job {job_id} failed: {job.get('result')}\n{job.get('logs')}", + ) + return job.get("result") or {} + class TestGitSync(GitSyncTestBase): # ────────────────────────────────────────────────── @@ -802,27 +898,6 @@ class TestGitSyncAutoPull(GitSyncTestBase): # Two poll cycles + job execution, with slack for a loaded CI runner. PULL_TIMEOUT = 240 - def _seed_wmill_yaml(self, repo_name: str, branch: str = "main"): - """Commit a minimal wmill.yaml: the pull CLI requires one in the repo. - Real setups get it from the init/settings-push flow; pushes alone - don't write it.""" - self._gitea.create_file( - repo_name, - "wmill.yaml", - "defaultTs: bun\n" - "includes:\n" - ' - "**"\n' - "excludes: []\n" - "codebases: []\n" - "skipVariables: true\n" - "skipResources: true\n" - "skipResourceTypes: true\n" - "skipSecrets: true\n" - "includeSchedules: false\n" - "includeTriggers: false\n", - branch=branch, - ) - def _configure_auto_pull(self, resource_path: str, sync_forks: bool = False): """Single sync repo with auto-pull enabled in polling mode.""" auto_pull = {"enabled": True, "mode": "polling"} @@ -914,16 +989,7 @@ class TestGitSyncAutoPull(GitSyncTestBase): self._configure_auto_pull(resource_path, sync_forks=True) - # Create the fork (branch first, then workspace), like the UI does. - fork_id = f"wm-fork-{uuid.uuid4().hex[:8]}" - self._fork_workspaces_to_cleanup.append(fork_id) - job_ids = self._client.create_workspace_fork_branch(fork_id, f"Fork {fork_id}") - if job_ids: - self._client.wait_for_jobs_by_ids(job_ids, timeout=90) - time.sleep(3) - self._client.create_workspace_fork(fork_id, f"Fork {fork_id}") - - fork_branch = f"wm-fork/main/{fork_id[len('wm-fork-'):]}" + fork_id, fork_branch = self._create_fork(self._client) self._gitea.create_file( repo_name, script_file, ts_script("return 'fork only'"), branch=fork_branch, @@ -1012,19 +1078,12 @@ class TestGitSyncAutoPull(GitSyncTestBase): f"attach_dev_workspace failed: {attach.content.decode()}", ) - # Fork the dev workspace (branch first, then workspace) — its parent is - # the dev, so this is a fork OF a dev workspace. - fork_id = f"wm-fork-{uuid.uuid4().hex[:8]}" - self._fork_workspaces_to_cleanup.append(fork_id) - job_ids = dev_client.create_workspace_fork_branch(fork_id, f"Fork {fork_id}") - if job_ids: - dev_client.wait_for_jobs_by_ids(job_ids, timeout=90) - time.sleep(3) - dev_client.create_workspace_fork(fork_id, f"Fork {fork_id}") + # Fork the dev workspace — its parent is the dev, so this is a fork OF + # a dev workspace. + fork_id, fork_branch = self._create_fork(dev_client) # The fork branch is named after the tracked branch, not the dev label. fork_suffix = fork_id[len("wm-fork-"):] - fork_branch = f"wm-fork/main/{fork_suffix}" branches = self._get_branches(self._clone_repo_all_branches(repo_name)) self.assertTrue( any(fork_branch in b for b in branches), @@ -1195,3 +1254,73 @@ class TestGitSyncAutoPull(GitSyncTestBase): initial_count, "Unknown webhook delivery enqueued a job", ) + + +class TestGitSyncUiPull(GitSyncTestBase): + """The pull the git-sync UI runs (hub init script, git → workspace).""" + + def test_ui_pull_script_is_the_backend_pull_script(self): + """The UI's pull and the backend's auto-pull are the same hub script, + so the two pins must move together.""" + self.assertEqual(ui_pull_script_path(), backend_pull_script_path()) + + def test_fork_pull_does_not_report_parent_owned_schedule_enabled(self): + """In a fork, a schedule the parent also has takes its `enabled` from + the parent, so a fork-branch file that disagrees on that flag can never + be made to agree: a pull that treated it as a change would list the + same row on every run. Pull into the fork, then preview: the schedule + must not be reported, while an ordinary fork-branch edit does land.""" + repo_name, _ = self._create_test_repo() + resource_path = self._setup_git_sync_resource(repo_name) + include_type = ["script", "schedule"] + self._configure_single_repo_sync(resource_path, include_type=include_type) + + script_path = self._deploy_seed_script("forkuipull") + schedule_path = f"u/admin/{unique_name('forkuipull_sched')}" + initial_count = self._client.count_deployment_callback_jobs() + self._client.create_schedule(schedule_path, script_path, schedule="0 0 0 1 1 *") + self.addCleanup(self._client.delete_schedule, schedule_path) + self._client.wait_for_sync_jobs(initial_count, min_new=1) + time.sleep(3) + self._seed_wmill_yaml(repo_name, include_schedules=True) + + # Fork after the schedule reached git so the fork branch inherits it + # with the parent's `enabled: true`; the fork's own copy lands disabled. + fork_id, fork_branch = self._create_fork(self._client) + + schedule_file = f"{schedule_path}.schedule.yaml" + fork_dir = self._clone_repo(repo_name, branch=fork_branch) + content = self._read_file_content(fork_dir, schedule_file) + self.assertIn( + "enabled: true", content, f"expected the parent's enabled schedule in git:\n{content}" + ) + self._gitea.create_file( + repo_name, + schedule_file, + content.replace("enabled: true", "enabled: false"), + branch=fork_branch, + ) + # A real change alongside it proves the pull ran against the fork branch. + script_file = self._repo_script_file(repo_name, script_path, branch=fork_branch) + self._gitea.create_file( + repo_name, script_file, ts_script("return 'fork ui pull'"), branch=fork_branch + ) + + fork_client = WindmillClient(workspace=fork_id) + self._run_ui_pull(fork_client, resource_path, include_type, clone_ref=fork_branch) + self.assertIn( + "fork ui pull", + fork_client.get_script_content(script_path), + "the fork-branch script edit was not applied by the pull", + ) + preview = self._run_ui_pull( + fork_client, resource_path, include_type, clone_ref=fork_branch, dry_run=True + ) + self.assertIn("changes", preview, f"preview result has no changes list: {preview}") + changes = preview["changes"] + self.assertIsInstance(changes, list, f"preview changes is not a list: {preview}") + self.assertEqual( + [c for c in changes if c.get("path", "").endswith(schedule_file)], + [], + f"a pull into the fork keeps reporting the parent-owned schedule flag: {changes}", + ) From 582761e37c776e92dc1c6ebfee8c4efe7c35d822 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 3 Sep 2026 11:28:54 +0200 Subject: [PATCH 19/31] feat: reuse an existing workspace resource in the project import wizard (#10935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: let the import wizard reuse an existing workspace resource The project import wizard always opened the create-resource drawer, so a workspace that already had, say, an SMTP resource still ended up with a second one. Step 4 now offers a choice: fill in a new resource as before, or pick an existing one of the same type. Picking an existing resource rewrites the deployed items to point at it and then deletes the imported stub. The rewrite covers scripts, flows, apps, raw apps and every workspace trigger kind, and holds two rules: it writes nothing unless every referrer can be rewritten, and it only touches items under the target folder. Raw apps re-upload the bundle shipped in the project export instead of rebuilding it, and the retarget refuses when the deployed sources have moved on since the import — that bundle was built from the export's sources, so re-uploading it over edited sources would revert them. Adds `update` to the trigger-kind table for the eleven kinds whose service takes a plain config body; schedule keeps its own branch because updateSchedule takes a different shape. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * feat: only ask about resources the project actually points at A project declares one resource per `resource-` input schema as well as one per `$res:` reference, so an app that pins `f/calendly/google_calendar` for a script whose schema says `resource-gcal` ships an unreferenced `f/calendly/gcal` alongside it. Step 4 listed both and asked you to fill in each. Only the referenced ones have to hold a credential for the project to work. The rest are still created — a standalone run picks from them in the argument picker — but they no longer reach the checklist, and `resourceCount` counts the same set so the wizard does not offer a fourth step that has nothing on it. Across the twelve published hub projects this drops 9 of 19 rows, including three non-credential input shapes in `typeform`. Also fixes a miss in the retarget: a trigger holds its resource as a bare path in its own `*_resource_path` field rather than as a `$res:` token, so a token-only scan left it pointing at a stub that was then deleted. Detection now mirrors `rewriteTriggerConfig` through a shared `referencesResourcePath`, which matches the parsed structure rather than its serialization — keeping `f/proj/db` out of `$res:f/proj/db_prod` as well. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: refuse a resource retarget the scan or the rewriters cannot cover Uncompiled trigger features 404 on their list route; that is the instance not having the kind, not a listing that failed, so it no longer blocks every retarget on a stock build. The `listSearch*` endpoints cap server-side with no ordering and no pagination, so a full page is refused rather than read as the whole workspace. An item that names the resource path outside a `$res:` token is refused at plan time — no rewriter relocates it — and the trigger row keeps its own `script_path` so a runnable sharing the path is not repointed. A raw app whose sources the export cannot yield carries no entry at all, so the refusal its comment promises actually fires. The reused row offers text instead of a button that leads to a deleted resource. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * refactor: let an incomplete scan keep the stub instead of refusing the retarget The scan behind "nothing is written unless every referrer can be rewritten" cannot be proven complete: the listings come back capped, a trigger kind can fail to list, and a reference can sit where no rewriter reaches. Gating the whole run on that claim made every such case a refusal. Rewriting an item onto the chosen resource is safe on its own — the item resolves whether or not the stub survives — so only the delete needs the claim. `planRetarget` now answers with the referrers it can move plus the gaps it cannot account for, `applyRetarget` always moves the first set, and a gap keeps the stub rather than stopping the run. A referrer outside the project's folder is one of those gaps: the listings are workspace-wide, so it is seen for free, it stays the user's own, and its existence is why the stub stays. The outcome carries what moved and why the stub was kept, so the row settles to the chosen resource either way and says when the placeholder is still there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: preserve a retargeted item's deployed identity, and send back its own bundle Every write here edits a deployed item in place, but none of them said so. Without `preserve_on_behalf_of` the backend replaces the item's stored run identity with whoever opened the wizard, and `updatePolicy(next, undefined)` rebuilt an app's policy from nothing — dropping its sandbox rules and forcing `execution_mode: publisher`, which puts a viewer app on the publisher's identity even though the backend would otherwise have kept the deployed mode. The policy is now recomputed from the deployed one, which is what the triggerables rekeying actually needs. The raw-app bundle no longer comes from the project export. The browser can read a deployed bundle back — mint the app's public secret and fetch `/apps/get_data/v/{secret}.{ext}`, the same route the Hub publish reads — so the bundle sent back is the deployed one whoever last edited it. That removes `ExportedAppFiles`, its plumbing through the setup step, `rawSourcesDiverged`, and the two raw-app gaps: an app "edited since the import" is no longer a case that exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * perf: carry the trigger row from the scan into its write `rewriteTrigger` listed the whole kind again to find the row it had just read, once per trigger — and for schedules a listing is itself a listing plus a detail fetch per row. The scan already holds the row, so the referrer carries it. Pins two properties that nothing covered: the trigger update body leaves `enabled` out, so pointing a trigger at a credential cannot also start it; and a write that fails partway keeps the stub while reporting what had already moved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: keep unfilled resources out of the reuse chooser The chooser offered every resource of the row's type except the ones this import created, so a stub left behind by an earlier import of the same project showed up as a credential to reuse. Pointing a project at another project's empty placeholder is never the answer, and nothing downstream would have complained. Candidates are now read back and the unfilled ones dropped, using the same test the checklist uses to call one of the project's own resources blank. Past a cap they are all offered rather than costing a request each: a workspace with that many resources of the outstanding types is not the case this filters for. Also drops the chooser's promise that the imported placeholder is removed. That was true when the delete was unconditional; the stub is now kept whenever the scan cannot account for everything, and the row says which happened once it has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: move a retargeted item's bundle and identity, and see the paths it spells out Four gaps between what the retarget claimed and what it did. A trigger states its run identity as `permissioned_as`, not the `on_behalf_of` the other kinds use, and the backend keeps the row's value only when `preserve_permissioned_as` says so. Without the pair, a trigger created under a folder's `default_permissioned_as` started running as whoever picked the credential. A raw app's bundle is compiled from its sources, so a `$res:` a source spells out is baked into it. The import rewrites that copy — `retargetProjectExport` runs while `/bundle.js` is still one of `files` — but the retarget fetched the deployed bundle after that split and sent it back untouched, then deleted the stub the app still read. The fetched bundle is now rewritten too, and a path it names any other way keeps the stub instead. A script's content is one string, so the whole-string match that finds a bare path in a flow or an app could not see one written inside it. `getResource("f/…")` was invisible to both the scan, which then deleted the stub under it, and the step-4 filter, which dropped the row so nobody was asked to fill it. Trigger listings cap at the server's DEFAULT_PER_PAGE, which this table does not page past. A full page is now read the way a full `listSearch*` page is: as a listing that cannot account for the rest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: see a path a flow or app spells out, and name why an item did not move The script scan was taught to see a resource path written inside code; flows and apps were left on the whole-string test, which cannot. A flow whose inline module runs `getResource("f/proj/db")`, or a raw app whose source does, was neither rewritten nor recorded as a gap, so the stub was deleted while the deployed item still read it. Reachable from the wizard, because the step-4 filter does see such a reference and offers the row. Both branches now use the same test as the script branch, and gap rather than rewrite: the stub survives either way, so a `$res:` token in the same item still resolves, and rewriting half an item would only make the plan and the write disagree about what moved. Each rewriter now says why it left an item alone instead of answering yes or no, so a raw-app bundle that spells the path out is reported as a reference nothing could move rather than as a concurrent edit. Also corrects the resource-listing comment — `perPage` bounds the answer, the route does not default to 30 — and asks the askable-resource question against the export as published rather than the retargeted copy, so the step and the stepper that decides whether to offer it give one answer. A path spelled out in code is not retargeted, so only the raw export has its references and its resource paths agreeing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: a kept placeholder is still something to fill in Reuse marked the row done and replaced its action with static text even when the stub survived. A kept stub is empty and is still what every item the scan could not move reads, so the step reported "You're all set" over a project running on a placeholder, with no way back to filling it. Reachable from one hub project: a raw app whose source spells the resource path out gaps everything, nothing is rewritten, and the row went green anyway. Such a row now stays outstanding, keeps its button, says which path items still read, and re-checks on refresh so filling that placeholder in closes it. Flows and apps also went back to being rewritten as well as gapped, matching what the script branch already did — the reason given for skipping them was contradicted by that branch, and a comment merely naming the path was enough to strand an item's real `$res:` token on the stub. Two things had to become precise for that to hold. What counts as rewritable is now the presence of a `$res:` token rather than any reference, since a whole string equal to the path is the unreachable case, not a movable one. And the post-rewrite check reads tokens only: a path the item also spells out is the plan's gap to record, and re-reading it at write time reported one item twice, as both unmovable and changed underfoot. Writers now skip a write that would change nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: rewrite only the tokens, and let a filled placeholder close its row The import's flow and app rewriters also remap a runnable's own path on an exact match. That is right for the folder-wide map the import hands them, where every path is moving. Here the map holds one entry, a resource path — and scripts, flows and resources share a namespace, so a project shipping both a script and a resource named `smtp` had the step calling it repointed at the credential. Triggers were already guarded against exactly this; flows, apps and raw apps were not. All three now rewrite the serialized value, which moves the tokens and leaves every path alone. A kept placeholder that the user then fills in now closes its row: `stubKept` is cleared by the read that finds it filled, so the row stops saying items still need it while showing a green check beside "You're all set". A kept-stub row's button also goes straight to filling that placeholder rather than reopening the chooser. A second retarget from there can only be a no-op — every rewritable referrer is already off the stub — and it would have relabelled the row after moving nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: check staleness where it can be seen, and stop trusting a client-side licence The post-rewrite check could no longer fail: since the rewrite became token-only it ran over exactly what the check looked for, so it read as a guard while guarding nothing. The staleness it named is real — the plan classifies items from the search listings and each write re-reads its item by path — so the check now happens on that fresh read, and looks for the spelling no rewrite reaches. A referrer the plan already recorded as unreachable skips it: the stub survives either way, and re-reporting the same item would say it was both unmovable and changed underfoot. Trigger kinds are no longer skipped by the client-side licence store. That store is empty on an EE instance whose licence is unset or whose fetch failed, while the rows are still in the database and the routes still answer — and a kind skipped that way left no gap, so the stub went while an EE trigger still pointed at it. On CE those routes are not registered and the 404 branch already says so, from the server rather than from a store. `askableResources` now pairs the export's resources with the retargeted ones by position, the way `retargetProjectExport` maps them, instead of rebuilding the path by slicing a prefix. An external path the bundle pulled in lands at `f//` with a `_2` suffix on collision, which no slicing recovers — and the row would have gone missing from a checklist the stepper still counted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: a scan the caller is not shown all of cannot clear the stub for deletion The listings the scan reads run as the caller, and row-level security filters them inside the query. For anyone but a workspace admin that means an item they cannot read is not absent from the answer so much as invisible in it: it does not appear, and it does not count towards the full-page test that catches a truncated listing either. A colleague's private script referencing the stub is exactly that shape, so the scan reported a clean sweep and the stub was deleted out from under it, with nothing said. That is the one input to the completeness proof the destructive step rests on that was never checked. A caller who is not shown the whole workspace now records a gap like any other, so the rewrite still happens in full and the placeholder stays. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: ask whether this workspace's listings are complete, not a stale record's `UserExt` is per-workspace and outlives a workspace change, which is why it carries `workspace_id`. Reading `is_admin` off it without checking which workspace it describes answers for the wrong one. Step 4 is reachable by reload — it is built to be — and nothing on that path re-fetches the record, so it still describes the workspace the user came from. An admin of their own workspace importing into a shared one they are a plain member of got a clean scan over row-level-security-filtered listings, and the stub was deleted under a referrer they were never shown. The question is now asked of the target workspace, through a predicate that can be tested. An instance superadmin bypasses the policies everywhere, so that is asked separately rather than read off the same stale record. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * style: format the wizard retarget files Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: leave a trigger's runnable references alone, and read the app kind rather than guess it A trigger's `on_failure`, `on_recovery`, `on_success` and `url` name a runnable, and `rewriteTriggerConfig` remaps one on an exact match — right for the folder-wide map the import hands it, wrong for a map holding a single resource path. A schedule whose error handler ran a script sharing that path had the handler pointed at the credential instead. The same reason `path` and `script_path` were already restored; only the two prefixed shapes it remaps are, so a field holding a `$res:` token still moves. The scan guessed raw from low-code by looking for `files` and `runnables`, because `list_search_apps` returns only the path and the value. Both writers re-read the app anyway, and that record carries `raw_app`, so the write now dispatches on it. A guess wrong in either direction was a deploy the backend refuses for changing an app's kind, which aborted the run at that referrer. Also drops the past-tense clauses from four test comments. Each already states the invariant it guards; the rest described iterations of this branch that no reader will have seen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: restore a trigger's bare runnable references too The prefixed spellings were put back after the rewrite; the bare ones were not. `dynamic_skip`, `error_handler_path` and a websocket initial message's `runnable_result.path` each hold a plain script path, which `rewriteTriggerConfig` remaps on a whole-string match — so a trigger whose error handler ran a script sharing the stub's path had that handler pointed at the credential. All of them now come back from the row, taken from what `triggerHandlerRefs` reads rather than enumerated by hand. A prefixed field is still restored only when it holds the runnable spelling, so a `$res:` token in one still moves; a bare field is a path and nothing else, so it is always restored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj --------- Co-authored-by: Claude Opus 5 (1M context) --- .../src/lib/components/ImportSetupStep.svelte | 319 ++++++++- .../triggers/workspaceTriggersList.ts | 50 +- .../workspaceSettings/projectBundle.test.ts | 45 ++ .../workspaceSettings/projectBundle.ts | 83 +++ .../src/lib/importWizard/execution.svelte.ts | 23 +- .../lib/importWizard/retargetDeployed.test.ts | 393 +++++++++++ .../src/lib/importWizard/retargetDeployed.ts | 650 ++++++++++++++++++ .../projects/import/+page@(root).svelte | 7 +- 8 files changed, 1535 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/importWizard/retargetDeployed.test.ts create mode 100644 frontend/src/lib/importWizard/retargetDeployed.ts diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte index 86670b0817..94691f86d7 100644 --- a/frontend/src/lib/components/ImportSetupStep.svelte +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -14,16 +14,21 @@ import IconedResourceType from '$lib/components/IconedResourceType.svelte' import ImportSetupRow from '$lib/components/ImportSetupRow.svelte' import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte' + import Modal2 from '$lib/components/common/modal/Modal2.svelte' + import Select from '$lib/components/select/Select.svelte' + import { applyRetarget, seesWholeWorkspace } from '$lib/importWizard/retargetDeployed' import { OauthService } from '$lib/gen' import { registryCcCapableFor } from '$lib/components/oauthRegistry' import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay' import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall' import { probeMigrationsApplied } from '$lib/importWizard/probe' import { + projectReferencesResource, retargetProjectExport, type ProjectExport, type ProjectMigration } from '$lib/components/workspaceSettings/projectBundle' + import { superadmin, userStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { escapeHtml } from '$lib/utils' @@ -82,6 +87,17 @@ * is absent, and removing the row reports "all set" over a credential nobody filled. */ unreadable?: boolean + /** + * The workspace resource this row was pointed at. The project's items reference it + * directly now, so this is what the row has to say instead of the path it used to name. + */ + reusedFrom?: string + /** + * The empty placeholder is still at this row's path, because the retarget could not + * account for every item that might read it. Worth saying: the workspace has a resource + * on it that looks unfinished and is not. + */ + stubKept?: boolean } let loading = $state(true) @@ -89,9 +105,38 @@ let rows = $state([]) let blanks = $state([]) let projectResources: { path: string; resource_type: string }[] = [] + /** + * The subset of `projectResources` the checklist asks about: the ones something in the + * project actually points at. The rest are created and left alone — see + * `projectReferencesResource`. Kept apart from `projectResources` because the full list + * is still what a stub may not be replaced by. + */ + let askableResources: { path: string; resource_type: string }[] = [] let working = $state(false) let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined) + /** The folder the import wrote into, which is where every rewritable referrer lives. */ + const targetFolder = $derived(folder?.trim() || slug) + + /** + * Resources the workspace already has, by resource type — what a stub can be replaced + * by. Empty for a workspace this import created, which is why the choice is offered + * rather than imposed: with nothing to choose from the button goes straight to the + * editor, exactly as it did before. + */ + let candidates = $state>({}) + /** + * How many candidates are worth reading back to find the unfilled ones. Past this a + * workspace holds too many resources of these types to be the case worth filtering — + * one project's stub offered as another's credential — and they are all offered rather + * than costing a request each. + */ + const CANDIDATE_READ_CAP = 40 + /** The credential row whose choice dialog is open. */ + let choosing = $state(undefined) + let chosenPath = $state(undefined) + let reusing = $state(false) + const pendingTables = $derived(rows.filter((r) => r.status !== 'done')) // Split because the two say different things to the user: one data table was never // created, the other exists and could not be read. Telling someone to set up what they @@ -248,7 +293,7 @@ // Retargeted the same way the import was, so these are where the stubs actually // landed. `retargetProjectExport` is a no-op when the folder is the slug, which is // every new-workspace import. - const target = folder?.trim() || slug + const target = targetFolder const retargeted = retargetProjectExport(exportData, exportData.project?.slug ?? slug, target) // Contained for the same reason the import contains: a crafted export can name a // path outside the folder, and offering that for editing would reach a resource @@ -256,6 +301,20 @@ projectResources = (retargeted.resources ?? []) .map((r) => ({ path: String(r.path), resource_type: String((r as any).resource_type) })) .filter((r) => r.path.startsWith(`f/${target}/`)) + // Asked against the export as published, not the retargeted copy: a path the project + // spells out in code is not rewritten by the retarget, so only the raw export has + // its references and its resource paths agreeing. `resourceCount` asks the same + // question the same way, and the step and the stepper have to give one answer. + // Paired by position, not by reconstructing the retargeted path: `retargetProjectExport` + // maps `resources` in order, and an external path the bundle pulled in lands at + // `f//` with a `_2` suffix on collision, which no slicing recovers. + const askable = new Set( + (retargeted.resources ?? []) + .map((r, i) => [String(r.path), (exportData.resources ?? [])[i]] as const) + .filter(([, raw]) => raw && projectReferencesResource(exportData, String(raw.path))) + .map(([path]) => path) + ) + askableResources = projectResources.filter((r) => askable.has(r.path)) await refreshBlanks() } catch (e: any) { loadError = e?.body ?? e?.message ?? String(e) @@ -347,13 +406,19 @@ * it only moves a row from outstanding to done. */ async function refreshBlanks(): Promise { - const fresh = await findBlankResources(projectResources) + const fresh = await findBlankResources(askableResources) const stillBlank = new Map(fresh.map((b) => [b.path, b])) if (blanks.length === 0) { blanks = fresh + await loadCandidates() return } blanks = blanks.map((b) => { + // A row pointed at another resource is settled once its own stub is gone: the + // project's items read the chosen resource and nothing is left at this path. A row + // whose stub was kept is not settled, and re-reading it is how filling that stub in + // finally closes the row. + if (b.reusedFrom && !b.stubKept) return b const f = stillBlank.get(b.path) // Every field the fresh read decides is taken from it, not merged selectively: these // describe what is at the path *now*. Keeping a stale `unreadable` leaves a resource @@ -369,12 +434,15 @@ justSaved: false } } - // Gone from the blank list entirely: it was read, and it is filled. + // Gone from the blank list entirely: it was read, and it is filled. `stubKept` goes + // with it — the placeholder the items this run could not move read is a credential + // now, so there is nothing left to tell anyone to fill in. return { ...b, missing: [], unreadable: undefined, occupiedBy: undefined, + stubKept: undefined, done: true, justSaved: !b.done } @@ -387,6 +455,169 @@ if (row) row.justSaved = false }, 1500) } + await loadCandidates() + } + + /** + * Which existing resources each outstanding row could be replaced by. Re-read on every + * refresh rather than once: a resource created from the editor here is a candidate for + * the rows below it. + * + * The project's own resources are never offered — one of this project's stubs standing + * in for another is a reference to something equally unfilled. + */ + async function loadCandidates(): Promise { + const types = [...new Set(blanks.map((b) => b.resourceType))] + if (types.length === 0) { + candidates = {} + return + } + const own = new Set(projectResources.map((r) => r.path)) + const next: Record = Object.fromEntries(types.map((t) => [t, []])) + try { + // One call for every type at once — `resource_type` takes a comma-separated list — + // and every page of it: `perPage` is what bounds the answer, so without the loop a + // workspace past one page would have the rest of its resources silently hidden. + for (let page = 1; page <= 100; page++) { + const rows = await ResourceService.listResource({ + workspace, + resourceType: types.join(','), + page, + perPage: 100 + }) + for (const r of rows) { + if (own.has(r.path)) continue + next[r.resource_type ?? '']?.push(r.path) + } + if (rows.length < 100) break + } + } catch { + // Offer nothing rather than a partial list: every row then behaves as it did before + // this choice existed, which is a working way to fill a credential. + candidates = {} + return + } + // An unfilled resource is never the answer to "which credential should this use" — + // another project's stub above all, which the path filter above cannot recognise. + const paths = Object.values(next).flat() + if (paths.length <= CANDIDATE_READ_CAP) { + const settled = await Promise.all(paths.map(async (p) => [p, await isUnfilled(p)] as const)) + const unfilled = new Set(settled.filter(([, empty]) => empty).map(([p]) => p)) + for (const t of Object.keys(next)) next[t] = next[t].filter((p) => !unfilled.has(p)) + } + candidates = next + } + + /** + * Whether a resource holds nothing. Same test the checklist uses to call one of the + * project's own resources blank, so a resource this drops is exactly one the wizard + * would have asked someone to fill in. + */ + async function isUnfilled(path: string): Promise { + try { + const found = await ResourceService.getResource({ workspace, path }) + const value = found?.value + if (!value || typeof value !== 'object') return true + return !Object.values(value).some((v) => v !== undefined && v !== null && v !== '') + } catch { + // A read that fails says nothing about the value, and offering it is what this did + // before the check existed. + return false + } + } + + /** + * The row's one action. A workspace that already has a resource of this type gets the + * choice first — reusing what is there is usually the answer, and entering the same + * credentials a second time is the thing worth avoiding. With nothing to choose from + * there is no choice to make, so it goes straight where it always went. + */ + function startFilling(b: Blank): void { + // A kept-stub row has already been pointed at a resource; what is left is the empty + // placeholder the items this run could not move still read. Reusing a second resource + // would move nothing — every rewritable referrer is off the stub — and would relabel + // the row after a retarget that did nothing. + if (b.done || b.stubKept || (candidates[b.resourceType] ?? []).length === 0) { + fillDirectly(b) + return + } + chosenPath = undefined + choosing = b + } + + /** Connect where the instance can, hand-fill otherwise. */ + function fillDirectly(b: Blank): void { + if (canConnectType(b.resourceType)) appConnect?.open(b.resourceType, b.path) + else resourceEditor?.initEdit(b.path) + } + + /** + * The chooser's way out: close it and do what the button did before there was a choice. + * The row is read out of the state first — closing the dialog unmounts the block that + * would otherwise be holding it. + */ + function fillNewInstead(): void { + const b = choosing + choosing = undefined + if (b) fillDirectly(b) + } + + /** + * Point the project at an existing resource: every imported item that referenced the stub + * is rewritten to the chosen path. Nothing is copied. The stub is deleted only when + * `applyRetarget` can account for every item that might read it, and kept otherwise — so + * the toast says how many items moved, and whether the placeholder is still there. + */ + async function reuseChosen(): Promise { + const b = choosing + const target = chosenPath + if (!b || !target) return + reusing = true + working = true + try { + const outcome = await applyRetarget({ + workspace, + folder: targetFolder, + from: b.path, + to: target, + // Asked of this workspace, not of whichever one the user record still describes: + // reloading on this step leaves `$userStore` pointing at the previous workspace. + seesWholeWorkspace: seesWholeWorkspace($userStore, !!$superadmin, workspace) + }) + const moved = `${outcome.rewritten.length} item${outcome.rewritten.length === 1 ? '' : 's'}` + if (outcome.error) { + sendUserToast( + `Could not point the project at ${target}: ${outcome.error}. ${moved} had already been updated, and ${b.path} was kept.`, + true + ) + return + } + choosing = undefined + const row = blanks.find((x) => x.path === b.path) + if (row) { + row.reusedFrom = target + row.stubKept = !outcome.stubDeleted + // Settled only when the stub is gone. A kept stub is empty and is still what + // every item the scan could not move reads, so the row stays outstanding and + // keeps its action: filling it in is the thing left to do. + row.done = outcome.stubDeleted + row.justSaved = outcome.stubDeleted + } + await refreshBlanks() + sendUserToast( + outcome.stubDeleted + ? `The project now uses ${target} — ${moved} updated.` + : `The project now uses ${target} — ${moved} updated. ${b.path} was kept, because some items could not be checked.` + ) + } catch (e: any) { + sendUserToast( + `Could not point the project at ${target}: ${e?.body ?? e?.message ?? String(e)}`, + true + ) + } finally { + reusing = false + working = false + } } $effect(() => { @@ -692,7 +923,18 @@
{/snippet} {#snippet detail()} - {#if b.occupiedBy} + {#if b.reusedFrom} + + now uses {b.reusedFrom} + + {#if b.stubKept} + + + some items still read {b.path} — fill it in too + + {/if} + {:else if b.occupiedBy} a {resourceTypeDisplayName(b.occupiedBy)} resource already holds this path — the project did not get this one @@ -719,15 +961,16 @@ {b.occupiedBy ? 'Resolve in the workspace' : 'Check the workspace'} + {:else if b.reusedFrom && !b.stubKept} + + Reused {:else} @@ -757,15 +1000,17 @@ size="xs" > {#if missingTables.length > 0} - The tables {missingTables.length === 1 ? 'this data table holds' : 'these data tables hold'} + The tables {missingTables.length === 1 + ? 'this data table holds' + : 'these data tables hold'} do not exist, and the project's apps and flows read them. Every one of those fails as soon as it opens. {/if} {#if uncheckedTables.length > 0} {#if missingTables.length > 0}

{/if} {uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but - {uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the - project's tables are there is unknown. Check again once the database is reachable. + {uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the project's + tables are there is unknown. Check again once the database is reachable. {/if} {:else} @@ -859,3 +1104,55 @@ void refreshBlanks()} /> + + + choosing !== undefined, + (v) => { + if (!v && !reusing) choosing = undefined + } + } +> + {#if choosing} + {@const forRow = choosing} + {@const existing = candidates[forRow.resourceType] ?? []} +
+

+ This workspace already has {existing.length} + {resourceTypeDisplayName(forRow.resourceType)} + {existing.length === 1 ? 'resource' : 'resources'}. Use one and this project's apps, flows + and triggers are pointed at it. +

+