From aafe716823d88b6d7c17a2fa3317ca6d1fa0bda9 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:25:34 +0100 Subject: [PATCH 01/58] chore: add env config for wmdev (#8209) * add wmdev startup envs * name --- .wmdev.yaml | 5 +++++ .workmux.yaml | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.wmdev.yaml b/.wmdev.yaml index 8a5afce840..c028c8f3bf 100644 --- a/.wmdev.yaml +++ b/.wmdev.yaml @@ -1,3 +1,8 @@ +name: Windmill + +startupEnvs: + CARGO_FEATURES: "quickjs" + services: - name: BE portEnv: BACKEND_PORT diff --git a/.workmux.yaml b/.workmux.yaml index fcd1906080..46049109c0 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -1,5 +1,3 @@ -name: Windmill - main_branch: main merge_strategy: rebase From f331e1f0adacd5f2a8a995ca6b20f6f6a5d3fbb4 Mon Sep 17 00:00:00 2001 From: Henri Courdent <122811744+hcourdent@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:11:54 +0100 Subject: [PATCH 02/58] Error frontend links (#8210) --- frontend/src/lib/components/InstanceSettings.svelte | 2 +- .../components/workspaceSettings/WorkspaceIntegrations.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 10f65061bf..152b58dc96 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -913,7 +913,7 @@ {:else if category == 'Indexer'} {#if pendingCallback} From 7b6f1deeb125c59c9ca6609825171d6a92c0d360 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 3 Mar 2026 16:25:05 +0000 Subject: [PATCH 03/58] update ee ref --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 516de61c27..a04b5844ea 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -8ffae1f43b31dc8136714fa612d22b6301773e27 +8ffae1f43b31dc8136714fa612d22b6301773e27 \ No newline at end of file From ee01acd9a6a2cd68a3f226988bfb46f6a6e64c08 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 3 Mar 2026 16:46:10 +0000 Subject: [PATCH 04/58] feat: move index management out of /srch/, add storage size reporting (#8169) * feat: move index management endpoints out of /srch/, add storage size reporting - Mount management_service() at /api/indexer (authenticated) - Add management_service() OSS stub in indexer_oss.rs - Update OpenAPI: /indexer/delete/{idx_name} and /indexer/storage - Show disk + S3 storage sizes in IndexerMemorySettings UI Co-Authored-By: Claude Opus 4.6 * feat: add index storage section with refresh button Move storage sizes into a dedicated "Index storage" section with a refresh button to reload sizes after clearing an index. Co-Authored-By: Claude Opus 4.6 * feat: add indexer status endpoint with liveness detection and improve settings UI Add GET /indexer/status endpoint that combines lock-based liveness detection with storage sizes. Frontend now shows running/stopped indicators with last-active timestamps for each indexer. Co-Authored-By: Claude Opus 4.6 * update ee ref * fix --------- Co-authored-by: Claude Opus 4.6 --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 96 +++++++++++- backend/windmill-api/src/indexer_oss.rs | 5 + backend/windmill-api/src/lib.rs | 17 ++- .../IndexerMemorySettings.svelte | 143 +++++++++++++++--- 5 files changed, 232 insertions(+), 31 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a04b5844ea..e0d990384a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -8ffae1f43b31dc8136714fa612d22b6301773e27 \ No newline at end of file +9b3339730eb4bb0b564c7c56ac546f33fb3d8905 \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7e89e6dd0e..4dc1d252c4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16973,9 +16973,9 @@ paths: description: count of log lines that matched the query per hostname type: object - /srch/index/delete/{idx_name}: + /indexer/delete/{idx_name}: delete: - summary: Restart container and delete the index to recreate it. + summary: Clear an index and restart the indexer. operationId: clearIndex tags: - indexSearch @@ -16990,12 +16990,102 @@ paths: - ServiceLogIndex responses: "200": - description: idx to be deleted and container restarting + description: idx to be deleted and indexer restarting content: text/plain: schema: type: string + /indexer/storage: + get: + summary: Get index storage sizes (disk and S3). + operationId: getIndexStorageSizes + tags: + - indexSearch + responses: + "200": + description: storage sizes for each index + content: + application/json: + schema: + type: object + properties: + job_index: + type: object + properties: + disk_size_bytes: + type: integer + nullable: true + s3_size_bytes: + type: integer + nullable: true + service_log_index: + type: object + properties: + disk_size_bytes: + type: integer + nullable: true + s3_size_bytes: + type: integer + nullable: true + + /indexer/status: + get: + summary: Get indexer status including liveness and storage sizes. + operationId: getIndexerStatus + tags: + - indexSearch + responses: + "200": + description: indexer status for each index + content: + application/json: + schema: + type: object + properties: + job_indexer: + type: object + properties: + is_alive: + type: boolean + last_locked_at: + type: string + format: date-time + nullable: true + owner: + type: string + nullable: true + storage: + type: object + properties: + disk_size_bytes: + type: integer + nullable: true + s3_size_bytes: + type: integer + nullable: true + log_indexer: + type: object + properties: + is_alive: + type: boolean + last_locked_at: + type: string + format: date-time + nullable: true + owner: + type: string + nullable: true + storage: + type: object + properties: + disk_size_bytes: + type: integer + nullable: true + s3_size_bytes: + type: integer + nullable: true + /w/{workspace}/assets/list: get: summary: List all assets in the workspace with cursor pagination diff --git a/backend/windmill-api/src/indexer_oss.rs b/backend/windmill-api/src/indexer_oss.rs index eee87acdcb..fd87c10fe2 100644 --- a/backend/windmill-api/src/indexer_oss.rs +++ b/backend/windmill-api/src/indexer_oss.rs @@ -14,3 +14,8 @@ pub fn workspaced_service() -> Router { pub fn global_service() -> Router { Router::new() } + +#[cfg(not(feature = "private"))] +pub fn management_service() -> Router { + Router::new() +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 76fe0224ed..9b713cad0a 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -58,10 +58,7 @@ use windmill_common::db::UserDB; use windmill_common::worker::CLOUD_HOSTED; #[allow(unused_imports)] pub(crate) use windmill_common::BASE_URL; -use windmill_common::{ - utils::GIT_VERSION, - INSTANCE_NAME, -}; +use windmill_common::{utils::GIT_VERSION, INSTANCE_NAME}; use crate::scim_oss::has_scim_token; use windmill_common::error::AppError; @@ -550,6 +547,7 @@ pub async fn run_server( .nest("/embeddings", embeddings::global_service()) .nest("/ai", ai::global_service()) .nest("/inkeep", inkeep_oss::global_service()) + .nest("/indexer", indexer_oss::management_service()) .nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service) .nest("/health/detailed", health::detailed_service()) .route_layer(from_extractor::()) @@ -612,8 +610,10 @@ pub async fn run_server( if let Some(agent_workers_job_completed_tx) = agent_workers_job_completed_tx.clone() { - windmill_api_agent_workers::global_service(agent_workers_job_completed_tx) - .layer(Extension(agent_cache.clone())) + windmill_api_agent_workers::global_service( + agent_workers_job_completed_tx, + ) + .layer(Extension(agent_cache.clone())) } else { Router::new() } @@ -785,7 +785,10 @@ pub async fn run_server( }, ) // JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix) - .route("/.well-known/jwks.json", get(windmill_api_settings::get_jwks)) + .route( + "/.well-known/jwks.json", + get(windmill_api_settings::get_jwks), + ) .fallback(static_assets::static_handler) .layer(middleware_stack); diff --git a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte index 4044c141fc..5a9dfb496a 100644 --- a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte +++ b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte @@ -2,11 +2,14 @@ import { Button } from '$lib/components/common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { IndexSearchService } from '$lib/gen' + import type { GetIndexerStatusResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import { displaySize } from '$lib/utils' import Tooltip from '../Tooltip.svelte' import IntegerInput from '../IntegerInput.svelte' import InputError from '../InputError.svelte' import Label from '../Label.svelte' + import { Loader2, RefreshCw } from 'lucide-svelte' import type { Writable } from 'svelte/store' interface Props { @@ -19,6 +22,35 @@ let clearJobsIndexModalOpen = $state(false) let clearServiceLogsIndexModalOpen = $state(false) + + let status: GetIndexerStatusResponse | undefined = $state(undefined) + let statusLoading = $state(true) + let statusError = $state(false) + + function formatTimeAgo(isoDate: string): string { + const diffMs = Date.now() - new Date(isoDate).getTime() + const diffSecs = Math.floor(diffMs / 1000) + if (diffSecs < 60) return `${diffSecs}s ago` + const diffMins = Math.floor(diffSecs / 60) + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + return `${diffHours}h ago` + } + + async function loadStatus() { + statusLoading = true + statusError = false + try { + status = await IndexSearchService.getIndexerStatus() + } catch (e) { + status = undefined + statusError = true + } finally { + statusLoading = false + } + } + + loadStatus() @@ -52,30 +84,101 @@ /> + + {#snippet action()} + + {#if statusLoading} + + {:else} + + {/if} + + {/snippet} + {#if status} + + {#each [{ label: 'Job indexer', entry: status.job_indexer }, { label: 'Service log indexer', entry: status.log_indexer }] as { label, entry } (label)} + + + {label}: + + {entry?.is_alive ? 'Running' : 'Stopped'} + + {#if entry?.last_locked_at} + + Last active: {formatTimeAgo(entry.last_locked_at)} + + {/if} + + {/each} + + {:else if statusError && !statusLoading} + + Could not fetch indexer status. Search may not be enabled in this build. + + {/if} + + + {#if status} + + + Jobs index: + {#if status.job_indexer?.storage?.disk_size_bytes != null} + Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'} + {/if} + {#if status.job_indexer?.storage?.s3_size_bytes != null} + {#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if} + S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'} + {/if} + + + Service logs index: + {#if status.log_indexer?.storage?.disk_size_bytes != null} + Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'} + {/if} + {#if status.log_indexer?.storage?.s3_size_bytes != null} + {#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if} + S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'} + {/if} + + + {/if} + This buttons will clear the whole index, and the service will start reindexing from scratch. + >These buttons will clear the whole index, and the service will start reindexing from scratch. Full text search might be down during this time. - - { - clearJobsIndexModalOpen = true - }} - > - Clear jobs index - - { - clearServiceLogsIndexModalOpen = true - }} - > - Clear service logs index - + + + { + clearJobsIndexModalOpen = true + }} + > + Clear jobs index + + + + { + clearServiceLogsIndexModalOpen = true + }} + > + Clear service logs index + + Date: Tue, 3 Mar 2026 16:48:40 +0000 Subject: [PATCH 05/58] sqlx --- ...c103178ac4a25fc2842a13ce19b1ec4445c9d.json | 14 ++++++++ ...4d4fea16c0cf71ddc233f6431cf624ecdfe60.json | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 backend/.sqlx/query-380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d.json create mode 100644 backend/.sqlx/query-bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60.json diff --git a/backend/.sqlx/query-380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d.json b/backend/.sqlx/query-380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d.json new file mode 100644 index 0000000000..10dfbd3128 --- /dev/null +++ b/backend/.sqlx/query-380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ('indexer_settings', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d" +} diff --git a/backend/.sqlx/query-bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60.json b/backend/.sqlx/query-bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60.json new file mode 100644 index 0000000000..01e0e4671e --- /dev/null +++ b/backend/.sqlx/query-bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, last_locked_at, owner FROM concurrency_locks WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "last_locked_at", + "type_info": "Timestamp" + }, + { + "ordinal": 2, + "name": "owner", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60" +} From f6ceb2e36619f2abea7ae80f505252b3dfcf0bec Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:39:24 +0100 Subject: [PATCH 06/58] Remove `edit in fork` button for app.windmill.dev (#8213) * Remove edit in fork button for app.windmill.dev * remove duplicate import --- frontend/src/lib/components/FlowBuilder.svelte | 3 ++- frontend/src/lib/components/ScriptBuilder.svelte | 2 +- .../src/lib/components/apps/editor/AppEditorHeader.svelte | 3 ++- frontend/src/lib/components/common/table/AppRow.svelte | 5 +++-- frontend/src/lib/components/common/table/FlowRow.svelte | 5 +++-- frontend/src/lib/components/common/table/ScriptRow.svelte | 5 +++-- .../src/lib/components/raw_apps/RawAppEditorHeader.svelte | 3 ++- .../routes/(root)/(logged)/flows/get/[...path]/+page.svelte | 3 ++- .../src/routes/(root)/(logged)/run/[...run]/+page.svelte | 3 ++- .../(root)/(logged)/scripts/get/[...hash]/+page.svelte | 3 ++- 10 files changed, 22 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 987f7aed6b..8b285f4323 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -98,6 +98,7 @@ import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' + import { isCloudHosted } from '$lib/cloud' let { initialPath = $bindable(''), @@ -818,7 +819,7 @@ }) } - if (!newFlow && !isRuleActive('DisableWorkspaceForking')) { + if (!newFlow && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) { dropdownItems.push({ label: 'Edit in workspace fork', onClick: () => window.open(buildForkEditUrl('flow', initialPath)) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 19f2600483..6fecf016f7 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -771,7 +771,7 @@ window.open(`/scripts/add?template=${initialPath}`) } }, - ...(!isRuleActive('DisableWorkspaceForking') + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') ? [ { label: 'Edit in workspace fork', diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index f194bc1df7..7878f3a89b 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -65,6 +65,7 @@ import { updatePolicy } from './appPolicy' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' + import { isCloudHosted } from '$lib/cloud' interface Props { policy: Policy @@ -1121,7 +1122,7 @@ window.open(`/apps/add?template=${appPath}`) } }, - ...(!isRuleActive('DisableWorkspaceForking') + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') ? [ { label: 'Edit in workspace fork', diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 3998b15af5..b9441e62ef 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -34,6 +34,7 @@ import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' + import { isCloudHosted } from '$lib/cloud' interface Props { app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } @@ -114,7 +115,7 @@ {/if} - {#if !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !app.canWrite)} + {#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !app.canWrite)} {/if} - {#if !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !flow.canWrite)} + {#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !flow.canWrite)} {/if} {/if} - {#if !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !script.canWrite)} + {#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !script.canWrite)} Edit {/if} - {#if !showEditButton && !isRuleActive('DisableWorkspaceForking')} + {#if !showEditButton && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')} Date: Tue, 3 Mar 2026 19:41:11 +0100 Subject: [PATCH 07/58] feat(frontend): add script recorder for offline replay (#8200) * feat(frontend): add script recorder for offline replay of script test executions Co-Authored-By: Claude Opus 4.6 * fix(frontend): use Video icon for recording instead of Circle Co-Authored-By: Claude Opus 4.6 * fix(frontend): use Disc icon for recording Co-Authored-By: Claude Opus 4.6 * fix(frontend): improve script recorder replay and recording privacy - Record schema at capture time in ScriptRecording (lockfile unavailable for previews) - Read schema from recording instead of job object in replay view - Remove lockfile tab (not available via normal job API for preview jobs) - Use text-xs for code/schema views, remove max-height limits - Disable log download button in replay (endpoint won't work without real job) - Truncate UUIDs in downloaded recordings (last 8 chars) for privacy - Make activeReplay a $state so $derived(isReplay) in FlowStatusViewerInner updates reactively, preventing stale reads that caused API calls during replay - Use JSON round-trip instead of structuredClone to unwrap $state proxies Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/package.json | 8 + .../src/lib/components/FlowBuilder.svelte | 4 +- frontend/src/lib/components/JobLoader.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 124 +++++++--- frontend/src/lib/components/custom_ui.ts | 1 + .../recording/ScriptRecordingReplay.svelte | 219 ++++++++++++++++++ .../recording/flowRecording.svelte.ts | 31 ++- .../recording/scriptRecording.svelte.ts | 110 +++++++++ .../src/lib/components/recording/types.ts | 25 ++ .../components/scriptEditor/LogPanel.svelte | 4 +- .../(root)/(logged)/replay/+page.svelte | 45 +++- 11 files changed, 523 insertions(+), 52 deletions(-) create mode 100644 frontend/src/lib/components/recording/ScriptRecordingReplay.svelte create mode 100644 frontend/src/lib/components/recording/scriptRecording.svelte.ts diff --git a/frontend/package.json b/frontend/package.json index 360003f0e3..9232f07db8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -287,6 +287,11 @@ "svelte": "./package/components/recording/FlowRecordingReplay.svelte", "default": "./package/components/recording/FlowRecordingReplay.svelte" }, + "./components/ScriptRecordingReplay.svelte": { + "types": "./package/components/recording/ScriptRecordingReplay.svelte.d.ts", + "svelte": "./package/components/recording/ScriptRecordingReplay.svelte", + "default": "./package/components/recording/ScriptRecordingReplay.svelte" + }, "./components/FlowWrapper.svelte": { "types": "./package/components/FlowWrapper.svelte.d.ts", "svelte": "./package/components/FlowWrapper.svelte", @@ -489,6 +494,9 @@ "components/FlowRecordingReplay.svelte": [ "./package/components/recording/FlowRecordingReplay.svelte.d.ts" ], + "components/ScriptRecordingReplay.svelte": [ + "./package/components/recording/ScriptRecordingReplay.svelte.d.ts" + ], "components/FlowBuilder.svelte": [ "./package/components/FlowBuilder.svelte.d.ts" ], diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8b285f4323..8ec0acbdf1 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -58,7 +58,7 @@ CheckCircle, RefreshCw, CheckCheck, - Focus + Disc } from 'lucide-svelte' import Awareness from './Awareness.svelte' import { getAllModules } from './flows/flowExplorer' @@ -940,7 +940,7 @@ }, { displayName: 'Test flow & record', - icon: Focus, + icon: Disc, action: () => flowPreviewButtons?.openRecordingPreview() } ] diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index c918b0c1ea..75d2f09903 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -369,8 +369,8 @@ if (replay) { const recorded = replay.jobs[testId] if (recorded) { - job = structuredClone(recorded.initial_job) - callbacks?.change?.(job) + job = JSON.parse(JSON.stringify(recorded.initial_job)) + callbacks?.change?.(job!) // Compute delays relative to replay start so sub-jobs (discovered // later by FlowStatusViewerInner) stay in sync with the root job. const elapsed = Date.now() - getReplayStartTime() diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 4659abc0ac..5fe03b03fd 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -33,6 +33,8 @@ Bug, Copy, CornerDownLeft, + Disc, + Download, ExternalLink, Github, GitBranch, @@ -86,6 +88,10 @@ import { deepEqual } from 'fast-equals' import { usePreparedAssetSqlQueries } from '$lib/infer.svelte' import { resource, watch } from 'runed' + import { createScriptRecording } from './recording/scriptRecording.svelte' + import { setActiveRecording } from './recording/flowRecording.svelte' + import type { ScriptRecording } from './recording/types' + import DropdownV2 from './DropdownV2.svelte' interface Props { // Exported @@ -235,6 +241,10 @@ let pastPreviews: CompletedJob[] = $state([]) let validCode = $state(true) + // Recording + let scriptRecording = createScriptRecording() + let lastRecording: ScriptRecording | undefined = $state(undefined) + let wsProvider: WebsocketProvider | undefined = $state(undefined) let yContent: Y.Text | undefined = $state(undefined) let peers: { name: string }[] = $state([]) @@ -332,11 +342,18 @@ undefined, { done(_x) { + if (scriptRecording.active) { + lastRecording = scriptRecording.stop() + setActiveRecording(undefined) + } loadPastTests() }, doneError({ error }) { + if (scriptRecording.active) { + lastRecording = scriptRecording.stop() + setActiveRecording(undefined) + } console.error(error) - // sendUserToast('Error running test', true) } } ) @@ -344,6 +361,19 @@ return job } + async function recordAndTest() { + lastRecording = undefined + scriptRecording.start(path ?? '', code, lang ?? '', args ?? {}, schema) + setActiveRecording(scriptRecording) + await runTest() + } + + function downloadRecording() { + if (lastRecording) { + scriptRecording.download(lastRecording) + } + } + async function loadPastTests(): Promise { pastPreviews = await JobService.listCompletedJobs({ workspace: $workspaceStore!, @@ -1110,40 +1140,72 @@ /> {#if !(debugMode && isDebuggableScript)} - - {#if testIsLoading} - - - Cancel - - {:else} - {@const disableTriggerButton = - customUi?.previewPanel?.disableTriggerButton === true} - runTest()} - unifiedSize="md" - btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}" - variant="accent-secondary" - startIcon={{ icon: Play, classes: 'animate-none' }} - shortCut={{ Icon: CornerDownLeft }} - > - Test - - {#if !disableTriggerButton} - + + + {#if testIsLoading} + + + Cancel + + {:else} + {@const disableTriggerButton = + customUi?.previewPanel?.disableTriggerButton === true} + runTest()} + unifiedSize="md" + btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}" + variant="accent-secondary" + startIcon={{ icon: Play, classes: 'animate-none' }} + shortCut={{ Icon: CornerDownLeft }} + > + Test + + {#if !disableTriggerButton} + + {/if} {/if} + + {#if lastRecording} + {/if} {/if} - + + + recordAndTest() + }, + ...(lastRecording + ? [ + { + displayName: 'Download recording', + icon: Download, + action: () => downloadRecording() + } + ] + : []) + ]} + /> + + import type { Job, Script } from '$lib/gen' + import { setActiveReplay } from './flowRecording.svelte' + import { createScriptRecording } from './scriptRecording.svelte' + import type { ScriptRecording } from './types' + import { sendUserToast } from '$lib/toast' + import { Button, Tab, TabContent } from '$lib/components/common' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' + import Tabs from '$lib/components/common/tabs/Tabs.svelte' + import HighlightCode from '$lib/components/HighlightCode.svelte' + import { Highlight } from 'svelte-highlight' + import { json as jsonLang } from 'svelte-highlight/languages' + import HighlightTheme from '$lib/components/HighlightTheme.svelte' + import JobArgs from '$lib/components/JobArgs.svelte' + import DisplayResult from '$lib/components/DisplayResult.svelte' + import LogViewer from '$lib/components/LogViewer.svelte' + import { ClipboardCopy, InfoIcon, LogOut, Play, Square } from 'lucide-svelte' + import { copyToClipboard } from '$lib/utils' + import { onDestroy, tick } from 'svelte' + import JobLoader from '$lib/components/JobLoader.svelte' + + interface Props { + recording: ScriptRecording + } + + let { recording }: Props = $props() + + type ReplayState = 'loaded' | 'playing' + + let replayState: ReplayState = $state('loaded') + let jobId: string | undefined = $state(undefined) + let job: Job | undefined = $state(undefined) + let jobLoader: JobLoader | undefined = $state(undefined) + let done = $derived((job as any)?.type === 'CompletedJob') + + let scriptRecordingStore = createScriptRecording() + + function stop() { + setActiveReplay(undefined) + job = undefined + replayState = 'loaded' + } + + /** + * Rebase absolute timestamps so they are relative to "now". + * JobLoader replay uses Date.now() for delay computation. + */ + function rebaseTimestamps(data: ScriptRecording): ScriptRecording { + const anchor = data.job?.initial_job?.started_at ?? data.job?.initial_job?.created_at + if (!anchor) return data + const earliest = new Date(anchor).getTime() + if (isNaN(earliest)) return data + + const offset = Date.now() - earliest + + function offsetDate(d: string | number | undefined): string | undefined { + if (!d) return d as undefined + const t = new Date(d).getTime() + if (isNaN(t)) return d as string + return new Date(t + offset).toISOString() + } + + function offsetJobTimestamps(j: any) { + if (j.started_at) j.started_at = offsetDate(j.started_at) + if (j.created_at) j.created_at = offsetDate(j.created_at) + if (j.completed_at) j.completed_at = offsetDate(j.completed_at) + } + + offsetJobTimestamps(data.job.initial_job) + for (const event of data.job.events) { + if (event.data?.job) offsetJobTimestamps(event.data.job) + } + return data + } + + function initRecording() { + const id = recording.job?.initial_job?.id + if (!id) { + sendUserToast('Recording has no job data', true) + return + } + jobId = id + replayState = 'loaded' + } + + initRecording() + + async function startReplay() { + const snapshot = JSON.parse(JSON.stringify(recording)) as ScriptRecording + rebaseTimestamps(snapshot) + const replayData = scriptRecordingStore.toReplayData(snapshot) + setActiveReplay(replayData) + job = undefined + replayState = 'playing' + await tick() + if (jobLoader && jobId) { + jobLoader.watchJob(jobId) + } + } + + onDestroy(() => { + setActiveReplay(undefined) + }) + + let schema = $derived(recording.schema) + + + + +{#if !recording?.job?.initial_job?.id} + + + + This recording does not contain valid job data. It may have been recorded incorrectly. + + + +{:else if replayState === 'loaded'} + + + + {recording.script_path || 'Untitled script'} + {recording.language} + + + + Recorded {new Date(recording.recorded_at).toLocaleString()} — + {(recording.total_duration_ms / 1000).toFixed(1)}s + + + + + Play + + + + {#if recording.args && Object.keys(recording.args).length > 0} + + {/if} + + + + {#if schema} + + {/if} + {#snippet content()} + + + + + + + + {#if schema} + copyToClipboard(JSON.stringify(schema, null, 4))} + class="absolute top-2 right-2" + > + + + + {:else} + + No schema available in this recording + + {/if} + + + {/snippet} + + +{:else if replayState === 'playing' && jobId} + + + Replaying: {recording.script_path || 'Untitled script'} + + {done ? 'Exit' : 'Stop'} + + + + + {#if done && job} + + Result + + {#if job.type === 'CompletedJob' && job.result !== undefined} + + {:else} + No result available + {/if} + + + {/if} + + + + + +{/if} diff --git a/frontend/src/lib/components/recording/flowRecording.svelte.ts b/frontend/src/lib/components/recording/flowRecording.svelte.ts index 2e715c7082..bf7d705a3f 100644 --- a/frontend/src/lib/components/recording/flowRecording.svelte.ts +++ b/frontend/src/lib/components/recording/flowRecording.svelte.ts @@ -1,21 +1,38 @@ import type { Job, OpenFlow } from '$lib/gen' -import type { FlowRecording, RecordedJob } from './types' +import type { ActiveRecording, ActiveReplayData, FlowRecording, RecordedJob } from './types' + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + +export function truncateUuids(json: string): string { + const map = new Map() + let counter = 0 + return json.replace(UUID_RE, (uuid) => { + const key = uuid.toLowerCase() + let short = map.get(key) + if (!short) { + short = counter === 0 ? key.slice(-8) : key.slice(-8) + '_' + counter + counter++ + map.set(key, short) + } + return short + }) +} // Module-level active instances (bypasses context/portal issues) -let activeRecording: FlowRecordingStore | undefined = undefined -let activeReplay: FlowRecording | undefined = undefined +let activeRecording: ActiveRecording | undefined = undefined +let activeReplay: ActiveReplayData | undefined = $state(undefined) let replayStartTime: number = 0 export function getActiveRecording() { return activeRecording } -export function setActiveRecording(r: FlowRecordingStore | undefined) { +export function setActiveRecording(r: ActiveRecording | undefined) { activeRecording = r } export function getActiveReplay() { return activeReplay } -export function setActiveReplay(r: FlowRecording | undefined) { +export function setActiveReplay(r: ActiveReplayData | undefined) { activeReplay = r replayStartTime = r ? Date.now() : 0 } @@ -178,7 +195,9 @@ export function createFlowRecording() { } }, download(recording: FlowRecording) { - const blob = new Blob([JSON.stringify(recording, null, 2)], { type: 'application/json' }) + const blob = new Blob([truncateUuids(JSON.stringify(recording, null, 2))], { + type: 'application/json' + }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url diff --git a/frontend/src/lib/components/recording/scriptRecording.svelte.ts b/frontend/src/lib/components/recording/scriptRecording.svelte.ts new file mode 100644 index 0000000000..d8f09b6beb --- /dev/null +++ b/frontend/src/lib/components/recording/scriptRecording.svelte.ts @@ -0,0 +1,110 @@ +import type { Job } from '$lib/gen' +import type { ActiveRecording, ActiveReplayData, RecordedJob, ScriptRecording } from './types' +import { truncateUuids } from './flowRecording.svelte' + +export function createScriptRecording(): ScriptRecordingStore { + let active = $state(false) + let startTime = 0 + let scriptPath = '' + let code = '' + let language = '' + let scriptArgs: Record = {} + let scriptSchema: Record | undefined = undefined + let recordedJob: RecordedJob | undefined = undefined + + return { + get active() { + return active + }, + start( + path: string, + scriptCode: string, + lang: string, + args: Record, + schema?: Record + ) { + active = true + startTime = Date.now() + scriptPath = path + code = scriptCode + language = lang + scriptArgs = JSON.parse(JSON.stringify(args)) + scriptSchema = schema ? JSON.parse(JSON.stringify(schema)) : undefined + recordedJob = undefined + }, + recordInitialJob(_id: string, job: Job) { + if (!active) return + recordedJob = { + initial_job: $state.snapshot(job) as Job, + events: [] + } + }, + recordEvent(_id: string, data: Record) { + if (!active) return + if (!recordedJob) { + recordedJob = { + initial_job: (data as any).job + ? ($state.snapshot((data as any).job) as Job) + : ({} as Job), + events: [] + } + } + recordedJob.events.push({ + t: Date.now() - startTime, + data: $state.snapshot(data) as Record + }) + }, + stop(): ScriptRecording { + active = false + const recording: ScriptRecording = { + version: 1, + type: 'script', + recorded_at: new Date().toISOString(), + script_path: scriptPath, + total_duration_ms: Date.now() - startTime, + code, + language, + args: scriptArgs, + schema: scriptSchema, + job: recordedJob ?? { initial_job: {} as Job, events: [] } + } + return recording + }, + /** Convert to ActiveReplayData shape for JobLoader replay */ + toReplayData(recording: ScriptRecording): ActiveReplayData { + // Find the job ID from the recorded initial_job + const id = recording.job.initial_job?.id + if (!id) { + return { jobs: {} } + } + return { + jobs: { [id]: recording.job } + } + }, + download(recording: ScriptRecording) { + const blob = new Blob([truncateUuids(JSON.stringify(recording, null, 2))], { + type: 'application/json' + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `script-recording-${(recording.script_path || 'untitled').replace(/\//g, '-')}-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + } +} + +export type ScriptRecordingStore = ActiveRecording & { + readonly active: boolean + start( + path: string, + code: string, + lang: string, + args: Record, + schema?: Record + ): void + stop(): ScriptRecording + toReplayData(recording: ScriptRecording): ActiveReplayData + download(recording: ScriptRecording): void +} diff --git a/frontend/src/lib/components/recording/types.ts b/frontend/src/lib/components/recording/types.ts index 86e7aac754..8d1dc8ca27 100644 --- a/frontend/src/lib/components/recording/types.ts +++ b/frontend/src/lib/components/recording/types.ts @@ -12,9 +12,34 @@ export type RecordedJob = { export type FlowRecording = { version: 1 + type?: 'flow' recorded_at: string flow_path: string total_duration_ms: number jobs: Record flow?: OpenFlow } + +export type ScriptRecording = { + version: 1 + type: 'script' + recorded_at: string + script_path: string + total_duration_ms: number + code: string + language: string + args: Record + schema?: Record + job: RecordedJob +} + +/** Minimal interface that both flow and script recording stores implement */ +export interface ActiveRecording { + recordInitialJob(jobId: string, job: Job): void + recordEvent(jobId: string, data: Record): void +} + +/** Shape needed by JobLoader replay — a map of job ID to recorded data */ +export interface ActiveReplayData { + jobs: Record +} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 013c87b6c9..c47c05c281 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -128,7 +128,9 @@ {#if showCaptures && customUi?.disableTriggerCaptures !== true} {/if} - + {#if customUi?.disableTracing !== true} + + {/if} {#snippet content()} diff --git a/frontend/src/routes/(root)/(logged)/replay/+page.svelte b/frontend/src/routes/(root)/(logged)/replay/+page.svelte index 4309a2078f..559961aff0 100644 --- a/frontend/src/routes/(root)/(logged)/replay/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/replay/+page.svelte @@ -1,24 +1,41 @@ - {#if recording} + {#if flowRecording} Load another recording - + + {:else if scriptRecording} + + + Load another recording + + + {:else} - Replay a flow recording + Replay a recording - Upload a recording JSON file to replay a flow execution offline. + Upload a recording JSON file to replay a flow or script execution offline. Date: Wed, 4 Mar 2026 07:14:00 +0000 Subject: [PATCH 08/58] chore(main): release 1.649.0 (#8198) * chore(main): release 1.649.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 22 +++ backend/Cargo.lock | 172 +++++++++--------- backend/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.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 +- 15 files changed, 126 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fca3e649..ae23b08efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.649.0](https://github.com/windmill-labs/windmill/compare/v1.648.0...v1.649.0) (2026-03-03) + + +### Features + +* **frontend:** add script recorder for offline replay ([#8200](https://github.com/windmill-labs/windmill/issues/8200)) ([c97d8b4](https://github.com/windmill-labs/windmill/commit/c97d8b4715f86ea83ab2c0223ba859ced690829a)) +* move index management out of /srch/, add storage size reporting ([#8169](https://github.com/windmill-labs/windmill/issues/8169)) ([ee01acd](https://github.com/windmill-labs/windmill/commit/ee01acd9a6a2cd68a3f226988bfb46f6a6e64c08)) + + +### Bug Fixes + +* clean up slow-load toast interval on component destroy ([#8207](https://github.com/windmill-labs/windmill/issues/8207)) ([26f4f2b](https://github.com/windmill-labs/windmill/commit/26f4f2b399b828185b553289d6560e12261030a3)) +* **frontend:** prevent subflow expansion from hiding all insertion points ([#8203](https://github.com/windmill-labs/windmill/issues/8203)) ([e97da86](https://github.com/windmill-labs/windmill/commit/e97da860672171e33054a77d71f4824bb09e540d)) +* gracefully handle malformed OAuth entries in instance config ([#8205](https://github.com/windmill-labs/windmill/issues/8205)) ([cac4bdd](https://github.com/windmill-labs/windmill/commit/cac4bdd54f0c3ea80844ac31f7597f418ff7d8ae)) +* skip stop_after_if evaluation for skipped (identity) flow steps ([#8201](https://github.com/windmill-labs/windmill/issues/8201)) ([e6f7775](https://github.com/windmill-labs/windmill/commit/e6f7775d4d9a052aefc37260c6ed161146841cd7)) +* use exact matching for python requirements directive parsing ([#8199](https://github.com/windmill-labs/windmill/issues/8199)) ([2b2be38](https://github.com/windmill-labs/windmill/commit/2b2be38f129bbe58b6bb3815c4bd94aa03a3da90)) + + +### Performance Improvements + +* use two-step query in input history to leverage v2_job index ([#8197](https://github.com/windmill-labs/windmill/issues/8197)) ([50defdd](https://github.com/windmill-labs/windmill/commit/50defdded113b4d2cf0991b3fb642d1cd9a462b7)) + ## [1.648.0](https://github.com/windmill-labs/windmill/compare/v1.647.2...v1.648.0) (2026-03-02) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a2a27a9413..d26d5b76d7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -860,9 +860,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", @@ -870,9 +870,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", @@ -1334,9 +1334,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.14" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" dependencies = [ "xmlparser", ] @@ -6173,20 +6173,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -7421,9 +7421,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "ipnetwork" @@ -10723,6 +10723,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -13850,7 +13856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -15732,7 +15738,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-nats", @@ -15796,7 +15802,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15809,7 +15815,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "argon2", @@ -15947,7 +15953,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15970,7 +15976,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15983,7 +15989,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16009,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.648.0" +version = "1.649.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16019,7 +16025,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16036,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16059,7 +16065,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16082,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16098,7 +16104,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16118,7 +16124,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16138,7 +16144,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16152,7 +16158,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-nats", @@ -16179,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16204,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16222,7 +16228,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16243,7 +16249,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16263,7 +16269,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16293,7 +16299,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16320,7 +16326,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.648.0" +version = "1.649.0" dependencies = [ "lazy_static", "serde", @@ -16332,7 +16338,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.648.0" +version = "1.649.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16355,7 +16361,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16369,7 +16375,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.648.0" +version = "1.649.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16399,7 +16405,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.648.0" +version = "1.649.0" dependencies = [ "chrono", "lazy_static", @@ -16413,7 +16419,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16432,7 +16438,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.648.0" +version = "1.649.0" dependencies = [ "aes-gcm", "anyhow", @@ -16531,7 +16537,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.648.0" +version = "1.649.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16550,7 +16556,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.648.0" +version = "1.649.0" dependencies = [ "regex", "serde", @@ -16565,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16589,7 +16595,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "futures", @@ -16606,7 +16612,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.648.0" +version = "1.649.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16622,7 +16628,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -16643,7 +16649,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -16674,7 +16680,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-oauth2", @@ -16698,7 +16704,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-stream", @@ -16732,7 +16738,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "futures", @@ -16750,7 +16756,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.648.0" +version = "1.649.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16759,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "lazy_static", @@ -16771,7 +16777,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "serde_json", @@ -16783,7 +16789,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "gosyn", @@ -16795,7 +16801,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "lazy_static", @@ -16807,7 +16813,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "serde_json", @@ -16819,7 +16825,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "nu-parser", @@ -16830,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16841,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16854,7 +16860,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-recursion", @@ -16878,7 +16884,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "lazy_static", @@ -16892,7 +16898,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16909,7 +16915,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "lazy_static", @@ -16924,7 +16930,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "lazy_static", @@ -16943,7 +16949,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "serde", @@ -16954,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-recursion", @@ -16991,7 +16997,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "const_format", @@ -17029,7 +17035,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.648.0" +version = "1.649.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17040,7 +17046,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-recursion", @@ -17069,7 +17075,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17092,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17125,7 +17131,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17145,7 +17151,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17179,7 +17185,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17214,7 +17220,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17237,7 +17243,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17261,7 +17267,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-nats", @@ -17285,7 +17291,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17320,7 +17326,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17348,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-trait", @@ -17371,7 +17377,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17389,7 +17395,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.648.0" +version = "1.649.0" dependencies = [ "anyhow", "async-once-cell", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2d964c45b4..195d8bd30d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.648.0" +version = "1.649.0" authors.workspace = true edition.workspace = true @@ -76,7 +76,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.648.0" +version = "1.649.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4dc1d252c4..163217862e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.648.0 + version: 1.649.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 41204c956a..cc1417c4b3 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.648.0"; +export const VERSION = "v1.649.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 38b68bb0f4..b584c540d8 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -67,7 +67,7 @@ export { workspaceAdd, }; -export const VERSION = "1.648.0"; +export const VERSION = "1.649.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 520148b1d6..c24e48e02d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.648.0", + "version": "1.649.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.648.0", + "version": "1.649.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 9232f07db8..d6c6554459 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.648.0", + "version": "1.649.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index c01fab216e..37aecedea0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.648.0" +wmill = ">=1.649.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9f9ee14ffb..2b181a6c7b 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.648.0 + version: 1.649.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 53f6b21227..ec7cea297d 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.648.0' + ModuleVersion = '1.649.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 0d6daad51f..368b0de11b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.648.0" +version = "1.649.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 e4842b836b..8b07a94942 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.648.0", + "version": "1.649.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 71689ffd27..a5cd2f4395 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.648.0", + "version": "1.649.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 53ea075c24..c34844244a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.648.0 +1.649.0 From 424ca59dfe3e730f5388d9cac4ea7e69773614d3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 4 Mar 2026 08:53:25 +0000 Subject: [PATCH 09/58] feat: make WINDMILL_DIR configurable via environment variable (#8215) * fix: auto-heal corrupted python runtime cache on remote workers Co-Authored-By: Claude Opus 4.6 * Revert "fix: auto-heal corrupted python runtime cache on remote workers" This reverts commit 0ea013a5545ffb14e21a8d280d01b184ba50b032. * feat: make WINDMILL_DIR configurable via environment variable Allow users to configure the base directory for Windmill's tmp/cache files via the WINDMILL_DIR env var (default: /tmp/windmill). This fixes Python runtime cache corruption on RHEL systems where systemd-tmpfiles-clean removes files from /tmp. Converts TMP_DIR (renamed to WINDMILL_DIR) and all derived cache directory constants from compile-time const &str (concatcp!) to runtime lazy_static String values. Co-Authored-By: Claude Opus 4.6 * chore: update ee ref Co-Authored-By: Claude Opus 4.6 * chore: update ee ref Co-Authored-By: Claude Opus 4.6 * fix: deref ERROR_DIR lazy_static for AsRef and Display traits Co-Authored-By: Claude Opus 4.6 * chore: update ee ref to branch name for CI compatibility Co-Authored-By: Claude Opus 4.6 * fix: deref lazy_static constants in all executor files Co-Authored-By: Claude Opus 4.6 * chore: update ee ref Co-Authored-By: Claude Opus 4.6 * chore: update ee ref Co-Authored-By: Claude Opus 4.6 * chore: update ee ref Co-Authored-By: Claude Opus 4.6 * fix: panic if WINDMILL_DIR has trailing slash Co-Authored-By: Claude Opus 4.6 * fix: also reject trailing backslash in WINDMILL_DIR for Windows Co-Authored-By: Claude Opus 4.6 * fix: deref GO_BIN_CACHE_DIR in test utils Co-Authored-By: Claude Opus 4.6 * fix: replace remaining hardcoded /tmp/windmill paths and validate empty WINDMILL_DIR Co-Authored-By: Claude Opus 4.6 * fix: nsjail powershell mount dst, Windows path assumptions, pwsh deref consistency Co-Authored-By: Claude Opus 4.6 * fix: restore Windows /tmp path translation in go and bun executors The Windows path translation replaces /tmp with the Windows temp dir (e.g. C:\tmp) before normalizing slashes. Without this, the default WINDMILL_DIR=/tmp/windmill produces paths without a drive letter on Windows. Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to 6fd5a2ce908235a17975ad4dbdf0051cd89334f3 This commit updates the EE repository reference after PR #436 was merged in windmill-ee-private. Previous ee-repo-ref: e8c03e16720833230ebd1878b4c63642ecc6c80f New ee-repo-ref: 6fd5a2ce908235a17975ad4dbdf0051cd89334f3 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/src/main.rs | 60 ++++++++-------- backend/src/monitor.rs | 17 ++--- backend/tests/nativets_stress.rs | 2 +- backend/tests/python_jobs.rs | 23 ++++--- backend/windmill-api-settings/src/lib.rs | 2 +- backend/windmill-api/src/jobs.rs | 16 ++--- backend/windmill-api/src/lib.rs | 2 +- backend/windmill-api/src/service_logs.rs | 8 ++- backend/windmill-api/src/workspaces_export.rs | 3 +- backend/windmill-common/src/bench.rs | 5 +- backend/windmill-common/src/jobs.rs | 6 +- backend/windmill-common/src/scripts.rs | 4 +- backend/windmill-common/src/tracing_init.rs | 8 +-- backend/windmill-common/src/worker.rs | 29 +++++--- backend/windmill-runtime-nativets/src/lib.rs | 21 +++--- backend/windmill-store/src/resources.rs | 4 +- backend/windmill-test-utils/src/lib.rs | 2 +- .../nsjail/run.powershell.config.proto | 2 +- .../windmill-worker/src/ansible_executor.rs | 6 +- backend/windmill-worker/src/bash_executor.rs | 2 +- backend/windmill-worker/src/bun_executor.rs | 6 +- backend/windmill-worker/src/common.rs | 11 ++- .../windmill-worker/src/csharp_executor.rs | 34 ++++----- backend/windmill-worker/src/deno_executor.rs | 6 +- backend/windmill-worker/src/global_cache.rs | 10 +-- backend/windmill-worker/src/go_executor.rs | 25 ++++--- backend/windmill-worker/src/java_executor.rs | 33 ++++----- backend/windmill-worker/src/nu_executor.rs | 6 +- backend/windmill-worker/src/pwsh_executor.rs | 10 +-- .../windmill-worker/src/python_executor.rs | 10 +-- .../windmill-worker/src/python_versions.rs | 21 +++--- backend/windmill-worker/src/ruby_executor.rs | 17 +++-- backend/windmill-worker/src/rust_executor.rs | 27 ++++---- backend/windmill-worker/src/worker.rs | 69 ++++++++++--------- 35 files changed, 269 insertions(+), 240 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e0d990384a..a71cab586d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9b3339730eb4bb0b564c7c56ac546f33fb3d8905 \ No newline at end of file +6fd5a2ce908235a17975ad4dbdf0051cd89334f3 diff --git a/backend/src/main.rs b/backend/src/main.rs index c4440b7f34..97980484a5 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -62,7 +62,7 @@ use windmill_common::{ }, worker::{ is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR, - HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP, + HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP, }, KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED, }; @@ -238,8 +238,8 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { ) })?; - create_dir_all(HUB_CACHE_DIR)?; - create_dir_all(BUN_BUNDLE_CACHE_DIR)?; + create_dir_all(&*HUB_CACHE_DIR)?; + create_dir_all(&*BUN_BUNDLE_CACHE_DIR)?; for path in paths.values() { tracing::info!("Caching hub script at {path}"); @@ -249,7 +249,7 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { .as_ref() .is_some_and(|x| x == &ScriptLang::Deno) { - let job_dir = format!("{}/cache_init/{}", TMP_DIR, Uuid::new_v4()); + let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, Uuid::new_v4()); create_dir_all(&job_dir)?; let _ = windmill_worker::generate_deno_lock( &Uuid::nil(), @@ -267,7 +267,7 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { tokio::fs::remove_dir_all(job_dir).await?; } else if res.language.as_ref().is_some_and(|x| x == &ScriptLang::Bun) { let job_id = Uuid::new_v4(); - let job_dir = format!("{}/cache_init/{}", TMP_DIR, job_id); + let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, job_id); create_dir_all(&job_dir)?; if let Some(lock) = res.lockfile { let _ = windmill_worker::prepare_job_dir(&lock, &job_dir).await?; @@ -384,9 +384,9 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> { println!("Fetched {} resource types from hub", resource_types.len()); - create_dir_all(HUB_RT_CACHE_DIR)?; + create_dir_all(&*HUB_RT_CACHE_DIR)?; - let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); + let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); let content = serde_json::to_string_pretty(&resource_types) .with_context(|| "Failed to serialize resource types")?; @@ -398,7 +398,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> { } pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyhow::Result<()> { - let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); + let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); if tokio::fs::metadata(&cache_path).await.is_err() { tracing::info!( @@ -969,7 +969,7 @@ Windmill Community Edition {GIT_VERSION} DirBuilder::new() .recursive(true) - .create("/tmp/windmill") + .create(&*WINDMILL_DIR) .expect("could not create initial server dir"); #[cfg(feature = "tantivy")] @@ -1794,27 +1794,27 @@ pub async fn run_workers( let mut handles = Vec::with_capacity(num_workers as usize); for x in [ - TMP_LOGS_DIR, - UV_CACHE_DIR, - DENO_CACHE_DIR, - DENO_CACHE_DIR_DEPS, - DENO_CACHE_DIR_NPM, - BUN_CACHE_DIR, - PY310_CACHE_DIR, - PY311_CACHE_DIR, - PY312_CACHE_DIR, - PY313_CACHE_DIR, - BUN_BUNDLE_CACHE_DIR, - GO_CACHE_DIR, - GO_BIN_CACHE_DIR, - RUST_CACHE_DIR, - CSHARP_CACHE_DIR, - NU_CACHE_DIR, - HUB_CACHE_DIR, - POWERSHELL_CACHE_DIR, - JAVA_CACHE_DIR, - RUBY_CACHE_DIR, - TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG + &*TMP_LOGS_DIR, + &*UV_CACHE_DIR, + &*DENO_CACHE_DIR, + &*DENO_CACHE_DIR_DEPS, + &*DENO_CACHE_DIR_NPM, + &*BUN_CACHE_DIR, + &*PY310_CACHE_DIR, + &*PY311_CACHE_DIR, + &*PY312_CACHE_DIR, + &*PY313_CACHE_DIR, + &*BUN_BUNDLE_CACHE_DIR, + &*GO_CACHE_DIR, + &*GO_BIN_CACHE_DIR, + &*RUST_CACHE_DIR, + &*CSHARP_CACHE_DIR, + &*NU_CACHE_DIR, + &*HUB_CACHE_DIR, + &*POWERSHELL_CACHE_DIR, + &*JAVA_CACHE_DIR, + &*RUBY_CACHE_DIR, + &*TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG ] { DirBuilder::new() .recursive(true) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 17e79c3393..6baba34a02 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -73,7 +73,7 @@ use windmill_common::{ load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env, load_worker_config, reload_custom_tags_setting, store_pull_query, store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE, - DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, TMP_DIR, + DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, }, KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED, @@ -595,7 +595,7 @@ async fn sleep_until_next_minute_start_plus_one_s() { use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE; async fn find_two_highest_files(hostname: &str) -> (Option, Option) { - let log_dir = format!("{}/{}/", TMP_WINDMILL_LOGS_SERVICE, hostname); + let log_dir = format!("{}/{}/", *TMP_WINDMILL_LOGS_SERVICE, hostname); let rd_dir = tokio::fs::read_dir(log_dir).await; if let Ok(mut log_files) = rd_dir { let mut highest_file: Option = None; @@ -614,7 +614,8 @@ async fn find_two_highest_files(hostname: &str) -> (Option, Option () { .iter() .map(|f| format!("{}/{}", f.hostname, f.file_path)) .collect(); - delete_log_files_from_disk_and_store(paths, TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await; + delete_log_files_from_disk_and_store(paths, &*TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await; } Err(e) => tracing::error!("Error deleting log file: {:?}", e), @@ -1140,7 +1141,7 @@ async fn delete_expired_jobs_batch( .filter_map(|opt| opt) .flat_map(|inner_vec| inner_vec.into_iter()) .collect(); - delete_log_files_from_disk_and_store(paths, TMP_DIR, "").await; + delete_log_files_from_disk_and_store(paths, &*WINDMILL_DIR, "").await; } Err(e) => tracing::error!("Error deleting job logs: {:?}", e), } @@ -1367,7 +1368,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) { let settings_xml = MAVEN_SETTINGS_XML.read().await.clone(); match settings_xml { Some(ref content) if !content.trim().is_empty() => { - let m2_dir = format!("{JAVA_HOME_DIR}/.m2"); + let m2_dir = format!("{}/.m2", *JAVA_HOME_DIR); if let Err(e) = tokio::fs::create_dir_all(&m2_dir).await { tracing::error!("Failed to create .m2 directory: {e:#}"); return; @@ -1378,7 +1379,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) { } } _ => { - let settings_path = format!("{JAVA_HOME_DIR}/.m2/settings.xml"); + let settings_path = format!("{}/.m2/settings.xml", *JAVA_HOME_DIR); let _ = tokio::fs::remove_file(&settings_path).await; } } diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs index 9717403830..082a43be2c 100644 --- a/backend/tests/nativets_stress.rs +++ b/backend/tests/nativets_stress.rs @@ -206,7 +206,7 @@ fn spawn_workers( std::fs::DirBuilder::new() .recursive(true) - .create(windmill_worker::GO_BIN_CACHE_DIR) + .create(&*windmill_worker::GO_BIN_CACHE_DIR) .expect("could not create initial worker dir"); let (tx, _) = KillpillSender::new(n + 1); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index dd494de007..22c2313b91 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1,7 +1,7 @@ -use windmill_test_utils::*; use sqlx::postgres::Postgres; use sqlx::Pool; use windmill_common::scripts::ScriptLang; +use windmill_test_utils::*; #[cfg(feature = "python")] #[sqlx::test(fixtures("base", "lockfile_python"))] @@ -188,7 +188,8 @@ def main(): path: None, language: ScriptLang::Python3, lock: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -207,14 +208,14 @@ def main(): #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_python_global_site_packages(db: Pool) -> anyhow::Result<()> { - use windmill_common::{cache::concatcp, worker::ROOT_CACHE_DIR}; + use windmill_common::worker::ROOT_CACHE_DIR; initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Shared for all 3.12.* - let path = concatcp!(ROOT_CACHE_DIR, "python_3_12/global-site-packages").to_owned(); + let path = format!("{}python_3_12/global-site-packages", *ROOT_CACHE_DIR); std::fs::create_dir_all(&path).unwrap(); std::fs::write(path + "/my_global_site_package_3_12_any.py", "").unwrap(); @@ -237,7 +238,9 @@ def main(): path: None, language: ScriptLang::Python3, lock: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -271,7 +274,9 @@ def main(): path: None, language: ScriptLang::Python3, lock: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -310,7 +315,8 @@ def main(): path: None, language: ScriptLang::Python3, lock: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -347,7 +353,8 @@ def main(): path: None, language: ScriptLang::Python3, lock: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 04a1eb617b..bf493f9420 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1085,7 +1085,7 @@ async fn sync_cached_resource_types( require_super_admin(&db, &authed.email).await?; use windmill_common::worker::HUB_RT_CACHE_DIR; - let cache_path = format!("{}/resource_types.json", HUB_RT_CACHE_DIR); + let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| { error::Error::NotFound(format!( diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 420bbf24b9..d58dcd3c69 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -47,7 +47,7 @@ use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAsset use windmill_common::scripts::ScriptRunnableSettingsInline; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::{RunnableKind, WarnAfterExt}; -use windmill_common::worker::{Connection, CLOUD_HOSTED, TMP_DIR}; +use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR}; use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES, }; @@ -1412,7 +1412,7 @@ async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{TMP_DIR}/{file_p}")) + if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) .await .is_ok() { @@ -1427,7 +1427,7 @@ async fn get_logs_from_disk( "#.to_string(), )); for file_p in file_index.clone() { - let mut file = tokio::fs::File::open(format!("{TMP_DIR}/{file_p}")).await.map_err(to_anyhow)?; + let mut file = tokio::fs::File::open(format!("{}/{file_p}", *WINDMILL_DIR)).await.map_err(to_anyhow)?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; yield Ok(bytes::Bytes::from(buffer)) as anyhow::Result; @@ -5888,7 +5888,7 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R )); } - let local_file = format!("{TMP_DIR}/logs/{file_p}"); + let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR); if tokio::fs::metadata(&local_file).await.is_ok() { let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?; let mut buffer = Vec::new(); @@ -5934,10 +5934,10 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R } #[cfg(not(all(feature = "enterprise", feature = "parquet")))] - return Err(error::Error::NotFound(format!( - "File not found on server logs volume /tmp/windmill/logs and no distributed logs s3 storage for {}", - file_p - ))); + return Err(error::Error::NotFound(format!( + "File not found on server logs volume {}/logs and no distributed logs s3 storage for {}", + *WINDMILL_DIR, file_p + ))); } async fn get_job_update( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 9b713cad0a..528e60b02f 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -262,7 +262,7 @@ pub async fn run_server( ) -> anyhow::Result<()> { let user_db = UserDB::new(db.clone()); - for x in [HUB_CACHE_DIR] { + for x in [&*HUB_CACHE_DIR] { DirBuilder::new() .recursive(true) .create(x) diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index 20d2b58d0e..c83bb21f2c 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -102,7 +102,11 @@ async fn get_log_file( #[cfg(feature = "parquet")] if let Some(s3_client) = s3_client { let path = format!("{}{}", windmill_common::tracing_init::LOGS_SERVICE, path); - let file = s3_client.get(&windmill_object_store::object_store_reexports::Path::from(path)).await; + let file = s3_client + .get(&windmill_object_store::object_store_reexports::Path::from( + path, + )) + .await; match file { Ok(file) => { let bytes = file.bytes().await; @@ -126,7 +130,7 @@ async fn get_log_file( } } } - let file = tokio::fs::read(format!("{}{}", TMP_WINDMILL_LOGS_SERVICE, path)).await; + let file = tokio::fs::read(format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path)).await; if let Ok(bytes) = file { Ok(content_plain(Body::from(bytes::Bytes::from(bytes)))) } else { diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index c881e9ae9b..091c465a61 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -43,6 +43,7 @@ use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings use windmill_common::scripts::ScriptRunnableSettingsHandle; use windmill_common::utils::require_admin; use windmill_common::variables::decrypt; +use windmill_common::worker::WINDMILL_DIR; use windmill_common::{ db::UserDB, error::{to_anyhow, Error, Result}, @@ -372,7 +373,7 @@ pub(crate) async fn tarball_workspace( let mut tx = user_db.begin(&authed).await?; - let tmp_dir = TempDir::new_in("/tmp/windmill/")?; + let tmp_dir = TempDir::new_in(&*WINDMILL_DIR)?; let name = match archive_type.as_deref() { Some("tar") | None => Ok(format!("windmill-{w_id}.tar")), diff --git a/backend/windmill-common/src/bench.rs b/backend/windmill-common/src/bench.rs index b87872119b..1ed2b9aa56 100644 --- a/backend/windmill-common/src/bench.rs +++ b/backend/windmill-common/src/bench.rs @@ -1,5 +1,5 @@ use crate::{ - worker::{write_file, TMP_DIR}, + worker::{write_file, WINDMILL_DIR}, DB, }; use serde::Serialize; @@ -113,7 +113,8 @@ impl BenchmarkInfo { "Writing benchmark {path}, duration of benchmark: {total_duration}ms and RPS: {}{pool_info}", self.iters as f64 / total_duration as f64 * 1000.0 ); - write_file(TMP_DIR, path, &serde_json::to_string(&self).unwrap()).expect("write profiling"); + write_file(&WINDMILL_DIR, path, &serde_json::to_string(&self).unwrap()) + .expect("write profiling"); Ok(()) } } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index bc40e4cd75..583914c573 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -18,7 +18,7 @@ use crate::{ scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, users::username_to_permissioned_as, utils::{StripPath, HTTP_CLIENT}, - worker::{to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, TMP_DIR}, + worker::{to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, FlowVersionInfo, ScriptHashInfo, Tag, }; @@ -225,7 +225,7 @@ pub async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{TMP_DIR}/{file_p}")) + if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) .await .is_ok() { @@ -236,7 +236,7 @@ pub async fn get_logs_from_disk( let logs = logs.to_string(); let stream = async_stream::stream! { for file_p in file_index.clone() { - let mut file = tokio::fs::File::open(format!("{TMP_DIR}/{file_p}")).await.map_err(to_anyhow)?; + let mut file = tokio::fs::File::open(format!("{}/{file_p}", *WINDMILL_DIR)).await.map_err(to_anyhow)?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; yield Ok(bytes::Bytes::from(buffer)) as anyhow::Result; diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 68f77a2303..cfff28dbcb 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -206,12 +206,12 @@ pub async fn get_full_hub_script_by_path( let version = path_iterator .next() .ok_or_else(|| Error::internal_err(format!("expected hub path to have version number")))?; - let cache_path = format!("{HUB_CACHE_DIR}/{version}"); + let cache_path = format!("{}/{version}", *HUB_CACHE_DIR); let script; if tokio::fs::metadata(&cache_path).await.is_err() { script = get_full_hub_script_by_path_inner(path, http_client, db).await?; if let Err(e) = crate::worker::write_file( - HUB_CACHE_DIR, + &HUB_CACHE_DIR, &version, &serde_json::to_string(&script).map_err(to_anyhow)?, ) { diff --git a/backend/windmill-common/src/tracing_init.rs b/backend/windmill-common/src/tracing_init.rs index 701a0887d0..f4892b67c9 100644 --- a/backend/windmill-common/src/tracing_init.rs +++ b/backend/windmill-common/src/tracing_init.rs @@ -6,8 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -use const_format::concatcp; - use std::{ collections::HashMap, sync::{Arc, RwLock}, @@ -61,7 +59,9 @@ fn create_targets_filter(default_env_filter: LevelFilter) -> Targets { pub const LOGS_SERVICE: &str = "logs/services/"; -pub const TMP_WINDMILL_LOGS_SERVICE: &str = concatcp!("/tmp/windmill/", LOGS_SERVICE); +lazy_static::lazy_static! { + pub static ref TMP_WINDMILL_LOGS_SERVICE: String = format!("{}/{}", *crate::worker::WINDMILL_DIR, LOGS_SERVICE); +} pub fn initialize_tracing( hostname: &str, @@ -108,7 +108,7 @@ pub fn initialize_tracing( use tracing_appender::rolling::{RollingFileAppender, Rotation}; - let log_dir = format!("{}/{}/", TMP_WINDMILL_LOGS_SERVICE, hostname); + let log_dir = format!("{}/{}/", *TMP_WINDMILL_LOGS_SERVICE, hostname); std::fs::create_dir_all(&log_dir).unwrap(); let file_appender = RollingFileAppender::builder() .rotation(Rotation::MINUTELY) diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 1a2d55b896..8d95541f61 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -2,7 +2,6 @@ use anyhow::anyhow; use axum::http::HeaderMap; use bytes::Bytes; -use const_format::concatcp; use itertools::Itertools; use regex::Regex; use reqwest_middleware::ClientWithMiddleware; @@ -274,7 +273,9 @@ lazy_static::lazy_static! { pub static ref ROOT_STANDALONE_BUNDLE_DIR: String = format!("{}/.windmill/standalone_bundle", std::env::var("HOME").unwrap_or_else(|_| "/root".to_string())); } -pub const ROOT_CACHE_NOMOUNT_DIR: &str = concatcp!(TMP_DIR, "/cache_nomount/"); +lazy_static::lazy_static! { + pub static ref ROOT_CACHE_NOMOUNT_DIR: String = format!("{}/cache_nomount/", *WINDMILL_DIR); +} /// Whether native mode is forced by the environment (NATIVE_MODE=true env var or WORKER_GROUP=native). /// This does NOT account for native_mode set in the DB worker group config — for that, read @@ -490,13 +491,23 @@ pub async fn store_pull_query(wc: &WorkerConfig) { *l = queries; } -pub const TMP_DIR: &str = "/tmp/windmill"; -pub const TMP_LOGS_DIR: &str = concatcp!(TMP_DIR, "/logs"); - -pub const HUB_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "hub"); -pub const HUB_RT_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "hub_rt"); - -pub const ROOT_CACHE_DIR: &str = concatcp!(TMP_DIR, "/cache/"); +lazy_static::lazy_static! { + pub static ref WINDMILL_DIR: String = { + let dir = std::env::var("WINDMILL_DIR") + .unwrap_or_else(|_| "/tmp/windmill".to_string()); + if dir.is_empty() { + panic!("WINDMILL_DIR must not be empty"); + } + if dir.ends_with('/') || dir.ends_with('\\') { + panic!("WINDMILL_DIR must not end with a trailing slash, got: {dir}"); + } + dir + }; + pub static ref TMP_LOGS_DIR: String = format!("{}/logs", *WINDMILL_DIR); + pub static ref ROOT_CACHE_DIR: String = format!("{}/cache/", *WINDMILL_DIR); + pub static ref HUB_CACHE_DIR: String = format!("{}hub", *ROOT_CACHE_DIR); + pub static ref HUB_RT_CACHE_DIR: String = format!("{}hub_rt", *ROOT_CACHE_DIR); +} pub fn write_file(dir: &str, path: &str, content: &str) -> error::Result { let path = format!("{}/{}", dir, path); diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index a523106cbc..e81925e7bb 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -45,7 +45,7 @@ use uuid::Uuid; use windmill_common::error::Error; use windmill_common::result_stream::append_result_stream_db; -use windmill_common::worker::{write_file, Connection, TMP_DIR}; +use windmill_common::worker::{write_file, Connection, WINDMILL_DIR}; // ── Permission container ───────────────────────────────────────────── @@ -151,7 +151,9 @@ static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/FETCH pub(crate) const WINDMILL_CLIENT: &str = include_str!("./windmill-client.js"); -const ERROR_DIR: &str = const_format::concatcp!(TMP_DIR, "/native_errors"); +lazy_static::lazy_static! { + static ref ERROR_DIR: String = format!("{}/native_errors", *WINDMILL_DIR); +} lazy_static! { static ref RE_PROXY: Regex = @@ -263,14 +265,14 @@ fn capture_proxy(s: &str) -> Option<(String, Option<(String, String)>)> { } fn write_error_expr(expr: &str, uuid: &Uuid) { - if let Err(e) = std::fs::create_dir_all(ERROR_DIR) { - tracing::error!("failed to create error dir {ERROR_DIR}: {e}"); + if let Err(e) = std::fs::create_dir_all(&*ERROR_DIR) { + tracing::error!("failed to create error dir {}: {e}", *ERROR_DIR); return; } - let dir_entries = match std::fs::read_dir(ERROR_DIR) { + let dir_entries = match std::fs::read_dir(&*ERROR_DIR) { Ok(entries) => entries.count(), Err(_) => { - tracing::error!("failed to read error dir {ERROR_DIR}"); + tracing::error!("failed to read error dir {}", *ERROR_DIR); return; } }; @@ -279,15 +281,16 @@ fn write_error_expr(expr: &str, uuid: &Uuid) { tracing::info!("native error for job {uuid}: {expr}"); } if dir_entries >= 100 { - tracing::info!("Too many error files in {ERROR_DIR}, skipping write"); + tracing::info!("Too many error files in {}, skipping write", *ERROR_DIR); return; } let path = format!("/{uuid}.js"); tracing::info!( - "nativets job {uuid} failed, writing error expr to {ERROR_DIR}/{path} for debugging: {path}" + "nativets job {uuid} failed, writing error expr to {}/{path} for debugging: {path}", + *ERROR_DIR ); - if let Err(e) = write_file(ERROR_DIR, &path, expr) { + if let Err(e) = write_file(&ERROR_DIR, &path, expr) { tracing::error!("failed to write error expr to file {path}: {e}"); } } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index bcac53e7d2..6b414ca10f 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -47,7 +47,7 @@ use windmill_common::{ StripPath, }, variables, - worker::{CLOUD_HOSTED, TMP_DIR}, + worker::{CLOUD_HOSTED, WINDMILL_DIR}, PgDatabase, }; @@ -1752,7 +1752,7 @@ async fn write_ssh_file( var_path: &str, ) -> std::result::Result { let id_file_name = format!(".ssh_id_priv_{}", Uuid::new_v4()); - let loc = std::path::Path::new(TMP_DIR) + let loc = std::path::Path::new(&*WINDMILL_DIR) .join("ssh_ids") .join(id_file_name); diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index adbeb83ee7..69e7fc0558 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -380,7 +380,7 @@ pub fn spawn_test_worker( std::fs::DirBuilder::new() .recursive(true) - .create(windmill_worker::GO_BIN_CACHE_DIR) + .create(&*windmill_worker::GO_BIN_CACHE_DIR) .expect("could not create initial worker dir"); let (tx, rx) = KillpillSender::new(1); diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index 87b6abda21..afe9d5df9f 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -127,7 +127,7 @@ iface_no_lo: true mount { src: "{CACHE_DIR}" - dst: "/tmp/windmill/cache/powershell" + dst: "{CACHE_DIR}" is_bind: true rw: false mandatory: false diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index aa250de4ab..253384f6f2 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -33,9 +33,9 @@ use crate::{ read_and_check_result, start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, + is_sandboxing_enabled, python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile}, - is_sandboxing_enabled, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, - PY_INSTALL_DIR, TZ_ENV, + DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, }; use windmill_common::client::AuthedClient; @@ -1184,7 +1184,7 @@ mount {{ job_dir, "run.config.proto", &NSJAIL_CONFIG_RUN_ANSIBLE_CONTENT - .replace("{PY_INSTALL_DIR}", PY_INSTALL_DIR) + .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 64270b4044..30348895cf 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -194,7 +194,7 @@ exit $exit_status .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), )?; let mut cmd_args = vec![ diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 9c46db572d..08a61645ce 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -737,7 +737,7 @@ async fn pull_codebase(w_id: &str, id: &str, job_dir: &str) -> Result Error { pub async fn clean_cache() -> error::Result<()> { tracing::info!("Started cleaning cache"); - tokio::fs::remove_dir_all(ROOT_CACHE_DIR).await?; + tokio::fs::remove_dir_all(&*ROOT_CACHE_DIR).await?; tracing::info!("Finished cleaning cache"); Ok(()) } @@ -1557,4 +1557,3 @@ mod tests { assert!(result.is_err()); } } - diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index e4c32648d0..4c3e63f4c9 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -13,14 +13,11 @@ use itertools::Itertools; #[cfg(feature = "csharp")] use tokio::{fs::File, io::AsyncReadExt, process::Command}; #[cfg(feature = "csharp")] -use windmill_common::{ - utils::calculate_hash, - worker::write_file, -}; +use windmill_common::{utils::calculate_hash, worker::write_file}; -use windmill_common::error::{self, Error}; #[cfg(feature = "csharp")] use crate::global_cache::save_cache; +use windmill_common::error::{self, Error}; #[cfg(feature = "csharp")] use windmill_queue::append_logs; @@ -105,8 +102,8 @@ pub async fn generate_nuget_lockfile( let mut gen_lockfile_cmd = Command::new(DOTNET_PATH.as_str()); gen_lockfile_cmd .current_dir(job_dir) - .env("DOTNET_CLI_HOME", CSHARP_CACHE_DIR) - .env("NUGET_PACKAGES", format!("{CSHARP_CACHE_DIR}/nuget")) + .env("DOTNET_CLI_HOME", &*CSHARP_CACHE_DIR) + .env("NUGET_PACKAGES", format!("{}/nuget", *CSHARP_CACHE_DIR)) .env("DOTNET_CLI_TELEMETRY_OPTOUT", "true") .env("DOTNET_NOLOGO", "true") .env("MSBUILDDISABLENODEREUSE", "1") @@ -367,8 +364,8 @@ async fn build_cs_proj( .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("HOME", HOME_ENV.as_str()) - .env("DOTNET_CLI_HOME", CSHARP_CACHE_DIR) - .env("NUGET_PACKAGES", format!("{CSHARP_CACHE_DIR}/nuget")) + .env("DOTNET_CLI_HOME", &*CSHARP_CACHE_DIR) + .env("NUGET_PACKAGES", format!("{}/nuget", *CSHARP_CACHE_DIR)) .env("DOTNET_CLI_TELEMETRY_OPTOUT", "true") .env("DOTNET_NOLOGO", "true") .env("MSBUILDDISABLENODEREUSE", "1") @@ -434,7 +431,7 @@ async fn build_cs_proj( } } - let bin_path = format!("{}/{hash}", CSHARP_CACHE_DIR); + let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR); #[cfg(unix)] let target = format!("{job_dir}/Main"); #[cfg(windows)] @@ -516,11 +513,10 @@ pub async fn handle_csharp_job( inner_content, requirements_o.unwrap_or(&String::new()) )); - let bin_path = format!("{}/{hash}", CSHARP_CACHE_DIR); + let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR); let remote_path = format!("{CSHARP_OBJECT_STORE_PREFIX}{hash}"); - let (cache, cache_logs) = - crate::global_cache::load_cache(&bin_path, &remote_path, false).await; + let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await; let cache_logs = if cache { #[cfg(unix)] @@ -591,11 +587,11 @@ pub async fn handle_csharp_job( "run.config.proto", &NSJAIL_CONFIG_RUN_CSHARP_CONTENT .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", CSHARP_CACHE_DIR) + .replace("{CACHE_DIR}", &*CSHARP_CACHE_DIR) .replace("{CACHE_HASH}", &hash) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); @@ -608,8 +604,8 @@ pub async fn handle_csharp_job( .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) - .env("DOTNET_CLI_HOME", CSHARP_CACHE_DIR) - .env("NUGET_PACKAGES", format!("{CSHARP_CACHE_DIR}/nuget")) + .env("DOTNET_CLI_HOME", &*CSHARP_CACHE_DIR) + .env("NUGET_PACKAGES", format!("{}/nuget", *CSHARP_CACHE_DIR)) .env("DOTNET_CLI_TELEMETRY_OPTOUT", "true") .env("DOTNET_NOLOGO", "true") .env("DOTNET_ROOT", DOTNET_ROOT.as_str()) @@ -640,8 +636,8 @@ pub async fn handle_csharp_job( .envs(get_proxy_envs_for_lang(&ScriptLang::CSharp).await?) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) - .env("DOTNET_CLI_HOME", CSHARP_CACHE_DIR) - .env("NUGET_PACKAGES", format!("{CSHARP_CACHE_DIR}/nuget")) + .env("DOTNET_CLI_HOME", &*CSHARP_CACHE_DIR) + .env("NUGET_PACKAGES", format!("{}/nuget", *CSHARP_CACHE_DIR)) .env("DOTNET_CLI_TELEMETRY_OPTOUT", "true") .env("DOTNET_NOLOGO", "true") .env("DOTNET_ROOT", DOTNET_ROOT.as_str()) diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 66d8ae8673..19831eadd0 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -443,7 +443,8 @@ try {{ } let allow_read = format!( - "--allow-read=./,/tmp/windmill/cache/deno/,{}", + "--allow-read=./,{}/,{}", + *DENO_CACHE_DIR, DENO_PATH.as_str() ); if let Some(deno_flags) = DENO_FLAGS.as_ref() { @@ -504,7 +505,8 @@ try {{ *has_stream = handle_result.result_stream.is_some(); // logs.push_str(format!("execute: {:?}\n", start.elapsed().as_millis()).as_str()); - if let Err(e) = tokio::fs::remove_dir_all(format!("{DENO_CACHE_DIR}/gen/file/{job_dir}")).await + if let Err(e) = + tokio::fs::remove_dir_all(format!("{}/gen/file/{job_dir}", *DENO_CACHE_DIR)).await { tracing::error!("failed to remove deno gen tmp cache dir: {}", e); } diff --git a/backend/windmill-worker/src/global_cache.rs b/backend/windmill-worker/src/global_cache.rs index 812be2fc7a..ea5a9ded46 100644 --- a/backend/windmill-worker/src/global_cache.rs +++ b/backend/windmill-worker/src/global_cache.rs @@ -18,8 +18,8 @@ pub async fn build_tar_and_push( custom_folder_name: Option, platform_agnostic: bool, ) -> error::Result<()> { - use windmill_object_store::object_store_reexports::Path; use tokio::fs::create_dir_all; + use windmill_object_store::object_store_reexports::Path; use crate::TAR_PYBASE_CACHE_DIR; @@ -33,7 +33,7 @@ pub async fn build_tar_and_push( folder.split("/").last().unwrap().to_owned() }; - let prefix = &format!("{TAR_PYBASE_CACHE_DIR}/{}", lang); + let prefix = &format!("{}/{}", *TAR_PYBASE_CACHE_DIR, lang); let tar_path = format!("{prefix}/{folder_name}_tar.tar"); create_dir_all(prefix).await?; @@ -197,7 +197,9 @@ pub async fn exists_in_cache(bin_path: &str, _remote_path: &str) -> bool { #[cfg(all(feature = "enterprise", feature = "parquet"))] if let Some(os) = windmill_object_store::get_object_store().await { return os - .get(&windmill_object_store::object_store_reexports::Path::from(_remote_path)) + .get(&windmill_object_store::object_store_reexports::Path::from( + _remote_path, + )) .await .is_ok(); } @@ -221,7 +223,7 @@ pub async fn save_cache( let file_to_cache = if is_dir { let tar_path = format!( "{}/tar/{}_tar.tar", - windmill_common::worker::ROOT_CACHE_DIR, + *windmill_common::worker::ROOT_CACHE_DIR, local_cache_path .split("/") .last() diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 35307baa72..29fff1a2ba 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -2,6 +2,7 @@ use crate::{common::MaybeLock, get_proxy_envs_for_lang}; use std::{collections::HashMap, fs::DirBuilder, process::Stdio}; use windmill_common::scripts::ScriptLang; +use crate::global_cache::save_cache; use itertools::Itertools; use serde_json::value::RawValue; use tokio::{ @@ -15,7 +16,6 @@ use windmill_common::{ utils::calculate_hash, worker::{write_file, Connection, GoAnnotations}, }; -use crate::global_cache::save_cache; use windmill_parser_go::{parse_go_imports, REQUIRE_PARSE}; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; @@ -108,10 +108,9 @@ pub async fn handle_go_job( .expect("could not create go job dir"); let hash = calculate_hash(&format!("{}{:?}v2", inner_content, &maybe_lock)); - let bin_path = format!("{}/{hash}", GO_BIN_CACHE_DIR); + let bin_path = format!("{}/{hash}", *GO_BIN_CACHE_DIR); let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}"); - let (cache, cache_logs) = - crate::global_cache::load_cache(&bin_path, &remote_path, false).await; + let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await; let (skip_go_mod, skip_tidy) = if cache { (true, true) @@ -238,15 +237,15 @@ func Run(req Req) (interface{{}}, error){{ .env("GOPATH", { #[cfg(unix)] { - GO_CACHE_DIR + GO_CACHE_DIR.as_str() } #[cfg(windows)] { - windows_gopath() + &windows_gopath() } }) .env("HOME", HOME_ENV.as_str()) - .env("GOCACHE", GO_CACHE_DIR) + .env("GOCACHE", GO_CACHE_DIR.as_str()) .envs(PROXY_ENVS.clone()) .args(vec!["build", "main.go"]) .stdout(Stdio::piped()) @@ -347,7 +346,7 @@ func Run(req Req) (interface{{}}, error){{ .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); @@ -384,11 +383,11 @@ func Run(req Req) (interface{{}}, error){{ .env("GOPATH", { #[cfg(unix)] { - GO_CACHE_DIR + GO_CACHE_DIR.as_str() } #[cfg(windows)] { - windows_gopath() + &windows_gopath() } }) .env("HOME", HOME_ENV.as_str()); @@ -508,7 +507,7 @@ pub async fn install_go_dependencies( #[cfg(windows)] child_cmd.env("GOPATH", windows_gopath()); #[cfg(unix)] - child_cmd.env("GOPATH", GO_CACHE_DIR); + child_cmd.env("GOPATH", GO_CACHE_DIR.as_str()); #[cfg(windows)] set_windows_env_vars(&mut child_cmd); @@ -591,11 +590,11 @@ pub async fn install_go_dependencies( .env("GOPATH", { #[cfg(unix)] { - GO_CACHE_DIR + GO_CACHE_DIR.as_str() } #[cfg(windows)] { - windows_gopath() + &windows_gopath() } }) .args(vec!["mod", mod_command]) diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index 49129848ce..efbe403145 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, path::PathBuf, process::Stdio}; +use crate::global_cache::save_cache; use anyhow::{anyhow, bail}; use async_recursion::async_recursion; use itertools::Itertools; @@ -15,7 +16,6 @@ use windmill_common::{ utils::calculate_hash, worker::{copy_dir_recursively, write_file, Connection}, }; -use crate::global_cache::save_cache; use windmill_parser::Arg; use windmill_parser_java::parse_java_sig_meta; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; @@ -185,8 +185,8 @@ pub async fn resolve<'a>( cmd.env_clear() .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) - .env("HOME", JAVA_HOME_DIR) - .env("COURSIER_CACHE", COURSIER_CACHE_DIR) + .env("HOME", &*JAVA_HOME_DIR) + .env("COURSIER_CACHE", &*COURSIER_CACHE_DIR) .envs(PROXY_ENVS.clone()); // Configure proxies @@ -208,7 +208,7 @@ pub async fn resolve<'a>( cmd.arg(&format!("-Dhttp.nonProxyHosts=\"{}\"", val)); } } - cmd.arg(&format!("-Duser.home={}", JAVA_HOME_DIR)); + cmd.arg(&format!("-Duser.home={}", *JAVA_HOME_DIR)); if metadata(TRUST_STORE_PATH.clone()).await.is_ok() { cmd.args(&[ &format!("-Djavax.net.ssl.trustStore={}", *TRUST_STORE_PATH), @@ -223,7 +223,7 @@ pub async fn resolve<'a>( "--parallel", &format!("{}", *JAVA_CONCURRENT_DOWNLOADS), "--cache", - COURSIER_CACHE_DIR, + &*COURSIER_CACHE_DIR, ]) .args(&get_repos(job_id, w_id, conn).await) .args(&deps.split("\n").collect_vec()) @@ -276,7 +276,8 @@ async fn install<'a>( match (it.next(), it.next(), it.next()) { (Some(group_id), Some(artifact_id), Some(version)) => { let path = format!( - "{JAVA_REPOSITORY_DIR}/{}/{artifact_id}/{version}", + "{}/{}/{artifact_id}/{version}", + *JAVA_REPOSITORY_DIR, group_id.replace(".", "/") ); Ok(RequiredDependency { @@ -312,7 +313,7 @@ async fn install<'a>( metadata(TRUST_STORE_PATH.clone()).await, ); let job_dir = job_dir.to_owned(); - let fetch_dir = format!("{JAVA_CACHE_DIR}/tmp-fetch-{}", Uuid::new_v4()); + let fetch_dir = format!("{}/tmp-fetch-{}", *JAVA_CACHE_DIR, Uuid::new_v4()); let fetch_dir2 = fetch_dir.clone(); par_install_language_dependencies_all_at_once( deps, @@ -334,8 +335,8 @@ async fn install<'a>( cmd.env_clear() .current_dir(&job_dir) .env("PATH", PATH_ENV.as_str()) - .env("HOME", JAVA_HOME_DIR) - .env("COURSIER_CACHE", COURSIER_CACHE_DIR) + .env("HOME", &*JAVA_HOME_DIR) + .env("COURSIER_CACHE", &*COURSIER_CACHE_DIR) .envs(PROXY_ENVS.clone()); // Configure proxies { @@ -357,7 +358,7 @@ async fn install<'a>( } } - cmd.arg(&format!("-Duser.home={}", JAVA_HOME_DIR)); + cmd.arg(&format!("-Duser.home={}", *JAVA_HOME_DIR)); if trust_store_metadata.is_ok() { cmd.args(&[ &format!("-Djavax.net.ssl.trustStore={}", *TRUST_STORE_PATH), @@ -400,7 +401,7 @@ async fn install<'a>( if depth == 3 { copy_dir_recursively( &PathBuf::from(path), - &PathBuf::from(JAVA_REPOSITORY_DIR), + &PathBuf::from(&*JAVA_REPOSITORY_DIR), )?; return Ok(()); @@ -465,7 +466,7 @@ async fn compile<'a>( let reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?; let hash = compute_hash(inner_content, *requirements_o); - let bin_path = format!("{}/{hash}", JAVA_CACHE_DIR); + let bin_path = format!("{}/{hash}", *JAVA_CACHE_DIR); let remote_path = format!("java_jar/{hash}"); let (cache, ..) = crate::global_cache::load_cache(&bin_path, &remote_path, true).await; @@ -501,7 +502,7 @@ async fn compile<'a>( cmd.env_clear() .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) - .env("HOME", JAVA_HOME_DIR) + .env("HOME", &*JAVA_HOME_DIR) .env("BASE_INTERNAL_URL", base_internal_url) .envs(envs) .envs(reserved_variables) @@ -604,7 +605,7 @@ async fn run<'a>( "run.config.proto", &NSJAIL_CONFIG_RUN_JAVA_CONTENT .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", JAVA_CACHE_DIR) + .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) .replace("{SHARED_MOUNT}", &shared_mount) // .replace("{CACHED_TARGET}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), @@ -613,7 +614,7 @@ async fn run<'a>( cmd.env_clear() .current_dir(job_dir) .env("PATH", PATH_ENV.as_str()) - .env("HOME", JAVA_HOME_DIR) + .env("HOME", &*JAVA_HOME_DIR) .env("BASE_INTERNAL_URL", base_internal_url) .envs(envs) .envs(reserved_variables) @@ -671,7 +672,7 @@ async fn run<'a>( cmd.env_clear() .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) - .env("HOME", JAVA_HOME_DIR) + .env("HOME", &*JAVA_HOME_DIR) .env("BASE_INTERNAL_URL", base_internal_url) .envs(envs) .envs(reserved_variables); diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index a4320a3612..ca309105b5 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -16,8 +16,8 @@ use crate::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_result, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, - TRACING_PROXY_CA_CERT_PATH, + get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, + PATH_ENV, TRACING_PROXY_CA_CERT_PATH, }; use windmill_common::client::AuthedClient; use windmill_common::scripts::ScriptLang; @@ -253,7 +253,7 @@ async fn run<'a>( .replace("{NU_PATH}", &NU_PATH) .replace("{SHARED_MOUNT}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index acae81a588..961a49a58a 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -159,7 +159,7 @@ try { async fn scan_module_directories() -> Result, Error> { let mut module_dirs = HashMap::new(); - let cache_dir = std::path::Path::new(POWERSHELL_CACHE_DIR); + let cache_dir = std::path::Path::new(&*POWERSHELL_CACHE_DIR); if let Ok(entries) = fs::read_dir(cache_dir) { for entry in entries { @@ -391,7 +391,7 @@ pub async fn handle_powershell_job( .join(", "); let install_string = generate_powershell_install_code() - .replace("{path}", POWERSHELL_CACHE_DIR) + .replace("{path}", &*POWERSHELL_CACHE_DIR) .replace("{job_id}", &job.id.to_string()) .replace("{has_private_repo}", &format!("${has_private_repo}")) .replace("{has_credentials}", &format!("${has_credentials}")) @@ -442,7 +442,7 @@ $PSModulePathBackup = $env:PSModulePath $env:PSModulePath = \"$PSHome/Modules\" Get-Module -ListAvailable | Import-Module $env:PSModulePath = \"{}:$PSModulePathBackup\"", - POWERSHELL_CACHE_DIR + *POWERSHELL_CACHE_DIR ); #[cfg(windows)] @@ -452,7 +452,7 @@ $PSModulePathBackup = $env:PSModulePath $env:PSModulePath = \"C:\\Program Files\\PowerShell\\7\\Modules\" Get-Module -ListAvailable | Import-Module $env:PSModulePath = \"{};$PSModulePathBackup\"", - POWERSHELL_CACHE_DIR + *POWERSHELL_CACHE_DIR ); // NOTE: powershell error handling / termination is quite tricky compared to bash @@ -525,7 +525,7 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) - .replace("{CACHE_DIR}", POWERSHELL_CACHE_DIR), + .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR), )?; let cmd_args = vec![ "--config", diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 047521a0b4..b289f05380 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -278,7 +278,7 @@ pub async fn uv_pip_compile( "requirements.txt", // Target to /tmp/windmill/cache/uv "--cache-dir", - UV_CACHE_DIR, + &*UV_CACHE_DIR, ]; args.extend(["-p", &py_version_str, "--python-preference", "only-managed"]); @@ -805,7 +805,7 @@ mount {{ "run.config.proto", &NSJAIL_CONFIG_RUN_PYTHON3_CONTENT .replace("{JOB_DIR}", job_dir) - .replace("{PY_INSTALL_DIR}", PY_INSTALL_DIR) + .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace("{SHARED_DEPENDENCIES}", shared_deps.as_str()) @@ -815,7 +815,7 @@ mount {{ "{ADDITIONAL_PYTHON_PATHS}", additional_python_paths_folders.as_str(), ) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), )?; } else { @@ -1410,10 +1410,10 @@ async fn spawn_uv_install( &nsjail_proto, NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT .replace("{WORKER_DIR}", worker_dir) - .replace("{PY_INSTALL_DIR}", &PY_INSTALL_DIR) + .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{TARGET_DIR}", &venv_p) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .as_str(), )?; diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 52636808a6..78b03deb07 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -18,6 +18,8 @@ use windmill_common::{ use anyhow::{anyhow, bail}; use windmill_queue::append_logs; +#[cfg(unix)] +use crate::python_executor::UV_PATH; use crate::{ common::{start_child_process, OccupancyMetrics}, handle_child::handle_child, @@ -25,8 +27,6 @@ use crate::{ HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, UV_CACHE_DIR, WIN_ENVS, }; -#[cfg(unix)] -use crate::python_executor::UV_PATH; impl From for PyVAlias { fn from(value: PyV) -> Self { @@ -234,7 +234,8 @@ impl PyV { pub(crate) fn to_cache_dir(&self, ignore_patch: bool) -> String { use windmill_common::worker::ROOT_CACHE_DIR; format!( - "{ROOT_CACHE_DIR}{}", + "{}{}", + *ROOT_CACHE_DIR, self.to_cache_dir_top_level(ignore_patch) ) } @@ -311,7 +312,7 @@ impl PyV { Command::new(uv_cmd) .env_clear() .envs(WIN_ENVS.to_vec()) - .env("UV_CACHE_DIR", UV_CACHE_DIR) + .env("UV_CACHE_DIR", &*UV_CACHE_DIR) .args([ "python", "list", @@ -539,8 +540,8 @@ impl PyV { ]) // TODO: Do we need these? .envs([ - ("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR), - ("UV_CACHE_DIR", UV_CACHE_DIR), + ("UV_PYTHON_INSTALL_DIR", &*PY_INSTALL_DIR), + ("UV_CACHE_DIR", &*UV_CACHE_DIR), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -630,11 +631,9 @@ impl PyV { "--system", "--python-preference=only-managed", ]) - .envs([ - ("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR), - ("UV_PYTHON_PREFERENCE", "only-managed"), - ("UV_CACHE_DIR", UV_CACHE_DIR), - ]) + .env("UV_PYTHON_INSTALL_DIR", &*PY_INSTALL_DIR) + .env("UV_PYTHON_PREFERENCE", "only-managed") + .env("UV_CACHE_DIR", &*UV_CACHE_DIR) // .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index 869b58d7de..11031fa481 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -1,7 +1,6 @@ use std::{collections::HashMap, process::Stdio}; use anyhow::anyhow; -use const_format::concatcp; use itertools::Itertools; use regex::Regex; use tokio::{ @@ -122,7 +121,7 @@ pub async fn prepare<'a>( .write_all(&wrap(inner_content)?.into_bytes()) .await?; - let mini_wm_path = format!("{RUBY_CACHE_DIR}/gems/windmill-internal/windmill"); + let mini_wm_path = format!("{}/gems/windmill-internal/windmill", *RUBY_CACHE_DIR); if !std::fs::metadata(&mini_wm_path).is_ok() { fs::create_dir_all(&mini_wm_path).await?; @@ -339,7 +338,7 @@ Your Gemfile syntax will continue to work as-is." &NSJAIL_CONFIG_LOCK_RUBY_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); @@ -588,7 +587,7 @@ async fn install<'a>( // 123...zx-activesupport-8.0.2 // ^^^^^^^^ hash based on source and type (GEM or GIT) let handle = format!("{}-{}-{}", hash, pkg, version); - let path = format!("{RUBY_CACHE_DIR}/gems/{}", &handle); + let path = format!("{}/gems/{}", *RUBY_CACHE_DIR, &handle); deps.push(RequiredDependency { path, @@ -632,7 +631,7 @@ async fn install<'a>( &NSJAIL_CONFIG_DOWNLOAD_RUBY_CONTENT .replace("{TARGET}", &dependency.path) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); @@ -741,9 +740,9 @@ async fn install<'a>( }; // Include builtin windmill client { - const WM_INTERNAL: &str = concatcp!(RUBY_CACHE_DIR, "/gems/windmill-internal"); - res.top_level_paths.push(WM_INTERNAL.to_owned()); - res.rubylib += format!(":{WM_INTERNAL}").as_str(); + let wm_internal = format!("{}/gems/windmill-internal", *RUBY_CACHE_DIR); + res.top_level_paths.push(wm_internal.clone()); + res.rubylib += format!(":{wm_internal}").as_str(); } Ok(res) } @@ -800,7 +799,7 @@ mount {{ .replace("{JOB_DIR}", job_dir) .replace("{SHARED_MOUNT}", &shared_mount) .replace("{SHARED_DEPENDENCIES}", &shared_deps) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), )?; diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 54d90550b0..216c59f2cd 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -5,6 +5,7 @@ use std::{collections::HashMap, process::Stdio}; use uuid::Uuid; use windmill_parser_rust::parse_rust_deps_into_manifest; +use crate::global_cache::save_cache; use itertools::Itertools; use tokio::{ fs::{create_dir_all, File}, @@ -16,7 +17,6 @@ use windmill_common::{ utils::calculate_hash, worker::{write_file, Connection}, }; -use crate::global_cache::save_cache; use windmill_queue::MiniPulledJob; use windmill_queue::{append_logs, CanceledBy}; @@ -337,7 +337,7 @@ async fn get_build_dir( if !is_sandboxing_enabled() { // If nsjail is disabled then entire worker has shared build directory // It drastically improves cache hit-rate. - Some((format!("{RUST_CACHE_DIR}/build/{worker_name}"), true)) + Some((format!("{}/build/{worker_name}", *RUST_CACHE_DIR), true)) } else { // If nsjail is enabled, having global shared directory is vulnerability and target for an attack // Instead we either: @@ -345,7 +345,8 @@ async fn get_build_dir( // 2. If user is not known or something else goes wrong - use random build dir. This is equivalent to no cache at all. Some(( format!( - "{RUST_CACHE_DIR}/build/{}@{}@{}", + "{}/build/{}@{}@{}", + *RUST_CACHE_DIR, &job.workspace_id, p.replace('/', "."), &job.created_by @@ -355,7 +356,10 @@ async fn get_build_dir( } } }) - .unwrap_or((format!("{RUST_CACHE_DIR}/build/{}", Uuid::new_v4()), false)); + .unwrap_or(( + format!("{}/build/{}", *RUST_CACHE_DIR, Uuid::new_v4()), + false, + )); { let (t, r, g) = ( @@ -449,7 +453,7 @@ pub async fn build_rust_crate( is_preview: bool, ) -> error::Result { ensure_rust_runtime_dirs(); - let bin_path = format!("{}/{hash}", RUST_CACHE_DIR); + let bin_path = format!("{}/{hash}", *RUST_CACHE_DIR); let build_dir = get_build_dir(job, job_dir, conn, worker_name, is_preview).await?; @@ -459,9 +463,9 @@ pub async fn build_rust_crate( "download.config.proto", &NSJAIL_CONFIG_COMPILE_RUST_CONTENT .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", RUST_CACHE_DIR) + .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) .replace("{CARGO_HOME}", CARGO_HOME.as_str()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{BUILD}", &build_dir), )?; @@ -605,14 +609,13 @@ pub async fn handle_rust_job( check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?; let hash = compute_rust_hash(inner_content, requirements_o); - let bin_path = format!("{}/{hash}", RUST_CACHE_DIR); + let bin_path = format!("{}/{hash}", *RUST_CACHE_DIR); let remote_path = format!("{RUST_OBJECT_STORE_PREFIX}{hash}"); let reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; - let (cache, cache_logs) = - crate::global_cache::load_cache(&bin_path, &remote_path, false).await; + let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await; let cache_logs = if cache { let target = format!("{job_dir}/main"); @@ -669,10 +672,10 @@ pub async fn handle_rust_job( "run.config.proto", &NSJAIL_CONFIG_RUN_RUST_CONTENT .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", RUST_CACHE_DIR) + .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) .replace("{CACHE_HASH}", &hash) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{SHARED_MOUNT}", shared_mount), )?; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index c354d34ee2..49a1048afd 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -37,7 +37,7 @@ use windmill_common::{ utils::{create_directory_async, WarnAfterExt}, worker::{ make_pull_query, write_file, Connection, HttpClient, MAX_TIMEOUT, - MIN_PERIODIC_SCRIPT_INTERVAL_SECONDS, ROOT_CACHE_DIR, ROOT_CACHE_NOMOUNT_DIR, TMP_DIR, + MIN_PERIODIC_SCRIPT_INTERVAL_SECONDS, ROOT_CACHE_DIR, ROOT_CACHE_NOMOUNT_DIR, WINDMILL_DIR, }, worker_group_job_stats::JobStatsMap, KillpillSender, @@ -47,7 +47,6 @@ use windmill_common::{ use windmill_common::ee_oss::LICENSE_KEY_VALID; use anyhow::Result; -use const_format::concatcp; #[cfg(feature = "prometheus")] use prometheus::IntCounter; @@ -196,45 +195,47 @@ use windmill_common::bench::{benchmark_init, benchmark_verify, BenchmarkInfo, Be use windmill_common::add_time; -pub const PY310_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_3_10"); -pub const PY311_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_3_11"); -pub const PY312_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_3_12"); -pub const PY313_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_3_13"); +lazy_static::lazy_static! { + pub static ref PY310_CACHE_DIR: String = format!("{}python_3_10", *ROOT_CACHE_DIR); + pub static ref PY311_CACHE_DIR: String = format!("{}python_3_11", *ROOT_CACHE_DIR); + pub static ref PY312_CACHE_DIR: String = format!("{}python_3_12", *ROOT_CACHE_DIR); + pub static ref PY313_CACHE_DIR: String = format!("{}python_3_13", *ROOT_CACHE_DIR); -pub const TAR_JAVA_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/java"); + pub static ref TAR_JAVA_CACHE_DIR: String = format!("{}tar/java", *ROOT_CACHE_DIR); -pub const UV_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "uv"); -pub const PY_INSTALL_DIR: &str = concatcp!(ROOT_CACHE_DIR, "py_runtime"); -pub const TAR_PYBASE_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar"); -pub const DENO_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "deno"); -pub const DENO_CACHE_DIR_DEPS: &str = concatcp!(ROOT_CACHE_DIR, "deno/deps"); -pub const DENO_CACHE_DIR_NPM: &str = concatcp!(ROOT_CACHE_DIR, "deno/npm"); + pub static ref UV_CACHE_DIR: String = format!("{}uv", *ROOT_CACHE_DIR); + pub static ref PY_INSTALL_DIR: String = format!("{}py_runtime", *ROOT_CACHE_DIR); + pub static ref TAR_PYBASE_CACHE_DIR: String = format!("{}tar", *ROOT_CACHE_DIR); + pub static ref DENO_CACHE_DIR: String = format!("{}deno", *ROOT_CACHE_DIR); + pub static ref DENO_CACHE_DIR_DEPS: String = format!("{}deno/deps", *ROOT_CACHE_DIR); + pub static ref DENO_CACHE_DIR_NPM: String = format!("{}deno/npm", *ROOT_CACHE_DIR); -pub const GO_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "go"); -pub const RUST_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "rust"); -pub const NU_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "nu"); -pub const CSHARP_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "csharp"); + pub static ref GO_CACHE_DIR: String = format!("{}go", *ROOT_CACHE_DIR); + pub static ref RUST_CACHE_DIR: String = format!("{}rust", *ROOT_CACHE_DIR); + pub static ref NU_CACHE_DIR: String = format!("{}nu", *ROOT_CACHE_DIR); + pub static ref CSHARP_CACHE_DIR: String = format!("{}csharp", *ROOT_CACHE_DIR); -// Java -pub const JAVA_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "java"); -pub const COURSIER_CACHE_DIR: &str = concatcp!(JAVA_CACHE_DIR, "/coursier-cache"); -pub const JAVA_REPOSITORY_DIR: &str = concatcp!(JAVA_CACHE_DIR, "/repository"); -pub const JAVA_HOME_DIR: &str = concatcp!(JAVA_CACHE_DIR, "/home"); + // Java + pub static ref JAVA_CACHE_DIR: String = format!("{}java", *ROOT_CACHE_DIR); + pub static ref COURSIER_CACHE_DIR: String = format!("{}/coursier-cache", *JAVA_CACHE_DIR); + pub static ref JAVA_REPOSITORY_DIR: String = format!("{}/repository", *JAVA_CACHE_DIR); + pub static ref JAVA_HOME_DIR: String = format!("{}/home", *JAVA_CACHE_DIR); -// Ruby -pub const RUBY_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "ruby"); + // Ruby + pub static ref RUBY_CACHE_DIR: String = format!("{}ruby", *ROOT_CACHE_DIR); -// for related places search: ADD_NEW_LANG -pub const BUN_CACHE_DIR: &str = concatcp!(ROOT_CACHE_NOMOUNT_DIR, "bun"); -pub const BUN_BUNDLE_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "bun"); -pub const BUN_CODEBASE_BUNDLE_CACHE_DIR: &str = concatcp!(ROOT_CACHE_NOMOUNT_DIR, "script_bundle"); + // for related places search: ADD_NEW_LANG + pub static ref BUN_CACHE_DIR: String = format!("{}bun", *ROOT_CACHE_NOMOUNT_DIR); + pub static ref BUN_BUNDLE_CACHE_DIR: String = format!("{}bun", *ROOT_CACHE_DIR); + pub static ref BUN_CODEBASE_BUNDLE_CACHE_DIR: String = format!("{}script_bundle", *ROOT_CACHE_NOMOUNT_DIR); -pub const GO_BIN_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "gobin"); -pub const POWERSHELL_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "powershell"); -pub const COMPOSER_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "composer"); + pub static ref GO_BIN_CACHE_DIR: String = format!("{}gobin", *ROOT_CACHE_DIR); + pub static ref POWERSHELL_CACHE_DIR: String = format!("{}powershell", *ROOT_CACHE_DIR); + pub static ref COMPOSER_CACHE_DIR: String = format!("{}composer", *ROOT_CACHE_DIR); -pub const TRACING_PROXY_CA_CERT_PATH: &str = - concatcp!(ROOT_CACHE_NOMOUNT_DIR, "tracing_proxy_ca.pem"); + pub static ref TRACING_PROXY_CA_CERT_PATH: String = + format!("{}tracing_proxy_ca.pem", *ROOT_CACHE_NOMOUNT_DIR); +} const NUM_SECS_PING: u64 = 5; const NUM_SECS_READINGS: u64 = 60; @@ -1375,7 +1376,7 @@ pub async fn run_worker( let start_time = Instant::now(); - let worker_dir = format!("{TMP_DIR}/{worker_name}"); + let worker_dir = format!("{}/{worker_name}", *WINDMILL_DIR); tracing::debug!(worker = %worker_name, hostname = %hostname, worker_dir = %worker_dir, "Creating worker dir"); #[cfg(feature = "python")] From 53caecf1da8d76e246178dfb9b86d330f0ec52fd Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:46:08 +0100 Subject: [PATCH 10/58] feat: Ducklake typechecker (#8118) * Typedchecked ducklake queries * Display script preview error as SQL error * Fix duplication * fix replacer * Revert "fix replacer" This reverts commit c5492033c850cabc8bf18a50151c089b83cd6826. * Don't recompile regex every call * nit OOB * avoid potential panic * Apply suggestions from code review Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * safety throw * Update backend/windmill-worker/src/duckdb_executor.rs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Try catch individual chunks in prepareDatatableQueries Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * format * nit comment * Revert "Try catch individual chunks in prepareDatatableQueries" This reverts commit ae64a8ad27deb7e5ddda10163c6db04a428827f2. * Correct try catch * better error messages * nit unused variable * comment * handle non describable queries * npm i --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../windmill-duckdb-ffi-internal/Cargo.lock | 1 + .../windmill-duckdb-ffi-internal/Cargo.toml | 1 + .../windmill-duckdb-ffi-internal/src/lib.rs | 255 +++++++++++++++--- .../windmill-worker/src/duckdb_executor.rs | 81 ++++++ frontend/src/lib/infer.svelte.ts | 174 ++++++++---- 5 files changed, 423 insertions(+), 89 deletions(-) diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.lock b/backend/windmill-duckdb-ffi-internal/Cargo.lock index 07e428c633..559196a3c2 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.lock +++ b/backend/windmill-duckdb-ffi-internal/Cargo.lock @@ -2164,6 +2164,7 @@ version = "0.1.0" dependencies = [ "chrono", "duckdb", + "regex", "rust_decimal", "serde", "serde_json", diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.toml b/backend/windmill-duckdb-ffi-internal/Cargo.toml index 7043b33ee5..7eb6869ab9 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.toml +++ b/backend/windmill-duckdb-ffi-internal/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] chrono = "0.4.41" duckdb = { version = "1.4.4", features = ["bundled"] } +regex = "1" rust_decimal = "1.37.2" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index c5c819e60b..2701319d6e 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -1,12 +1,14 @@ use std::{ collections::HashMap, - ffi::{CStr, CString, c_char, c_uint}, + ffi::{c_char, c_uint, CStr, CString}, ptr::null_mut, + sync::LazyLock, }; -use duckdb::{Row, core::LogicalTypeId, params_from_iter, types::TimeUnit}; -use rust_decimal::{Decimal, prelude::FromPrimitive}; -use serde::Deserialize; +use duckdb::{core::LogicalTypeId, params_from_iter, types::TimeUnit, Row}; +use regex::Regex; +use rust_decimal::{prelude::FromPrimitive, Decimal}; +use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; #[derive(Deserialize, Clone, Debug, PartialEq, Default)] @@ -96,6 +98,218 @@ pub extern "C" fn run_duckdb_ffi( }) } +#[derive(Serialize, Debug)] +struct PrepareQueryColumnInfo { + name: String, + #[serde(rename = "type")] + type_name: String, +} + +#[derive(Serialize, Debug)] +struct PrepareQueryResult { + #[serde(skip_serializing_if = "Option::is_none")] + columns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn is_setup_statement(query: &str) -> bool { + let trimmed = query.trim_start(); + let upper = trimmed.to_uppercase(); + upper.starts_with("ATTACH") + || upper.starts_with("USE") + || upper.starts_with("INSTALL") + || upper.starts_with("LOAD") + || upper.starts_with("SET") + || upper.starts_with("RESET") + || upper.starts_with("CREATE OR REPLACE SECRET") + || upper.starts_with("CREATE SECRET") +} + +/// Returns true if the query is expected to return a result set and can be wrapped with DESCRIBE. +fn is_describable_query(query: &str) -> bool { + let trimmed = query.trim_start(); + let upper = trimmed.to_uppercase(); + upper.starts_with("SELECT") + || upper.starts_with("WITH") + || upper.starts_with("VALUES") + || upper.starts_with("TABLE") + || upper.starts_with("FROM") +} + +static PARAM_RE: LazyLock = LazyLock::new(|| Regex::new(r"\$\d+").expect("invalid regex")); + +fn replace_params_with_null(query: &str) -> String { + PARAM_RE.replace_all(query, "NULL").to_string() +} + +#[unsafe(no_mangle)] +pub extern "C" fn prepare_duckdb_ffi( + query_block_list: *const *const c_char, + query_block_list_count: usize, + token: *const c_char, + base_internal_url: *const c_char, + w_id: *const c_char, +) -> *mut c_char { + let r = match convert_prepare_args( + query_block_list, + query_block_list_count, + token, + base_internal_url, + w_id, + ) + .and_then(|(query_block_list, token, base_internal_url, w_id)| { + prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id) + }) { + Ok(result) => result, + Err(err) => { + let err = serde_json::to_string(&err) + .unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string()); + format!("ERROR {}", err) + } + }; + + CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| { + println!("Failed to allocate error string in duckdb ffi lib: {:?}", e); + null_mut() + }) +} + +fn setup_duckdb_connection( + conn: &duckdb::Connection, + token: &str, + base_internal_url: &str, + w_id: &str, +) -> Result<(), String> { + let (s3_access_key, s3_secret_key) = token.rsplit_once('.').unwrap_or(("", token)); + let (s3_endpoint_ssl, s3_endpoint) = base_internal_url + .split_once("://") + .unwrap_or(("http", &base_internal_url)); + let s3_endpoint_ssl = s3_endpoint_ssl == "https"; + + conn.execute_batch(&format!( + "INSTALL httpfs; LOAD httpfs; + INSTALL azure; LOAD azure; + CREATE OR REPLACE SECRET s3_secret ( + TYPE s3, + PROVIDER config, + KEY_ID '{s3_access_key}', + SECRET '{s3_secret_key}', + ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', + URL_STYLE path, + USE_SSL {s3_endpoint_ssl} + ); + CREATE OR REPLACE SECRET gcs_secret ( + TYPE gcs, + KEY_ID '{s3_access_key}', + SECRET '{s3_secret_key}', + ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', + USE_SSL {s3_endpoint_ssl} + ); + ", + )) + .map_err(|e| format!("Error setting up S3 secret: {}", e.to_string())) +} + +fn convert_prepare_args<'a>( + query_block_list: *const *const c_char, + query_block_list_count: usize, + token: *const c_char, + base_internal_url: *const c_char, + w_id: *const c_char, +) -> Result<(Vec<&'a str>, &'a str, &'a str, &'a str), String> { + let query_block_list = unsafe { + std::slice::from_raw_parts(query_block_list, query_block_list_count) + .iter() + .map(|q| { + CStr::from_ptr(*q).to_str().unwrap_or_else(|e| { + println!( + "Invalid query_block string pointer in duckdb ffi: {}", + e.to_string() + ); + "Invalid query_block string pointer in duckdb ffi" + }) + }) + .collect::>() + }; + let token = unsafe { CStr::from_ptr(token) } + .to_str() + .map_err(|e| format!("Invalid token string: {}", e.to_string()))?; + let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) } + .to_str() + .map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?; + let w_id = unsafe { CStr::from_ptr(w_id) } + .to_str() + .map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?; + Ok((query_block_list, token, base_internal_url, w_id)) +} + +fn prepare_duckdb_internal( + query_block_list: Vec<&str>, + token: &str, + base_internal_url: &str, + w_id: &str, +) -> Result { + let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; + + setup_duckdb_connection(&conn, token, base_internal_url, w_id)?; + + let mut results: Vec = vec![]; + + // IMPORTANT: Setup statements (ATTACH, USE, INSTALL, etc.) are executed but intentionally + // do not produce a PrepareQueryResult entry. The frontend prepends these as connection setup + // before the actual user queries, and mapPrepareResults expects results.length to equal the + // number of user queries (not setup statements). If a new setup-like statement is added to + // the connection flow (e.g. in setup_duckdb_connection or transform_attach_ducklake) without + // also being caught by is_setup_statement, the result count will mismatch and the frontend + // will throw. + for query_block in &query_block_list { + if is_setup_statement(query_block) { + conn.execute_batch(query_block) + .map_err(|e| format!("Error executing setup statement: {}", e.to_string()))?; + continue; + } + + let modified_query = replace_params_with_null(query_block); + // Validate the query parses correctly by preparing it + if let Err(e) = conn.prepare(&modified_query) { + results.push(PrepareQueryResult { columns: None, error: Some(e.to_string()) }); + continue; + } + + // DESCRIBE only works on queries that return result sets (SELECT, WITH, VALUES, TABLE, + // FROM). For non-returning statements (INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, etc.) + // we skip DESCRIBE and assume no columns. + if !is_describable_query(&modified_query) { + results.push(PrepareQueryResult { columns: Some(vec![]), error: None }); + continue; + } + + // Note: We have to use a DESCRIBE statement and cannot simply use the + // methods returned by .prepare() because they panic if the statement was + // not executed at least once (which we specifically do not want to do). + let describe_query = format!("DESCRIBE {}", modified_query); + match conn.prepare(&describe_query).and_then(|mut stmt| { + let rows = stmt.query_map([], |row| { + Ok(PrepareQueryColumnInfo { + name: row.get::<_, String>(0)?, + type_name: row.get::<_, String>(1)?, + }) + })?; + rows.collect::, _>>() + }) { + Ok(columns) => { + results.push(PrepareQueryResult { columns: Some(columns), error: None }); + } + Err(e) => { + results.push(PrepareQueryResult { columns: None, error: Some(e.to_string()) }); + } + } + } + + serde_json::to_string(&results).map_err(|e| e.to_string()) +} + fn convert_args<'a>( query_block_list: *const *const c_char, query_block_list_count: usize, @@ -170,38 +384,7 @@ fn run_duckdb_internal<'a>( ) -> Result<(String, Option>), String> { let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; - let (s3_access_key, s3_secret_key) = token.split_at(token.rfind('.').unwrap_or(0)); - let s3_secret_key = &s3_secret_key[1..]; - let (s3_endpoint_ssl, s3_endpoint) = base_internal_url - .split_once("://") - .unwrap_or(("http", &base_internal_url)); - let s3_endpoint_ssl = match s3_endpoint_ssl { - "https" => true, - _ => false, - }; - - conn.execute_batch(&format!( - "INSTALL httpfs; LOAD httpfs; - INSTALL azure; LOAD azure; - CREATE OR REPLACE SECRET s3_secret ( - TYPE s3, - PROVIDER config, - KEY_ID '{s3_access_key}', - SECRET '{s3_secret_key}', - ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', - URL_STYLE path, - USE_SSL {s3_endpoint_ssl} - ); - CREATE OR REPLACE SECRET gcs_secret ( - TYPE gcs, - KEY_ID '{s3_access_key}', - SECRET '{s3_secret_key}', - ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', - USE_SSL {s3_endpoint_ssl} - ); - ", - )) - .map_err(|e| format!("Error setting up S3 secret: {}", e.to_string()))?; + setup_duckdb_connection(&conn, token, base_internal_url, w_id)?; let mut results: Vec>> = vec![]; let mut column_order = None; diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 73136e1cc0..45e2f647a5 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -161,6 +161,22 @@ pub async fn do_duckdb( let base_internal_url = client.base_internal_url.clone(); let w_id = job.workspace_id.clone(); + if annotations.prepare { + let result = tokio::task::spawn_blocking(move || { + prepare_duckdb_ffi_safe( + query_block_list.iter().map(String::as_str), + &token, + &base_internal_url, + &w_id, + ) + }) + .await + .map_err(|e| Error::from(to_anyhow(e))) + .and_then(|r| r)?; + + return Ok(result); + } + let result = tokio::task::spawn_blocking(move || { run_duckdb_ffi_safe( query_block_list.iter().map(String::as_str), @@ -248,6 +264,18 @@ struct DuckDbFfiLib { collect_first_row_only: bool, ) -> *mut c_char, >, + prepare_duckdb_ffi: Option< + Symbol< + 'static, + unsafe extern "C" fn( + query_block_list: *const *const c_char, + query_block_list_count: usize, + token: *const c_char, + base_internal_url: *const c_char, + w_id: *const c_char, + ) -> *mut c_char, + >, + >, free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>, } @@ -307,8 +335,11 @@ impl DuckDbFfiLib { } } + let prepare_duckdb_ffi = unsafe { lib.get(b"prepare_duckdb_ffi").ok() }; + Ok(DuckDbFfiLib { run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? }, + prepare_duckdb_ffi, free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? }, }) } @@ -388,6 +419,56 @@ fn run_duckdb_ffi_safe<'a>( } } +fn prepare_duckdb_ffi_safe<'a>( + query_block_list: impl Iterator, + token: &str, + base_internal_url: &str, + w_id: &str, +) -> Result> { + let query_block_list = query_block_list + .map(|s| { + CString::new(s).map_err(|e| { + Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string())) + }) + }) + .collect::>>()?; + let query_block_list = query_block_list + .iter() + .map(|s| s.as_ptr()) + .collect::>(); + + let token = CString::new(token).map_err(to_anyhow)?; + let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; + let w_id = CString::new(w_id).map_err(to_anyhow)?; + + let lib = DuckDbFfiLib::get_singleton()?; + let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| { + Error::InternalErr( + "prepare_duckdb_ffi not available in duckdb ffi library. Please update to the latest windmill_duckdb_ffi_lib.".to_string(), + ) + })?; + let free_cstr = &lib.free_cstr; + + let result_str = unsafe { + let ptr = prepare_fn( + query_block_list.as_ptr(), + query_block_list.len(), + token.as_ptr(), + base_internal_url.as_ptr(), + w_id.as_ptr(), + ); + let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); + free_cstr(ptr); + str + }; + + if result_str.starts_with("ERROR") { + Err(Error::ExecutionErr(result_str[6..].to_string())) + } else { + Ok(serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?) + } +} + struct ParsedAttachDbResource<'a> { resource_path: &'a str, name: &'a str, diff --git a/frontend/src/lib/infer.svelte.ts b/frontend/src/lib/infer.svelte.ts index 419020631c..cbdfa95505 100644 --- a/frontend/src/lib/infer.svelte.ts +++ b/frontend/src/lib/infer.svelte.ts @@ -4,6 +4,13 @@ import { ChangeOnDeepInequality, MapResource } from './svelte5Utils.svelte' import { sqlDataTypeToJsTypeHeuristic } from './components/apps/components/display/dbtable/utils' import { chunkBy, clone, getQueryStmtCountHeuristic } from './utils' +function extractErrorMessage(e: unknown): string { + if (e != null && typeof e === 'object' && 'body' in e) { + return (e as any).body?.error?.message ?? JSON.stringify(e) + } + return e instanceof Error ? e.message : JSON.stringify(e) +} + function computeQueryKey(query: InferAssetsSqlQueryDetails, workspace?: string) { return `${query.source_kind}::${query.source_name}::${query.source_schema}::${workspace}::${query.query_string}` } @@ -21,66 +28,26 @@ export function usePreparedAssetSqlQueries( ), async (toFetch) => { let queries = Object.entries(clone(toFetch)) - // We only support preparing datatable source kinds for now. - queries = queries.filter(([_, q]) => q.source_kind === 'datatable') + queries = queries.filter( + ([_, q]) => q.source_kind === 'datatable' || q.source_kind === 'ducklake' + ) // We only support preparing single-statement queries for now. queries = queries.filter(([_, q]) => getQueryStmtCountHeuristic(q.query_string) === 1) if (!queries?.length) return {} - try { - // We chunk by source_name to minimize the number of requests. - // For example if we have 10 queries on the same data table, - // we can prepare them all with a single script. - queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name)) - let results = ( - await Promise.all( - chunkBy(queries, ([key, q]) => q.source_name).map(async (chunk) => { - console.log( - 'Preparing chunk of queries:', - chunk.map(([_, q]) => q) - ) - let queryContent = chunk - .flatMap(([key, q]) => [ - q.source_schema ? `SET search_path TO ${q.source_schema};` : 'RESET search_path;', - q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';') - ]) - .join('\n') - queryContent = - '-- prepare\n--result_collection=all_statements_first_row\n' + queryContent + let datatableQueries = queries.filter(([_, q]) => q.source_kind === 'datatable') + let ducklakeQueries = queries.filter(([_, q]) => q.source_kind === 'ducklake') - let res = (await JobService.runScriptPreviewAndWaitResult({ - workspace: getWorkspace()!, - requestBody: { - language: 'postgresql', - content: queryContent, - args: { database: `datatable://${chunk[0][1]?.source_name}` } - } - })) as { error?: string; columns?: { name: string; type: string }[] }[] + let allResults: [string, PreparedAssetsSqlQuery][] = [] - console.log('Prepared query content:', res) - - let res2: [string, PreparedAssetsSqlQuery][] = res.map((r, i) => [ - chunk[i][0], - r.columns - ? { - columns: Object.fromEntries( - r.columns.map(({ name, type }) => [ - name, - sqlDataTypeToJsTypeHeuristic(type) - ]) - ) - } - : { error: r.error ?? "Couldn't prepare query " } - ]) - return res2 - }) - ) - ).flat() - - return Object.fromEntries(results) - } catch (e) { - throw e + if (datatableQueries.length) { + allResults.push(...(await prepareDatatableQueries(datatableQueries, getWorkspace))) } + if (ducklakeQueries.length) { + allResults.push(...(await prepareDucklakeQueries(ducklakeQueries, getWorkspace))) + } + + return Object.fromEntries(allResults) } ) @@ -96,3 +63,104 @@ export function usePreparedAssetSqlQueries( } } } + +type QueryEntry = [string, InferAssetsSqlQueryDetails] + +function mapPrepareResults( + res: { error?: string; columns?: { name: string; type: string }[] }[], + chunk: QueryEntry[] +): [string, PreparedAssetsSqlQuery][] { + if (res.length !== chunk.length) { + throw new Error(`Prepare results count mismatch: got ${res.length}, expected ${chunk.length}`) + } + return res.map((r, i) => [ + chunk[i]?.[0], + r.columns + ? { + columns: Object.fromEntries( + r.columns.map(({ name, type: t }) => [name, sqlDataTypeToJsTypeHeuristic(t)]) + ) + } + : { error: r.error ?? "Couldn't prepare query " } + ]) +} + +async function prepareDatatableQueries( + queries: QueryEntry[], + getWorkspace: () => string | undefined +): Promise<[string, PreparedAssetsSqlQuery][]> { + queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name)) + let results = ( + await Promise.all( + chunkBy(queries, ([_, q]) => q.source_name).map(async (chunk) => { + let queryContent = chunk + .flatMap(([_, q]) => [ + q.source_schema ? `SET search_path TO ${q.source_schema};` : 'RESET search_path;', + q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';') + ]) + .join('\n') + queryContent = '-- prepare\n--result_collection=all_statements_first_row\n' + queryContent + + try { + let res = (await JobService.runScriptPreviewAndWaitResult({ + workspace: getWorkspace()!, + requestBody: { + language: 'postgresql', + content: queryContent, + args: { database: `datatable://${chunk[0][1]?.source_name}` } + } + })) as { error?: string; columns?: { name: string; type: string }[] }[] + + return mapPrepareResults(res, chunk) + } catch (e) { + const error = extractErrorMessage(e) + return chunk.map(([key]) => [key, { error }] as [string, PreparedAssetsSqlQuery]) + } + }) + ) + ).flat() + return results +} + +async function prepareDucklakeQueries( + queries: QueryEntry[], + getWorkspace: () => string | undefined +): Promise<[string, PreparedAssetsSqlQuery][]> { + queries.sort((a, b) => a[1].source_name.localeCompare(b[1].source_name)) + let results = ( + await Promise.all( + chunkBy(queries, ([_, q]) => `${q.source_name}::${q.source_schema ?? ''}`).map( + async (chunk) => { + let sourceName = chunk[0][1].source_name + let sourceSchema = chunk[0][1].source_schema + let attachSetup = `ATTACH 'ducklake://${sourceName}' AS dl;\n` + attachSetup += sourceSchema ? `USE dl.${sourceSchema};\n` : `USE dl;\n` + + let queryContent = chunk + .map(([_, q]) => q.query_string + (q.query_string.trim().endsWith(';') ? '' : ';')) + .join('\n') + queryContent = + '-- prepare\n--result_collection=all_statements_first_row\n' + + attachSetup + + queryContent + + try { + let res = (await JobService.runScriptPreviewAndWaitResult({ + workspace: getWorkspace()!, + requestBody: { + language: 'duckdb', + content: queryContent, + args: {} + } + })) as { error?: string; columns?: { name: string; type: string }[] }[] + return mapPrepareResults(res, chunk) + } catch (e) { + const error = extractErrorMessage(e) + return chunk.map(([key]) => [key, { error }] as [string, PreparedAssetsSqlQuery]) + } + } + ) + ) + ).flat() + return results +} From 4bf827bea4d44aca8c5ff7aa67ad449dbcf00673 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:46:34 +0100 Subject: [PATCH 11/58] feat: persistent Db manager state in URI (#8134) * DB Manager state in URL * Fix state not saving * shorted uri params * infer db_type from prefix * Revert "infer db_type from prefix" This reverts commit 7415fbed3db0d570f321a0b86e0c5db6e876b430. * dbm syntax * infer database type * Omit main and public * remove legacy #dbmanager: * Preserve hash * nit * Fix remaining dbManagerDrawer objects --- .../src/lib/components/DBManagerDrawer.svelte | 77 +++---- .../src/lib/components/DatatablePicker.svelte | 5 +- .../src/lib/components/DucklakePicker.svelte | 5 +- .../lib/components/ExploreAssetButton.svelte | 7 +- .../src/lib/components/ResourcePicker.svelte | 4 +- frontend/src/lib/components/RunsPage.svelte | 4 +- .../lib/components/assets/AssetButtons.svelte | 3 - .../assets/AssetsDropdownButton.svelte | 4 +- .../components/assets/JobAssetsViewer.svelte | 4 +- .../components/dbManagerDrawerModel.svelte.ts | 207 ++++++++++++++++++ .../graph/renderers/nodes/AssetNode.svelte | 3 +- .../components/sidebar/FavoriteMenu.svelte | 2 +- .../CustomInstanceDbSelect.svelte | 4 - .../CustomInstanceDbWizardModal.svelte | 3 - .../DataTableSettings.svelte | 6 +- .../workspaceSettings/DucklakeSettings.svelte | 5 +- frontend/src/lib/stores.ts | 5 +- frontend/src/lib/svelte5UtilsKit.svelte.ts | 5 +- .../src/routes/(root)/(logged)/+layout.svelte | 28 +-- .../(root)/(logged)/assets/+page.svelte | 3 - .../(root)/(logged)/resources/+page.svelte | 6 +- 21 files changed, 266 insertions(+), 124 deletions(-) create mode 100644 frontend/src/lib/components/dbManagerDrawerModel.svelte.ts diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index c5986e18a4..5996d98075 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -6,27 +6,20 @@ import DrawerContent from './common/drawer/DrawerContent.svelte' import Select from './select/Select.svelte' import { ArrowLeft, Expand, LoaderCircle, Minimize, RefreshCcw } from 'lucide-svelte' - import type { DbInput } from './dbTypes' import DBManagerContent from './DBManagerContent.svelte' import { resource } from 'runed' + import { untrack } from 'svelte' + import type { DbManagerUriState } from './dbManagerDrawerModel.svelte' interface Props { + uriState: DbManagerUriState /** Z-index offset for the drawer, useful when opening from within modals */ offset?: number } - let { offset = 0 }: Props = $props() + let { uriState, offset = 0 }: Props = $props() - let input: DbInput | undefined = $state() - let open = $derived(!!input) - - // For datatable inputs, track the selected datatable separately - let selectedDatatable = $state(undefined) - - // Check if input is a datatable type - const isDatatableInput = $derived( - input?.type === 'database' && input.resourcePath.startsWith('datatable://') - ) + let open = $derived(uriState.open) // Load available datatables when drawer opens with datatable input const datatables = resource([], async () => { @@ -39,16 +32,6 @@ } }) - // Computed input that updates when selectedDatatable changes - const effectiveInput: DbInput | undefined = $derived.by(() => { - if (!input) return undefined - if (!isDatatableInput || !selectedDatatable) return input - return { - ...input, - resourcePath: `datatable://${selectedDatatable}` - } - }) - const datatableItems = $derived( datatables.current.map((dt) => ({ value: dt, @@ -56,32 +39,26 @@ })) ) - export function openDrawer(nInput: DbInput) { - input = nInput - if (isDatatableInput) { - datatables.refetch() + // Refetch datatables when switching to a datatable input + $effect(() => { + if (uriState.isDatatableInput) { + untrack(() => datatables.refetch()) } - // If it's a datatable input, extract the datatable name for the selector - if (nInput.type === 'database' && nInput.resourcePath.startsWith('datatable://')) { - selectedDatatable = nInput.resourcePath.replace('datatable://', '') - datatables.refetch() - } else { - selectedDatatable = undefined - } - } - export function closeDrawer() { - input = undefined - selectedDatatable = undefined + }) + + function handleClose() { + uriState.closeDrawer() dbManagerContent?.clearReplResult() - if (window.location.hash.startsWith('#dbmanager:')) - history.replaceState('', document.title, window.location.href.replace(/#dbmanager:.*$/, '')) } let windowWidth = $state(window.innerWidth) let expand = $state(false) $effect(() => { - if (!open) expand = false + if (!open) { + expand = false + uriState.closeDrawer() + } }) let dbManagerContent: DBManagerContent | undefined = $state() @@ -96,7 +73,7 @@ size={expand ? `${windowWidth}px` : '1200px'} preventEscape {offset} - on:close={closeDrawer} + on:close={handleClose} > - {#if effectiveInput && $workspaceStore} - {#key selectedDatatable} - + {#if uriState.effectiveInput && $workspaceStore} + {#key uriState.selectedDatatable} + {#snippet dbSelector()} - {#if isDatatableInput} + {#if uriState.isDatatableInput} {#if datatables.loading} @@ -125,7 +108,7 @@ `Datatable: ${s}`} items={datatableItems} - bind:value={selectedDatatable} + bind:value={uriState.selectedDatatable} placeholder="Select data table" size="md" /> diff --git a/frontend/src/lib/components/DatatablePicker.svelte b/frontend/src/lib/components/DatatablePicker.svelte index 68cdccea63..e533e63f93 100644 --- a/frontend/src/lib/components/DatatablePicker.svelte +++ b/frontend/src/lib/components/DatatablePicker.svelte @@ -1,6 +1,6 @@ @@ -49,7 +49,6 @@ {/if} diff --git a/frontend/src/lib/components/DucklakePicker.svelte b/frontend/src/lib/components/DucklakePicker.svelte index 9260c99bf9..e84ddb9060 100644 --- a/frontend/src/lib/components/DucklakePicker.svelte +++ b/frontend/src/lib/components/DucklakePicker.svelte @@ -1,6 +1,6 @@ @@ -49,7 +49,6 @@ {/if} diff --git a/frontend/src/lib/components/ExploreAssetButton.svelte b/frontend/src/lib/components/ExploreAssetButton.svelte index ba623b4e4e..6cba9a0515 100644 --- a/frontend/src/lib/components/ExploreAssetButton.svelte +++ b/frontend/src/lib/components/ExploreAssetButton.svelte @@ -16,9 +16,8 @@ import { isDbType } from '$lib/components/dbTypes' import { formatAsset, type Asset } from '$lib/components/assets/lib' import { Button, ButtonType } from '$lib/components/common' - import DbManagerDrawer from '$lib/components/DBManagerDrawer.svelte' import S3FilePicker from '$lib/components/S3FilePicker.svelte' - import { userStore } from '$lib/stores' + import { globalDbManagerDrawer, userStore } from '$lib/stores' import { isS3Uri } from '$lib/utils' import { Database, File } from 'lucide-svelte' import DucklakeIcon from './icons/DucklakeIcon.svelte' @@ -27,7 +26,6 @@ asset, _resourceMetadata, s3FilePicker, - dbManagerDrawer, onClick, class: className = '', noText = false, @@ -38,7 +36,6 @@ asset: Asset _resourceMetadata?: { resource_type?: string } s3FilePicker?: S3FilePicker - dbManagerDrawer?: DbManagerDrawer onClick?: () => void class?: string noText?: boolean @@ -46,6 +43,8 @@ btnClasses?: string disabled?: boolean } = $props() + + let dbManagerDrawer = $derived(globalDbManagerDrawer.val) const assetUri = $derived(formatAsset(asset)) diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 062e39804b..6806e60d3c 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -1,6 +1,6 @@ @@ -317,7 +316,6 @@ class="mt-1" _resourceMetadata={{ resource_type: resourceType }} asset={{ kind: 'resource', path: value }} - {dbManagerDrawer} /> {/if} diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 59b63ab313..d03495c052 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -148,7 +148,9 @@ (v) => { v.maxTs ? (filters.val.max_ts = new Date(v.maxTs)) : delete filters.val.max_ts v.minTs ? (filters.val.min_ts = new Date(v.minTs)) : delete filters.val.min_ts - v.timeframe ? (filters.val.timeframe = v.timeframe) : delete filters.val.timeframe + v.timeframe && v.timeframe !== 'Latest runs' + ? (filters.val.timeframe = v.timeframe) + : delete filters.val.timeframe } ) let timeframe = $derived(_timeframe.val) diff --git a/frontend/src/lib/components/assets/AssetButtons.svelte b/frontend/src/lib/components/assets/AssetButtons.svelte index 241a0f050e..9364bf710b 100644 --- a/frontend/src/lib/components/assets/AssetButtons.svelte +++ b/frontend/src/lib/components/assets/AssetButtons.svelte @@ -8,7 +8,6 @@ type Props = { s3FilePicker?: any | undefined - dbManagerDrawer?: any | undefined resourceEditorDrawer?: ResourceEditorDrawer | undefined resourceDataCache: Record asset: Asset @@ -18,7 +17,6 @@ } let { s3FilePicker, - dbManagerDrawer, resourceEditorDrawer, resourceDataCache, asset, @@ -71,7 +69,6 @@ onClick?.()} noText _resourceMetadata={{ resource_type: resourceDataCacheValue }} diff --git a/frontend/src/lib/components/assets/AssetsDropdownButton.svelte b/frontend/src/lib/components/assets/AssetsDropdownButton.svelte index 1e1e1871ae..5c530cf606 100644 --- a/frontend/src/lib/components/assets/AssetsDropdownButton.svelte +++ b/frontend/src/lib/components/assets/AssetsDropdownButton.svelte @@ -14,7 +14,7 @@ } from './lib' import { untrack } from 'svelte' import { ResourceService, WorkspaceService } from '$lib/gen' - import { globalDbManagerDrawer, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import Tooltip from '../meltComponents/Tooltip.svelte' import Tooltip2 from '../Tooltip.svelte' import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte' @@ -48,7 +48,6 @@ let blueBgDiv: HTMLDivElement | undefined = $state() let s3FilePicker: S3FilePicker | undefined = $state() - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state() let isOpen = $state(false) let resourceDataCache: Record = $state({}) @@ -209,7 +208,6 @@ onClick={() => (isOpen = false)} {asset} {resourceDataCache} - {dbManagerDrawer} {resourceEditorDrawer} {s3FilePicker} {ducklakeNotFound} diff --git a/frontend/src/lib/components/assets/JobAssetsViewer.svelte b/frontend/src/lib/components/assets/JobAssetsViewer.svelte index 2eccf66d51..86b9c7eca7 100644 --- a/frontend/src/lib/components/assets/JobAssetsViewer.svelte +++ b/frontend/src/lib/components/assets/JobAssetsViewer.svelte @@ -1,7 +1,7 @@ @@ -86,7 +85,6 @@ diff --git a/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts new file mode 100644 index 0000000000..c03695e0a3 --- /dev/null +++ b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts @@ -0,0 +1,207 @@ +import { z } from 'zod' +import { useSearchParams } from '$lib/svelte5UtilsKit.svelte' +import type { DbInput, DbType } from './dbTypes' +import { isDbType } from './dbTypes' + +/** + * Single URL param `dbm` encodes the full DB manager state: + * firstSegment~path~schema.table + * + * firstSegment: + * datatable – database with datatable:// resource (resourceType always postgresql) + * ducklake – ducklake connection + * postgresql, mysql, … – regular database (the segment IS the resource type) + * + * schema.table (third segment, optional): + * schema.table – both + * .table – table only + * schema. – schema only + * (omitted) – neither + * + * Default schemas (omitted from URL, restored on parse): + * datatable → public + * ducklake → main + * + * Examples: + * datatable~main~.customers (schema "public" implied) + * ducklake~main~.orders (schema "main" implied) + * postgresql~$res:u/user/my_pg~public.customers + */ + +const dbManagerSchema = z.object({ + dbm: z.string().nullable() +}) + +interface ParsedDbm { + type: 'database' | 'datatable' | 'ducklake' + path: string + resType?: string + schema?: string + table?: string +} + +function parseDbm(raw: unknown): ParsedDbm | null { + if (!raw || typeof raw !== 'string') return null + const parts = raw.split('~') + if (parts.length < 2 || !parts[1]) return null + + const firstSeg = parts[0] + const path = parts[1] + const schemaTable = parts[2] ?? '' + + let type: ParsedDbm['type'] + let resType: string | undefined + if (firstSeg === 'datatable') { + type = 'datatable' + } else if (firstSeg === 'ducklake') { + type = 'ducklake' + } else if (isDbType(firstSeg)) { + type = 'database' + resType = firstSeg + } else { + return null + } + + let schema: string | undefined + let table: string | undefined + if (schemaTable) { + const dotIdx = schemaTable.indexOf('.') + if (dotIdx === 0) { + table = schemaTable.slice(1) || undefined + } else if (dotIdx === schemaTable.length - 1) { + schema = schemaTable.slice(0, -1) || undefined + } else if (dotIdx > 0) { + schema = schemaTable.slice(0, dotIdx) + table = schemaTable.slice(dotIdx + 1) + } + } + + // Restore default schema when omitted for datatable/ducklake + if (!schema && table && type in defaultSchemas) { + schema = defaultSchemas[type] + } + + return { type, path, resType, schema, table } +} + +const defaultSchemas: Record = { datatable: 'public', ducklake: 'main' } + +function buildDbm(p: ParsedDbm): string { + const firstSeg = p.type === 'database' ? p.resType! : p.type + const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema + let schemaTable = '' + if (schema && p.table) { + schemaTable = `${schema}.${p.table}` + } else if (p.table) { + schemaTable = `.${p.table}` + } else if (schema) { + schemaTable = `${schema}.` + } + return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}` +} + +export interface DbManagerUriState { + readonly input: DbInput | undefined + readonly effectiveInput: DbInput | undefined + readonly isDatatableInput: boolean + selectedDatatable: string | undefined + selectedSchema: string | undefined + selectedTable: string | undefined + readonly open: boolean + openDrawer: (nInput: DbInput) => void + closeDrawer: () => void +} + +export function useDbManagerUriState(): DbManagerUriState { + const params = useSearchParams(dbManagerSchema) + + const parsed = $derived(parseDbm(params.dbm)) + + let input: DbInput | undefined = $derived.by(() => { + if (!parsed) return undefined + if (parsed.type === 'ducklake') { + return { + type: 'ducklake' as const, + ducklake: parsed.path, + specificTable: parsed.table + } + } + // datatable or database + const resType = parsed.type === 'datatable' ? 'postgresql' : parsed.resType + if (!isDbType(resType ?? undefined)) return undefined + return { + type: 'database' as const, + resourceType: resType as DbType, + resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path, + specificSchema: parsed.schema, + specificTable: parsed.table + } + }) + + const isDatatableInput = $derived(parsed?.type === 'datatable') + + function updateField(updates: Partial) { + const p = parseDbm(params.dbm) + if (!p) return + Object.assign(p, updates) + params.dbm = buildDbm(p) + } + + function openDrawer(nInput: DbInput) { + if (nInput.type === 'database') { + const isDatatable = nInput.resourcePath.startsWith('datatable://') + params.dbm = buildDbm({ + type: isDatatable ? 'datatable' : 'database', + path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath, + resType: isDatatable ? undefined : nInput.resourceType, + schema: nInput.specificSchema, + table: nInput.specificTable + }) + } else { + params.dbm = buildDbm({ + type: 'ducklake', + path: nInput.ducklake, + table: nInput.specificTable + }) + } + } + + function closeDrawer() { + params.dbm = null + } + + return { + get input() { + return input + }, + get effectiveInput() { + return input + }, + get isDatatableInput() { + return isDatatableInput + }, + get selectedDatatable() { + return parsed?.type === 'datatable' ? parsed.path : undefined + }, + set selectedDatatable(v: string | undefined) { + if (v) updateField({ path: v }) + }, + get selectedSchema() { + return parsed?.schema + }, + set selectedSchema(v: string | undefined) { + updateField({ schema: v }) + }, + get selectedTable() { + return parsed?.table + }, + set selectedTable(v: string | undefined) { + updateField({ table: v }) + }, + get open() { + return !!input + }, + openDrawer, + closeDrawer + } +} diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index ad4a11bd2f..68c2bad5df 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -235,7 +235,7 @@ import type { Edge, Node } from '@xyflow/svelte' import { getNodeColorClasses, NODE } from '../../util' - import { globalDbManagerDrawer, userStore } from '$lib/stores' + import { userStore } from '$lib/stores' import { deepEqual } from 'fast-equals' import { slide } from 'svelte/transition' import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte' @@ -313,7 +313,6 @@ noText buttonVariant="accent" s3FilePicker={flowGraphAssetsCtx?.val.s3FilePicker} - dbManagerDrawer={globalDbManagerDrawer.val} _resourceMetadata={cachedResourceMetadata} /> diff --git a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte index d0a14143ca..056bca1489 100644 --- a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte +++ b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte @@ -10,7 +10,7 @@ flow: `/flows/get/${path}`, app: `/apps/get/${path}`, raw_app: `/apps_raw/get/${path}`, - asset: `#dbmanager:${path}` + asset: '#' }[kind] } export function getFavoriteLabel(path: string, kind: FavoriteKind): string { diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte index b74d7c9b92..44a4dcc6bf 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte @@ -8,14 +8,12 @@ import CustomInstanceDbWizardModal from './CustomInstanceDbWizardModal.svelte' import { ArrowRight, TriangleAlert } from 'lucide-svelte' import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte' - import DBManagerDrawer from '../DBManagerDrawer.svelte' import type { Snippet } from 'svelte' type Props = { value: string | undefined customInstanceDbs: ResourceReturn confirmationModal: ConfirmationModalHandle - dbManagerDrawer: DBManagerDrawer | undefined wizardBottomHint?: Snippet | undefined class?: string tag?: CustomInstanceDbTag @@ -24,7 +22,6 @@ value = $bindable(), customInstanceDbs, confirmationModal, - dbManagerDrawer, wizardBottomHint, class: className, tag @@ -90,7 +87,6 @@ confirmationModal: ConfirmationModalHandle - dbManagerDrawer: any | undefined bottomHint?: Snippet | undefined opened: { status: CustomInstanceDb | undefined; dbname: string } | undefined tag?: CustomInstanceDbTag @@ -33,7 +32,6 @@ let { customInstanceDbs, confirmationModal, - dbManagerDrawer, bottomHint, opened = $bindable(), tag @@ -76,7 +74,6 @@ class="flex-1" asset={{ kind: 'resource', path: 'CUSTOM_INSTANCE_DB/' + dbname }} _resourceMetadata={{ resource_type: 'postgresql' }} - {dbManagerDrawer} disabled={!$isCustomInstanceDbEnabled || !enableManageButton} onClick={() => (opened = undefined)} /> diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 188d736e9f..6410e6a1af 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -62,7 +62,7 @@ import { random_adj } from '../random_positive_adjetive' import { sendUserToast } from '$lib/toast' import { SettingService, WorkspaceService, type GetSettingsResponse } from '$lib/gen' - import { globalDbManagerDrawer, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { resource } from 'runed' @@ -141,7 +141,6 @@ } let confirmationModal = createAsyncConfirmationModal() - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) let dirtyMap = $derived.by(() => { const map: Record = {} for (let i = 0; i < tempSettings.dataTables.length; i++) { @@ -245,7 +244,6 @@ @@ -275,7 +272,6 @@ {/if} diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index ac8c20377d..c264219922 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -68,7 +68,7 @@ import { SettingService, WorkspaceService } from '$lib/gen' import type { GetSettingsResponse } from '$lib/gen' - import { globalDbManagerDrawer, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import ExploreAssetButton from '../ExploreAssetButton.svelte' import Tooltip from '../Tooltip.svelte' @@ -187,7 +187,6 @@ 'Where the data is actually stored, in parquet format. You need to configure a workspace storage first' } - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) let confirmationModal = createAsyncConfirmationModal() @@ -293,7 +292,6 @@ bind:value={ducklake.catalog.resource_path} {customInstanceDbs} {confirmationModal} - {dbManagerDrawer} tag="ducklake" > {#snippet wizardBottomHint()} @@ -372,7 +370,6 @@ {:else} {/if} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index a397cb4b7f..6a9a8e7805 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -14,6 +14,7 @@ import { import { getLocalSetting, type StateStore } from './utils' import { createState } from './svelte5Utils.svelte' import { DEFAULT_HUB_BASE_URL } from './hub' +import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte' export interface UserExt { email: string @@ -126,9 +127,7 @@ export const codeCompletionSessionEnabled = writable( export const usedTriggerKinds = writable([]) -export let globalDbManagerDrawer: StateStore = createState({ - val: undefined -}) +export let globalDbManagerDrawer: StateStore = { val: undefined } type SQLBaseSchema = { [schemaKey: string]: { diff --git a/frontend/src/lib/svelte5UtilsKit.svelte.ts b/frontend/src/lib/svelte5UtilsKit.svelte.ts index cadd2dee1e..921d41614e 100644 --- a/frontend/src/lib/svelte5UtilsKit.svelte.ts +++ b/frontend/src/lib/svelte5UtilsKit.svelte.ts @@ -107,9 +107,10 @@ export function useSearchParams(schema: S): SearchParamsRes } else { sp.set(key, serializeParam(v)) } + const hash = window.location.hash const newUrl = sp.toString() - ? `${window.location.pathname}?${sp}` - : window.location.pathname + ? `${window.location.pathname}?${sp}${hash}` + : `${window.location.pathname}${hash}` history.replaceState(history.state, '', newUrl) }, enumerable: true, diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 4260bbeede..e9b1884df2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -11,13 +11,7 @@ UserService, WorkspaceService } from '$lib/gen' - import { - capitalize, - classNames, - getModifierKey, - parseDbInputFromAssetSyntax, - sendUserToast - } from '$lib/utils' + import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils' import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' @@ -66,8 +60,8 @@ import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte' import { DEFAULT_HUB_BASE_URL } from '$lib/hub' import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte' - import { watchOnce } from 'runed' import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte' + import { useDbManagerUriState } from '$lib/components/dbManagerDrawerModel.svelte' interface Props { children?: import('svelte').Snippet } @@ -439,18 +433,8 @@ untrack(() => loadProtectionRules(workspace)) } }) - watchOnce( - () => globalDbManagerDrawer.val, - () => { - if (!globalDbManagerDrawer.val) return - const hash = window.location.hash - if (hash.startsWith('#dbmanager:')) { - const [_, path] = hash.split('#dbmanager:') - const dbInput = parseDbInputFromAssetSyntax(path) - if (dbInput) globalDbManagerDrawer.val?.openDrawer(dbInput) - } - } - ) + + globalDbManagerDrawer.val = useDbManagerUriState() @@ -786,6 +770,6 @@ {/if} -{#if $workspaceStore} - +{#if $workspaceStore && globalDbManagerDrawer.val} + {/if} diff --git a/frontend/src/routes/(root)/(logged)/assets/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/+page.svelte index 7eac2cc535..032a60af66 100644 --- a/frontend/src/routes/(root)/(logged)/assets/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/+page.svelte @@ -99,7 +99,6 @@ let assets = $derived(_assets.current?.flatMap((page) => page.assets)) let s3FilePicker: S3FilePicker | undefined = $state() - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any let assetsUsageDropdown: AssetsUsageDrawer | undefined = $state() let allS3Storages = resource( @@ -192,7 +191,6 @@ @@ -337,7 +335,6 @@ {/if} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index f0ac8ae9d6..abf17b88bb 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -35,8 +35,7 @@ enterpriseLicense, userStore, workspaceStore, - userWorkspaces, - globalDbManagerDrawer + userWorkspaces } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { @@ -557,8 +556,6 @@ } }) - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any - let showTable = $derived( tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme' ) @@ -1064,7 +1061,6 @@ {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} From c0c9388415716ce77d841bd08a46f94e0a529685 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 4 Mar 2026 10:53:01 +0000 Subject: [PATCH 12/58] feat: add move, delete, and duplicate to flow node context menu (#8050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add context menu, multi-select actions, and keyboard shortcuts to flow editor Co-Authored-By: Claude Opus 4.6 * fix: address review feedback on context menu PR - Revert accidental static import of @scalar/openapi-parser (keep lazy-loaded) - Restore [data-context-menu] in portalDivs for clickOutside compatibility - Make noteDisabled reactive ($derived) in ModuleNode - Use platform-aware shortcut hint (⌫ on Mac, Del on Windows/Linux) - Optimize resolveSelectedModuleIds with single-pass ancestor map Co-Authored-By: Claude Opus 4.6 * fix: address additional review feedback on flow context menu PR - Use $derived.by instead of $derived for computed bounds in SelectionBoundingBox - Remove redundant structuredClone wrappers around $state.snapshot - Add null guard for originalModules/targetModules in move handler - Add upper-bound guard (n < 10000) to copyId loop - Fix fragile toggle comparison in moveManager with full array equality Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../src/lib/components/DropdownV2Inner.svelte | 3 + .../src/lib/components/FlowBuilder.svelte | 12 +- .../flows/content/FlowEditorPanel.svelte | 26 ++- .../components/flows/content/FlowLoop.svelte | 1 - .../flows/content/FlowSelectionPanel.svelte | 59 +++++-- .../lib/components/flows/flowModuleNextId.ts | 12 ++ .../flows/map/FlowModuleSchemaItem.svelte | 153 +++++++---------- .../map/FlowModuleSchemaItemViewer.svelte | 20 ++- .../flows/map/FlowModuleSchemaMap.svelte | 154 ++++++++++++++++-- .../lib/components/flows/map/MapItem.svelte | 14 +- .../components/flows/map/VirtualItem.svelte | 11 +- .../lib/components/flows/multiSelectUtils.ts | 136 ++++++++++++++++ .../components/graph/DragCoordinator.svelte | 22 ++- .../src/lib/components/graph/DragGhost.svelte | 18 +- .../lib/components/graph/FlowGraphV2.svelte | 70 +++++++- .../components/graph/MoveHandleButton.svelte | 74 +++++++++ .../graph/SelectionBoundingBox.svelte | 111 ++++++++++--- .../components/graph/graphBuilder.svelte.ts | 1 + .../components/graph/moveManager.svelte.ts | 69 +++++--- .../components/graph/noteManager.svelte.ts | 7 + .../graph/renderers/edges/BaseEdge.svelte | 2 +- .../graph/renderers/nodes/ModuleNode.svelte | 48 +++++- .../graph/renderers/nodes/NodeWrapper.svelte | 18 +- .../components/graph/selectionUtils.svelte.ts | 13 +- frontend/src/lib/utils.ts | 2 + 25 files changed, 854 insertions(+), 202 deletions(-) create mode 100644 frontend/src/lib/components/flows/multiSelectUtils.ts create mode 100644 frontend/src/lib/components/graph/MoveHandleButton.svelte diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 21157bd6b2..b56a5cce70 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -62,6 +62,9 @@ {item.displayName} {@render item.extra?.()} + {#if item.shortcut} + {item.shortcut} + {/if} {#if item.tooltip} {#snippet text()} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8ec0acbdf1..d3187c43aa 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -862,14 +862,6 @@ const mod = isMac() ? '⌘' : 'Ctrl+' - const undoShortcutSnippet = createRawSnippet(() => ({ - render: () => `${mod}Z` - })) - - const redoShortcutSnippet = createRawSnippet(() => ({ - render: () => `${mod}⇧Z` - })) - function getMoreItems(): Item[] { return [ ...baseMenuItems, @@ -878,7 +870,7 @@ icon: Undo, action: () => handleUndo(), disabled: $history.index === 0, - extra: undoShortcutSnippet, + shortcut: `${mod}Z`, separatorTop: baseMenuItems.length > 0 }, { @@ -886,7 +878,7 @@ icon: Redo, action: () => handleRedo(), disabled: $history.index === $history.history.length - 1, - extra: redoShortcutSnippet + shortcut: `${mod}⇧Z` }, { displayName: 'Tutorials', diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 8ebf167d51..c8418e5183 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -16,6 +16,11 @@ import FlowResult from './FlowResult.svelte' import type { StateStore } from '$lib/utils' import FlowSelectionPanel from './FlowSelectionPanel.svelte' + import { + resolveSelectedModuleIds, + locateModules, + areContiguousSiblings + } from '../multiSelectUtils' interface Props { noEditor?: boolean @@ -89,10 +94,29 @@ $effect(() => { computeMissingInputWarnings(flowStore, flowStateStore.val, flowInputsStore) }) + + // Derived state for multi-select operations in the side panel + let resolvedModuleIds = $derived( + resolveSelectedModuleIds(selectionManager.selectedIds, flowStore.val.value.modules ?? []) + ) + let canMoveSelected = $derived( + resolvedModuleIds.length > 0 && + areContiguousSiblings( + locateModules(resolvedModuleIds, flowStore.val.value.modules ?? []) + ) + ) {#if selectionManager && selectionManager.selectedIds.length > 1} - + flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)} + onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)} + onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)} + {canMoveSelected} + resolvedCount={resolvedModuleIds.length} + /> {:else if selectedId?.startsWith('settings')} {:else if selectedId === 'Input'} diff --git a/frontend/src/lib/components/flows/content/FlowLoop.svelte b/frontend/src/lib/components/flows/content/FlowLoop.svelte index f087079356..886f57386f 100644 --- a/frontend/src/lib/components/flows/content/FlowLoop.svelte +++ b/frontend/src/lib/components/flows/content/FlowLoop.svelte @@ -425,7 +425,6 @@ on:blur={() => { iteratorFieldFocused = false }} - autofocus lang="javascript" bind:code={mod.value.iterator.expr} class="h-full" diff --git a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte index b14f090afc..7efb44b82d 100644 --- a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte @@ -2,14 +2,29 @@ import FlowCard from '../common/FlowCard.svelte' import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte' import { Button } from '$lib/components/common' + import DropdownV2 from '$lib/components/DropdownV2.svelte' import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte' - import { StickyNote } from 'lucide-svelte' + import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte' + import type { Item } from '$lib/utils' interface Props { selectionManager: SelectionManager noEditor: boolean + onDeleteSelected?: () => void + onDuplicateSelected?: () => void + onMoveSelected?: () => void + canMoveSelected?: boolean + resolvedCount?: number } - let { selectionManager, noEditor }: Props = $props() + let { + selectionManager, + noEditor, + onDeleteSelected, + onDuplicateSelected, + onMoveSelected, + canMoveSelected = false, + resolvedCount = 0 + }: Props = $props() const noteEditorContext = getNoteEditorContext() @@ -19,20 +34,44 @@ noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds) } } + + let menuItems: Item[] = $derived([ + { + displayName: 'Move', + icon: Move, + action: () => onMoveSelected?.(), + disabled: !canMoveSelected + }, + { + displayName: 'Duplicate', + icon: Copy, + action: () => onDuplicateSelected?.() + }, + { + displayName: `Delete (${resolvedCount})`, + icon: Trash2, + type: 'delete', + action: () => onDeleteSelected?.() + } + ]) {#snippet action()} - - Create group note - + + + Create group note + + {#if resolvedCount > 0} + + {/if} + {/snippet} - {selectionManager.selectedIds.length} nodes selected {#each selectionManager.selectedIds as nodeId} diff --git a/frontend/src/lib/components/flows/flowModuleNextId.ts b/frontend/src/lib/components/flows/flowModuleNextId.ts index 34bed860f8..e10c900982 100644 --- a/frontend/src/lib/components/flows/flowModuleNextId.ts +++ b/frontend/src/lib/components/flows/flowModuleNextId.ts @@ -17,3 +17,15 @@ export function nextId(flowState: FlowState, fullFlow: OpenFlow): string { }, 0) return numberToChars(max) } + +// Computes a copy id like "a2", "a3", etc. based on the original id +export function copyId(originalId: string, flowState: FlowState, fullFlow: OpenFlow): string { + const allIds = new Set(dfs(fullFlow.value.modules, (fm) => fm.id).concat(Object.keys(flowState))) + for (let n = 2; n < 10000; n++) { + const candidate = `${originalId}${n}` + if (!allIds.has(candidate)) { + return candidate + } + } + return `${originalId}10000` +} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 8836edb708..0a0fe310f0 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -2,12 +2,13 @@ import { preventDefault, stopPropagation } from 'svelte/legacy' import Popover from '$lib/components/Popover.svelte' - import { classNames, type StateStore } from '$lib/utils' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { classNames, type Item, type StateStore } from '$lib/utils' import { Bed, Database, Gauge, - Move, + EllipsisVertical, PhoneIncoming, Repeat, Square, @@ -20,7 +21,7 @@ Timer, Maximize2 } from 'lucide-svelte' - import { createEventDispatcher, getContext, onDestroy } from 'svelte' + import { createEventDispatcher, getContext } from 'svelte' import { fade } from 'svelte/transition' import type { FlowEditorContext } from '../types' import { twMerge } from 'tailwind-merge' @@ -48,6 +49,7 @@ import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' import DiffActionBar from './DiffActionBar.svelte' import { getGraphContext } from '$lib/components/graph/graphContext' + import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte' interface Props { selected?: boolean @@ -69,7 +71,6 @@ id?: string | undefined label: string path?: string - modType?: string | undefined nodeState?: FlowNodeState concurrency?: boolean // TODO: Implement for this one. See how concurrency is implemented. @@ -89,6 +90,7 @@ isOwner?: boolean enableTestRun?: boolean maximizeSubflow?: () => void + menuItems?: Item[] } let { @@ -106,7 +108,6 @@ id = undefined, label, path = '', - modType = undefined, nodeState, concurrency = false, debouncing = false, @@ -123,7 +124,8 @@ onEditInput, flowJob, enableTestRun = false, - maximizeSubflow = undefined + maximizeSubflow = undefined, + menuItems = undefined }: Props = $props() // AI action colors take priority over execution state @@ -138,6 +140,9 @@ const diffManager = flowGraphContext?.diffManager const moveManager = flowGraphContext?.moveManager + // Hide per-node action buttons when multiple nodes are selected (multi-select mode) + let isMultiSelected = $derived((flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1) + let pickableIds: Record | undefined = $state(undefined) const dispatch = createEventDispatcher() @@ -161,6 +166,7 @@ let outputPicker: OutputPicker | undefined = $state(undefined) let testJob: any | undefined = $state(undefined) let outputPickerBarOpen = $state(false) + let dropdownOpen = $state(false) let flowStateStore = $derived(flowEditorContext?.flowStateStore) @@ -188,42 +194,6 @@ let isDragging = $derived(!!moveManager?.dragging) - // --- Drag handle logic --- - let dragCleanup: (() => void) | undefined - - function onMovePointerDown(e: Event) { - const pe = e as PointerEvent - const startX = pe.clientX - const startY = pe.clientY - let didDrag = false - - function onMovePointer(me: PointerEvent) { - const dx = me.clientX - startX - const dy = me.clientY - startY - if (!didDrag && Math.sqrt(dx * dx + dy * dy) > 5) { - didDrag = true - if (moveManager && id) { - moveManager.startDrag(id, startX, startY) - } - } - } - - function onUp() { - document.removeEventListener('pointermove', onMovePointer) - document.removeEventListener('pointerup', onUp) - dragCleanup = undefined - if (!didDrag) { - dispatch('move') - } - } - - document.addEventListener('pointermove', onMovePointer) - document.addEventListener('pointerup', onUp) - dragCleanup = onUp - } - - onDestroy(() => dragCleanup?.()) - const outputPickerVisible = $derived( editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging ) @@ -467,6 +437,7 @@ {deletable} {bold} bind:editId + disableEditId={isMultiSelected} {hover} {colorClasses} > @@ -475,7 +446,7 @@ {/snippet} - {#if outputPickerVisible} + {#if outputPickerVisible && !isMultiSelected} - - - - - - {/if} - - - dispatch('delete', { id, type: modType })) - )} - onpointerdown={stopPropagation(preventDefault(() => {}))} - > - - - - {#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)} {/if} + + {#if !isMultiSelected && id !== 'preprocessor' && moveManager && id} + + dispatch('move')} + class="trash group-hover:block" + /> + + {/if} + + {#if !isMultiSelected && id !== 'preprocessor' && menuItems && menuItems.length > 0} + + + {#snippet buttonReplacement()} + {}))} + title="Actions" + > + + + {/snippet} + + + {/if} {:else if maximizeSubflow !== undefined} {@render buttonMaximizeSubflow?.()} {/if} @@ -603,7 +576,7 @@ onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)} > - {#if (hover || selected || testRunDropdownOpen) && outputPickerVisible} + {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible} {#if !testIsLoading} {#snippet buttonMaximizeSubflow()} - + { e.stopPropagation() diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte index 6425711661..8ee9b8ba11 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte @@ -15,6 +15,7 @@ deletable?: boolean bold?: boolean editId?: boolean + disableEditId?: boolean hover?: boolean colorClasses?: FlowNodeColorClasses icon?: import('svelte').Snippet @@ -28,6 +29,7 @@ deletable = false, bold = false, editId = $bindable(false), + disableEditId = false, hover = false, colorClasses, icon, @@ -74,16 +76,18 @@ )} baseClass={twMerge('!px-1')} title={id} - clickable - onclick={(e) => { - e?.preventDefault() - e?.stopPropagation() - editId = !editId - onclick?.() - }} + clickable={!disableEditId} + onclick={disableEditId + ? undefined + : (e) => { + e?.preventDefault() + e?.stopPropagation() + editId = !editId + onclick?.() + }} > - {#if editId || (hover && deletable)} + {#if !disableEditId && (editId || (hover && deletable))} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 6dbaea9f67..eada672f24 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -18,11 +18,13 @@ import { emptyFlowModuleState } from '../utils.svelte' import { dfs } from '../dfs' + import { nextId, copyId } from '../flowModuleNextId' import { push } from '$lib/history.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Portal from '$lib/components/Portal.svelte' import { getDependentComponents } from '../flowExplorer' + import { locateModules, groupByParent } from '../multiSelectUtils' import { workspaceStore } from '$lib/stores' import { copilotInfo } from '$lib/aiStore' import FlowTutorials from '$lib/components/FlowTutorials.svelte' @@ -345,6 +347,80 @@ noteMode = !noteMode } + export function deleteMultiple(ids: string[]) { + const deletingSet = new Set(ids) + const allDeps: Record = {} + for (const id of ids) { + const deps = getDependentComponents(id, flowStore.val) + for (const [depId, exprs] of Object.entries(deps)) { + if (!deletingSet.has(depId)) { + allDeps[depId] = [...(allDeps[depId] ?? []), ...exprs] + } + } + } + + const cb = () => { + push(history, flowStore.val) + for (const id of ids) { + removeAtId(flowStore.val.value.modules, id) + delete flowStateStore.val[id] + } + selectionManager.clearSelection() + refreshStateStore(flowStore) + } + + if (Object.keys(allDeps).length > 0) { + dependents = allDeps + deleteCallback = cb + } else { + cb() + } + } + + export function duplicateMultiple(ids: string[]) { + const locations = locateModules(ids, flowStore.val.value.modules) + const groups = groupByParent(locations) + + push(history, flowStore.val) + + const allCloneIds: string[] = [] + + for (const group of groups) { + const sorted = [...group].sort((a, b) => a.index - b.index) + const parentArr = sorted[0].parentArray + const lastIndex = sorted[sorted.length - 1].index + + const clones: FlowModule[] = [] + for (const loc of sorted) { + const original = parentArr[loc.index] + const clone: FlowModule = $state.snapshot(original) + + clone.id = copyId(original.id, flowStateStore.val, flowStore.val) + flowStateStore.val[clone.id] = emptyFlowModuleState() + + dfs([clone], (mod) => { + if (mod.id !== clone.id) { + const newModId = nextId(flowStateStore.val, flowStore.val) + mod.id = newModId + flowStateStore.val[newModId] = emptyFlowModuleState() + } + }) + + clones.push(clone) + allCloneIds.push(clone.id) + } + + parentArr.splice(lastIndex + 1, 0, ...clones) + } + + refreshStateStore(flowStore) + selectionManager.selectByIds(allCloneIds) + } + + export function moveMultiple(ids: string[]) { + moveManager.toggleMovingMultiple(ids) + } + const dispatch = createEventDispatcher<{ generateStep: { moduleId: string; instructions: string; lang: ScriptLang } change: void @@ -533,18 +609,37 @@ if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) { await tick() if (moveManager.movingModuleId) { - // console.log('modules', modules, movingModules, movingModule) push(history, flowStore.val) - let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id) - - let [removedModule] = originalModules.splice(indexToRemove, 1) - // When moving within the same array, removal shifts subsequent indices down by 1 - let insertIndex = detail.index - if (originalModules === targetModules && indexToRemove < detail.index) { - insertIndex -= 1 + if (!originalModules || !targetModules) { + moveManager.clearMoving() + return + } + if (moveManager.movingIds && moveManager.movingIds.length > 1) { + // Multi-move: splice out all moving modules from their parent, insert at target + const firstIndex = originalModules.findIndex( + (m) => m.id === moveManager.movingIds?.[0] + ) + const removedModules = originalModules.splice( + firstIndex, + moveManager.movingIds.length + ) + let insertIndex = detail.index + if (originalModules === targetModules && firstIndex < detail.index) { + insertIndex -= moveManager.movingIds.length + } + targetModules.splice(insertIndex, 0, ...removedModules) + selectionManager.selectByIds(removedModules.map((m) => m.id)) + } else { + let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id) + let [removedModule] = originalModules.splice(indexToRemove, 1) + // When moving within the same array, removal shifts subsequent indices down by 1 + let insertIndex = detail.index + if (originalModules === targetModules && indexToRemove < detail.index) { + insertIndex -= 1 + } + targetModules.splice(insertIndex, 0, removedModule) + selectionManager.selectId(removedModule.id) } - targetModules.splice(insertIndex, 0, removedModule) - selectionManager.selectId(removedModule.id) moveManager.clearMoving() } else { if (detail.isPreprocessor) { @@ -678,6 +773,41 @@ onMove={(id) => { moveManager.toggleMoving(id) }} + onDuplicate={(id) => { + let targetModules: FlowModule[] | undefined + let targetIndex: number = -1 + + dfs(flowStore.val.value.modules, (mod, modules) => { + const idx = modules.findIndex((m) => m.id === id) + if (idx !== -1) { + targetModules = modules + targetIndex = idx + } + }) + + if (!targetModules || targetIndex === -1) return + + push(history, flowStore.val) + + const original = targetModules[targetIndex] + const clone: FlowModule = $state.snapshot(original) + + // Assign copy id to the clone, and fresh ids to nested modules + clone.id = copyId(original.id, flowStateStore.val, flowStore.val) + flowStateStore.val[clone.id] = emptyFlowModuleState() + + dfs([clone], (mod) => { + if (mod.id !== clone.id) { + const newModId = nextId(flowStateStore.val, flowStore.val) + mod.id = newModId + flowStateStore.val[newModId] = emptyFlowModuleState() + } + }) + + targetModules.splice(targetIndex + 1, 0, clone) + refreshStateStore(flowStore) + selectionManager.selectId(clone.id) + }} onUpdateMock={(detail) => { let module = findModuleById(detail.id) module.mock = $state.snapshot(detail.mock) @@ -696,6 +826,10 @@ } }} multiSelectEnabled + movingIds={moveManager.movingIds} + onDeleteMultiple={deleteMultiple} + onDuplicateMultiple={duplicateMultiple} + onMoveMultiple={moveMultiple} /> diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index 417ce58346..1a65452873 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -5,7 +5,7 @@ import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte' import FlowModuleIcon from '../FlowModuleIcon.svelte' import { prettyLanguage } from '$lib/common' - import { msToSec } from '$lib/utils' + import { msToSec, type Item } from '$lib/utils' import FlowJobsMenu from './FlowJobsMenu.svelte' import { isTriggerStep, @@ -47,6 +47,7 @@ flowJob?: Job | undefined isOwner?: boolean maximizeSubflow?: () => void + menuItems?: Item[] } let { @@ -67,7 +68,8 @@ onEditInput, flowJob, isOwner = false, - maximizeSubflow + maximizeSubflow, + menuItems = undefined }: Props = $props() const { selectionManager, moveManager } = getGraphContext() @@ -117,12 +119,11 @@ : '' : '' ) - {#if mod} - {#if moveManager?.movingModuleId == mod.id} + {#if moveManager?.movingModuleId == mod.id && !moveManager?.movingIds?.includes(mod.id)} dispatch('move')} size="xs" destructive> Cancel move @@ -171,6 +172,7 @@ deletable={insertable} {editMode} {moduleAction} + {menuItems} label={`${ mod.summary || (mod.value.type == 'forloopflow' ? 'For loop' : 'While loop') } ${mod.value.parallel ? '(parallel)' : ''} ${ @@ -205,6 +207,7 @@ deletable={insertable} {editMode} {moduleAction} + {menuItems} on:changeId on:delete on:move @@ -224,6 +227,7 @@ deletable={insertable} {editMode} {moduleAction} + {menuItems} on:changeId on:delete on:move @@ -243,6 +247,7 @@ {retries} {editMode} {moduleAction} + {menuItems} on:changeId on:pointerdown={handlePointerDown} on:delete @@ -256,7 +261,6 @@ deletable={insertable} id={mod.id} {...itemProps} - modType={mod.value.type} {nodeState} label={mod.summary || (mod.value.type === 'aiagent' ? 'AI Agent' : undefined) || diff --git a/frontend/src/lib/components/flows/map/VirtualItem.svelte b/frontend/src/lib/components/flows/map/VirtualItem.svelte index d3f67ccb2f..f7a08f1933 100644 --- a/frontend/src/lib/components/flows/map/VirtualItem.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItem.svelte @@ -10,6 +10,7 @@ import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte' import type { Job } from '$lib/gen' import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph' + import { getGraphContext } from '$lib/components/graph/graphContext' interface Props { label?: string | undefined @@ -69,6 +70,12 @@ flowHasChanged = false }: Props = $props() + const flowGraphContext = getGraphContext() + + let isMultiSelected = $derived( + (flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1 + ) + const outputPickerVisible = $derived( (nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode ) @@ -125,7 +132,7 @@ {/if} - {#if outputPickerVisible} + {#if outputPickerVisible && !isMultiSelected} - {#if outputPickerVisible} + {#if outputPickerVisible && !isMultiSelected} > { + const ancestors = new Map>() + + function walk(mods: FlowModule[], parentAncestors: Set) { + for (const mod of mods) { + ancestors.set(mod.id, parentAncestors) + const childAncestors = new Set([...parentAncestors, mod.id]) + + const val = mod.value + if (val.type === 'forloopflow' || val.type === 'whileloopflow') { + walk(val.modules, childAncestors) + } else if (val.type === 'branchall') { + for (const branch of val.branches) walk(branch.modules, childAncestors) + } else if (val.type === 'branchone') { + for (const branch of val.branches) walk(branch.modules, childAncestors) + walk(val.default, childAncestors) + } + } + } + + walk(modules, new Set()) + return ancestors +} + +/** + * Filter raw selected node IDs down to the minimal set of top-level module IDs: + * 1. Filter out virtual graph nodes (Input, Result, Trigger, -start, -end, -branch-*, subflow:*, preprocessor, failure) + * 2. Verify each ID exists as a real module in the flow module tree + * 3. Deduplicate nested: if a container (loop/branch) AND its children are both selected, keep only the container + */ +export function resolveSelectedModuleIds(rawIds: string[], modules: FlowModule[]): string[] { + // Step 1: Filter out virtual IDs + const candidateIds = rawIds.filter((id) => !isVirtualId(id)) + + // Step 2+3: Single DFS to build ancestor map (also verifies existence) + const ancestorMap = buildAncestorMap(modules) + const verifiedIds = candidateIds.filter((id) => ancestorMap.has(id)) + + // If any ancestor of this module is also selected, it's a nested child — drop it + const selectedSet = new Set(verifiedIds) + return verifiedIds.filter((id) => { + const ancestors = ancestorMap.get(id)! + for (const ancestor of ancestors) { + if (selectedSet.has(ancestor)) return false + } + return true + }) +} + +export type ModuleLocation = { + id: string + parentArray: FlowModule[] + index: number +} + +/** + * For each ID, find its parent array (reference) and index using DFS. + */ +export function locateModules(ids: string[], modules: FlowModule[]): ModuleLocation[] { + const idSet = new Set(ids) + const locations: ModuleLocation[] = [] + + dfs(modules, (mod, parentModules) => { + if (idSet.has(mod.id)) { + const index = parentModules.findIndex((m) => m.id === mod.id) + if (index !== -1) { + locations.push({ id: mod.id, parentArray: parentModules, index }) + } + } + }) + + return locations +} + +/** + * Group locations that share the same parent array, sorted by index within each group. + */ +export function groupByParent(locations: ModuleLocation[]): ModuleLocation[][] { + const groups = new Map() + for (const loc of locations) { + const existing = groups.get(loc.parentArray) + if (existing) { + existing.push(loc) + } else { + groups.set(loc.parentArray, [loc]) + } + } + // Sort each group by index + for (const group of groups.values()) { + group.sort((a, b) => a.index - b.index) + } + return Array.from(groups.values()) +} + +/** + * True if all locations share the same parent array and have consecutive indices. + * Required for move to be valid. + */ +export function areContiguousSiblings(locations: ModuleLocation[]): boolean { + if (locations.length === 0) return false + if (locations.length === 1) return true + + // All must share the same parent + const parent = locations[0].parentArray + if (!locations.every((loc) => loc.parentArray === parent)) return false + + // Sort by index and check contiguity + const sorted = [...locations].sort((a, b) => a.index - b.index) + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].index !== sorted[i - 1].index + 1) return false + } + return true +} diff --git a/frontend/src/lib/components/graph/DragCoordinator.svelte b/frontend/src/lib/components/graph/DragCoordinator.svelte index ed277da2f0..cba81e71d4 100644 --- a/frontend/src/lib/components/graph/DragCoordinator.svelte +++ b/frontend/src/lib/components/graph/DragCoordinator.svelte @@ -21,7 +21,15 @@ onMount(() => { moveManager.setScreenToFlowPosition(screenToFlowPosition) - moveManager.setComputeDraggedNodeIds((moduleId) => getSubflowNodeIds(moduleId, nodes, edges)) + moveManager.setComputeDraggedNodeIds((moduleIds) => { + const combined = new Set() + for (const id of moduleIds) { + for (const nodeId of getSubflowNodeIds(id, nodes, edges)) { + combined.add(nodeId) + } + } + return combined + }) }) $effect(() => { @@ -33,11 +41,17 @@ function onPointerUp(_e: PointerEvent) { const moduleId = moveManager.dragging?.moduleId + const selectedIds = moveManager.dragging?.selectedIds const zone = moveManager.endDrag() if (zone && moduleId) { - // Set movingModuleId directly (non-toggle) so the insert handler knows which module to relocate - moveManager.setMoving(moduleId) - // Then trigger the insert, which detects movingModuleId and performs the splice + // Set moving state so the insert handler knows which module(s) to relocate + if (selectedIds && selectedIds.length > 1) { + moveManager.movingModuleId = selectedIds[0] + moveManager.movingIds = selectedIds + } else { + moveManager.setMoving(moduleId) + } + // Then trigger the insert, which detects movingModuleId/movingIds and performs the splice eventHandlers.insert({ sourceId: zone.sourceId, targetId: zone.targetId, diff --git a/frontend/src/lib/components/graph/DragGhost.svelte b/frontend/src/lib/components/graph/DragGhost.svelte index 1ad0ca5a63..6d2e26e10e 100644 --- a/frontend/src/lib/components/graph/DragGhost.svelte +++ b/frontend/src/lib/components/graph/DragGhost.svelte @@ -46,8 +46,19 @@ return { x: n.position.x, y: n.position.y } } - function computeGhost(moduleId: string, allNodes: Node[], allEdges: Edge[]) { - const { sfNodes, sfEdges } = getSubflowNodesAndEdges(moduleId, allNodes, allEdges) + function computeGhost(moduleId: string, draggedNodeIds: Set, allNodes: Node[], allEdges: Edge[]) { + // Use pre-computed draggedNodeIds when available (covers multi-select), + // otherwise fall back to single-module subflow computation. + let sfNodes: Node[] + let sfEdges: Edge[] + if (draggedNodeIds.size > 0) { + sfNodes = allNodes.filter((n) => draggedNodeIds.has(n.id)) + sfEdges = allEdges.filter((e) => draggedNodeIds.has(e.source) && draggedNodeIds.has(e.target)) + } else { + const result = getSubflowNodesAndEdges(moduleId, allNodes, allEdges) + sfNodes = result.sfNodes + sfEdges = result.sfEdges + } if (sfNodes.length === 0) return undefined // Compute bounding box using absolute positions @@ -112,8 +123,7 @@ let ghost = $derived.by(() => { const moduleId = moveManager.dragging?.moduleId if (!moduleId) return undefined - // Compute ghost once at drag start — don't react to node/edge changes during drag - return untrack(() => computeGhost(moduleId, nodes, edges)) + return untrack(() => computeGhost(moduleId, moveManager.draggedNodeIds, nodes, edges)) }) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index cc316630f5..b2346a1b84 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -78,6 +78,11 @@ import { computeNoteNodes } from './noteUtils.svelte' import { Tooltip } from '../meltComponents' import { getNoteEditorContext } from './noteEditor.svelte' + import { + resolveSelectedModuleIds, + locateModules, + areContiguousSiblings + } from '../flows/multiSelectUtils' let useDataflow: Writable = writable(false) let showAssets: Writable = writable(true) @@ -131,6 +136,10 @@ notes?: FlowNote[] chatInputEnabled?: boolean multiSelectEnabled?: boolean + onDeleteMultiple?: (ids: string[]) => void + onDuplicateMultiple?: (ids: string[]) => void + onMoveMultiple?: (ids: string[]) => void + movingIds?: string[] onDelete?: (id: string) => void onInsert?: (detail: { sourceId?: string @@ -150,6 +159,7 @@ onDeleteBranch?: (detail: { id: string; index: number }) => Promise onChangeId?: (detail: { id: string; newId: string; deps: Record }) => void onMove?: (id: string) => void + onDuplicate?: (id: string) => void onUpdateMock?: (detail: { mock: FlowModule['mock']; id: string }) => void onTestUpTo?: ((id: string) => void) | undefined onSelectedIteration?: onSelectedIteration @@ -175,6 +185,7 @@ onInsert = undefined, onDelete = undefined, onMove = undefined, + onDuplicate = undefined, onDeleteBranch = undefined, onNewBranch = undefined, onSelect = undefined, @@ -231,7 +242,11 @@ diffBeforeFlow = undefined, currentInputSchema = undefined, markRemovedAsShadowed = false, - multiSelectEnabled = false + multiSelectEnabled = false, + onDeleteMultiple = undefined, + onDuplicateMultiple = undefined, + onMoveMultiple = undefined, + movingIds = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -428,6 +443,9 @@ move: (detail) => { onMove?.(detail.id) }, + duplicate: (detail) => { + onDuplicate?.(detail.id) + }, selectedIteration: (detail) => { onSelectedIteration?.(detail) }, @@ -508,6 +526,15 @@ let canUseDiffDrawer = $derived(diffBeforeFlow || moduleActions || editMode) + // Derived state for multi-select operations + let resolvedModuleIds = $derived( + resolveSelectedModuleIds(selectionManager.selectedIds, effectiveModules ?? []) + ) + let canMoveSelected = $derived( + resolvedModuleIds.length > 0 && + areContiguousSiblings(locateModules(resolvedModuleIds, effectiveModules ?? [])) + ) + // Initialize moduleTracker with effectiveModules let moduleTracker = $state(new ChangeTracker([])) @@ -566,6 +593,31 @@ exitNoteMode?.() } } + if ((event.key === 'Backspace' || event.key === 'Delete') && editMode) { + const active = document.activeElement + if (active && active !== document.body && !flowContainer?.contains(active)) { + return + } + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute('contenteditable') === 'true' + ) { + return + } + if (noteManager.selectedNoteId && noteEditorContext) { + noteEditorContext.noteEditor.deleteNote(noteManager.selectedNoteId) + noteManager.clearNoteSelection() + return + } + if (resolvedModuleIds.length > 1) { + onDeleteMultiple?.(resolvedModuleIds) + } else if (resolvedModuleIds.length === 1) { + onDelete?.(resolvedModuleIds[0]) + } else if (selectedId) { + onDelete?.(selectedId) + } + } } async function updateStores() { @@ -967,6 +1019,7 @@ elevateNodesOnSelect={false} {proOptions} multiSelectionKey={'Shift'} + deleteKey={null} nodesDraggable={false} --background-color={false} > @@ -978,8 +1031,17 @@ {#if multiSelectEnabled} + nodesWithOffset.some(n => n.id === id) + )} allNodes={nodesWithOffset as (Node & { type: string })[]} + onDeleteSelected={() => onDeleteMultiple?.(resolvedModuleIds)} + onDuplicateSelected={() => onDuplicateMultiple?.(resolvedModuleIds)} + onMoveSelected={() => onMoveMultiple?.(resolvedModuleIds)} + onCancelMove={() => onMoveMultiple?.(movingIds ?? [])} + {canMoveSelected} + isMoving={movingIds != null && movingIds.length > 0} + {resolvedModuleIds} /> {/if} @@ -1094,4 +1156,8 @@ display: none; pointer-events: none; } + + :global(.svelte-flow__selection-wrapper) { + pointer-events: none !important; + } diff --git a/frontend/src/lib/components/graph/MoveHandleButton.svelte b/frontend/src/lib/components/graph/MoveHandleButton.svelte new file mode 100644 index 0000000000..d077f0d03d --- /dev/null +++ b/frontend/src/lib/components/graph/MoveHandleButton.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte index 5faff92d28..e1248b52ef 100644 --- a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte +++ b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte @@ -1,23 +1,46 @@ -{#if bounds() && selectedNodes.length > 1} - {@const currentBounds = bounds()!} +{#if bounds && selectedNodes.length > 1} + {@const currentBounds = bounds!} - - {#if noteEditorContext?.noteEditor} - - + {#if isMoving} + onCancelMove?.()} size="xs" destructive + >Cancel move - Create group note ({selectedNodes.length} nodes) - - - {/if} + {:else if resolvedCount > 0} + {#if canMoveSelected && moveManager && resolvedModuleIds.length > 0} + + onMoveSelected?.()} + /> + + {/if} + + {#snippet buttonReplacement()} + + + + {/snippet} + + {/if} + {/if} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index e1714f6d33..9b578030e2 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -56,6 +56,7 @@ export type GraphEventHandlers = { delete: (detail: { id: string }, label: string) => void newBranch: (id: string) => void move: (detail: { id: string }) => void + duplicate: (detail: { id: string }) => void selectedIteration: onSelectedIteration changeId: (newId: string) => void simplifyFlow: (b: boolean) => void diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts index 7f13c44171..605c4b849a 100644 --- a/frontend/src/lib/components/graph/moveManager.svelte.ts +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -22,6 +22,7 @@ export type DropZoneRegistration = { type DragInfo = { moduleId: string + selectedIds?: string[] } /** @@ -54,22 +55,13 @@ export function getSubflowNodeIds( // Include child nodes (e.g. asset/AI tool nodes) of nodes added via edges. // Nodes found through disableMoveIds (like inner module "b") may have children // ("b-asset-in-...") that weren't caught by the initial prefix match on moduleId. - // Only scan children of edge-added nodes that aren't already covered by the - // moduleId prefix (those children were already captured in the first pass). - const newFromEdges: string[] = [] - for (const id of nodeIds) { - if (id !== moduleId && !id.startsWith(nodeIdPrefix)) { - newFromEdges.push(id) - } - } - if (newFromEdges.length > 0) { - for (const n of allNodes) { - if (!nodeIds.has(n.id)) { - for (const id of newFromEdges) { - if (n.id.startsWith(id + '-')) { - nodeIds.add(n.id) - break - } + const edgeMatchedIds = [...nodeIds] + for (const n of allNodes) { + if (!nodeIds.has(n.id)) { + for (const id of edgeMatchedIds) { + if (n.id.startsWith(id + '-')) { + nodeIds.add(n.id) + break } } } @@ -88,35 +80,57 @@ export class MoveManager { /** The module ID currently being moved via legacy click-to-move */ movingModuleId = $state(undefined) + /** Multiple module IDs being moved together (multi-select move) */ + movingIds = $state(undefined) + toggleMoving(id: string) { if (this.movingModuleId === id) { this.movingModuleId = undefined this.#updateDraggedNodeIds(undefined) } else { this.movingModuleId = id - this.#updateDraggedNodeIds(id) + this.#updateDraggedNodeIds([id]) + } + } + + toggleMovingMultiple(ids: string[]) { + if ( + this.movingIds && + this.movingIds.length === ids.length && + this.movingIds.every((id, i) => id === ids[i]) + ) { + this.movingModuleId = undefined + this.movingIds = undefined + this.#updateDraggedNodeIds(undefined) + } else { + this.movingModuleId = ids[0] + this.movingIds = ids + this.#updateDraggedNodeIds(ids) } } setMoving(id: string) { this.movingModuleId = id - this.#updateDraggedNodeIds(id) + this.#updateDraggedNodeIds([id]) } clearMoving() { this.movingModuleId = undefined + this.movingIds = undefined this.#updateDraggedNodeIds(undefined) } - #computeDraggedNodeIds: ((moduleId: string) => Set) | undefined + #computeDraggedNodeIds: ((moduleIds: string[]) => Set) | undefined - setComputeDraggedNodeIds(fn: (moduleId: string) => Set) { + setComputeDraggedNodeIds(fn: (moduleIds: string[]) => Set) { this.#computeDraggedNodeIds = fn } - #updateDraggedNodeIds(moduleId: string | undefined) { + #updateDraggedNodeIds(moduleIds: string[] | undefined) { this.draggedNodeIds = - moduleId && this.#computeDraggedNodeIds ? this.#computeDraggedNodeIds(moduleId) : new Set() + moduleIds && moduleIds.length > 0 && this.#computeDraggedNodeIds + ? this.#computeDraggedNodeIds(moduleIds) + : new Set() } #screenToFlowPosition: ((pos: { x: number; y: number }) => { x: number; y: number }) | undefined @@ -134,14 +148,19 @@ export class MoveManager { this.#registeredDropZones.delete(edgeId) } - startDrag(moduleId: string, screenX: number, screenY: number) { + startDrag(moduleId: string, screenX: number, screenY: number, selectedIds?: string[]) { // Clear any active click-to-move so only drag mode is active this.movingModuleId = undefined - this.dragging = { moduleId } + this.dragging = { moduleId, selectedIds } this.ghostScreenX = screenX this.ghostScreenY = screenY this.nearestDropZone = undefined - this.#updateDraggedNodeIds(moduleId) + // Compute dragged node IDs for the primary module plus any additional selected modules + const allIds = + selectedIds && selectedIds.length > 0 + ? [moduleId, ...selectedIds.filter((id) => id !== moduleId)] + : [moduleId] + this.#updateDraggedNodeIds(allIds) } updateDrag(screenX: number, screenY: number) { diff --git a/frontend/src/lib/components/graph/noteManager.svelte.ts b/frontend/src/lib/components/graph/noteManager.svelte.ts index 6e605327a8..3ce16495a8 100644 --- a/frontend/src/lib/components/graph/noteManager.svelte.ts +++ b/frontend/src/lib/components/graph/noteManager.svelte.ts @@ -133,6 +133,13 @@ export class NoteManager { } } + /** + * Get the currently selected note ID + */ + get selectedNoteId(): string | undefined { + return this.#selectedNoteId + } + /** * Check if a note is currently selected */ diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index de74cd22cd..8f1ca0017f 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -213,7 +213,7 @@ {#if moveManager?.movingModuleId && data?.insertable} - {#if !data.disableMoveIds?.includes(moveManager.movingModuleId)} + {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))} { diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 27cb72457c..685f8e6874 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -1,12 +1,11 @@ - + {#snippet children({ darkMode })} @@ -22,10 +24,22 @@ offset = 0, wrapperClass = '', contextMenuItems = undefined, + menuItems = undefined, nodeId = undefined, children }: Props = $props() + let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived( + contextMenuItems ?? + menuItems?.map((item) => ({ + id: item.displayName, + label: item.displayName, + icon: item.icon, + disabled: item.disabled, + onClick: item.action as (() => void) | undefined + })) + ) + const { moveManager } = getGraphContext() let faded = $derived( @@ -37,8 +51,8 @@ -{#if contextMenuItems && contextMenuItems.length > 0} - +{#if resolvedContextMenuItems && resolvedContextMenuItems.length > 0} + {@render children?.({ darkMode })} diff --git a/frontend/src/lib/components/graph/selectionUtils.svelte.ts b/frontend/src/lib/components/graph/selectionUtils.svelte.ts index 8abc7fa563..daa9de3ecf 100644 --- a/frontend/src/lib/components/graph/selectionUtils.svelte.ts +++ b/frontend/src/lib/components/graph/selectionUtils.svelte.ts @@ -78,13 +78,24 @@ export class SelectionManager { } // If the new selection is the same as the current selection, do nothing - if (JSON.stringify(nodes) === JSON.stringify($state.snapshot(this.#selectedNodes))) { + const newIds = nodes.map((n) => n.id).join(',') + const currentIds = this.#selectedNodes.map((n) => n.id).join(',') + if (newIds === currentIds) { return } this.#selectedNodes = nodes } + // Select multiple nodes by their IDs + selectByIds(ids: string[]) { + if (!ids || ids.length === 0) { + this.clearSelection() + return + } + this.#selectedNodes = ids.map((id) => ({ id })) + } + // Clear all selections clearSelection() { this.#selectedNodes = [{ id: 'settings' }] diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0847fc35cb..496dba427b 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -17,6 +17,7 @@ export { sendUserToast } import type { AnyMeltElement } from '@melt-ui/svelte' import type { TriggerKind } from './components/triggers' import { stateSnapshot } from './svelte5Utils.svelte' + export namespace OpenApi { export enum OpenApiVersion { V2, @@ -1495,6 +1496,7 @@ export type Item = { tooltip?: string separatorTop?: boolean submenuItems?: Item[] + shortcut?: string } export function isObjectTooBig(obj: any): boolean { From 7fe1594d22e60965744a7f999cc8d6d62523cb9a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:47:36 +0000 Subject: [PATCH 13/58] add data tables comment to scheduled poll templates (#8221) Add a comment to each scheduled poll template (Python, Deno, Bun, Go) mentioning that data tables can be used for more complex states, with a link to the documentation. Closes #8220 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] --- frontend/src/lib/script_helpers.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index f8fc95c8f3..6485970f27 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -30,6 +30,9 @@ def main(): # wmill.setState(newState) # 4. Return the new rows # return range from (state to newState) + # + # For more complex states, consider using Data Tables: + # https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables return [1, 2, 3]` const PYTHON_INIT_CODE = `import os @@ -554,6 +557,9 @@ export async function main() { // await wmill.setState(newState) // 4. Return the new rows // return range from (state to newState) + // + // For more complex states, consider using Data Tables: + // https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables return [1,2,3] @@ -575,6 +581,9 @@ export async function main() { // await wmill.setState(newState) // 4. Return the new rows // return range from (state to newState) + // + // For more complex states, consider using Data Tables: + // https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables return [1,2,3] @@ -599,6 +608,9 @@ func main() (interface{}, error) { // 3. Compare the two states and update the internal state wmill.SetState(4) // 4. Return the new rows + // + // For more complex states, consider using Data Tables: + // https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables return state, nil From baf2bcf14da0c8c95bdbbf511fcaee48be33948b Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:50:59 +0100 Subject: [PATCH 14/58] feat: make WM_END_USER_EMAIL display users from different workspaces (#8208) Signed-off-by: pyranota --- ...b7c6841a5c4ff12ba7c12c73d691c49dd99ed.json | 22 ++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- backend/tests/end_user_email.rs | 323 ++++++++++++++++++ backend/tests/fixtures/end_user_email.sql | 63 ++++ backend/windmill-api-auth/src/auth.rs | 38 +++ backend/windmill-api-auth/src/lib.rs | 4 +- backend/windmill-api/src/apps.rs | 4 +- backend/windmill-api/src/auth.rs | 5 +- 8 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 backend/.sqlx/query-19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed.json create mode 100644 backend/tests/end_user_email.rs create mode 100644 backend/tests/fixtures/end_user_email.sql diff --git a/backend/.sqlx/query-19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed.json b/backend/.sqlx/query-19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed.json new file mode 100644 index 0000000000..18ad13d90f --- /dev/null +++ b/backend/.sqlx/query-19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/tests/end_user_email.rs b/backend/tests/end_user_email.rs new file mode 100644 index 0000000000..1a60f8672c --- /dev/null +++ b/backend/tests/end_user_email.rs @@ -0,0 +1,323 @@ +//! Tests for WM_END_USER_EMAIL environment variable. +//! +//! These tests verify that WM_END_USER_EMAIL is populated with the authenticated +//! user's email when executing app components. +//! +//! TODO: Add tests for scripts and flows once public execution endpoints are identified. +//! Currently only apps support non-workspace-member execution via OptAuthed + token lookup. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::worker::Connection; +use windmill_test_utils::*; + +const SAME_WS_TOKEN: &str = "SECRET_TOKEN"; +const OTHER_WS_TOKEN: &str = "OTHER_WS_TOKEN"; +const NO_WS_TOKEN: &str = "NO_WS_TOKEN"; + +const SAME_WS_EMAIL: &str = "test@windmill.dev"; +const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev"; +const NO_WS_EMAIL: &str = "no-ws@windmill.dev"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +// TODO: Script tests - need to identify public execution endpoints for non-workspace-members +// async fn run_script(port: u16, token: &str) -> anyhow::Result { +// let url = format!( +// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/p/f/test/get_end_user_email", +// port +// ); +// let resp = authed(client().post(&url), token) +// .json(&json!({})) +// .send() +// .await?; +// if !resp.status().is_success() { +// anyhow::bail!("script run failed: {} - {}", resp.status(), resp.text().await?); +// } +// Ok(resp.json::().await? +// .as_str().unwrap_or("").to_string()) +// } + +// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members +// async fn run_flow(port: u16, token: &str) -> anyhow::Result { +// let url = format!( +// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/f/f/test/get_end_user_email_flow", +// port +// ); +// let resp = authed(client().post(&url), token) +// .json(&json!({})) +// .send() +// .await?; +// if !resp.status().is_success() { +// anyhow::bail!("flow run failed: {} - {}", resp.status(), resp.text().await?); +// } +// Ok(resp.json::().await? +// .as_str().unwrap_or("").to_string()) +// } + +/// Create an app with inline script via API +async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps/create", + port + ); + let resp = authed(client().post(&url), SAME_WS_TOKEN) + .json(&json!({ + "path": path, + "summary": "Test app for WM_END_USER_EMAIL", + "value": { + "type": "app", + "grid": [], + "subgrids": {}, + "hiddenInlineScripts": [{ + "name": "get_email", + "language": "deno", + "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", + "path": "f/test/email_app/get_email" + }] + }, + "policy": { + "execution_mode": "anonymous", + "on_behalf_of": null, + "on_behalf_of_email": null, + "triggerables_v2": { + "get_email": { + "static_inputs": {}, + "one_of_inputs": {} + }, + // SHA256 hash of raw_code content for anonymous execution + "rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": { + "static_inputs": {}, + "one_of_inputs": {} + } + } + } + })) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("create app failed: {} - {}", resp.status(), resp.text().await?); + } + Ok(()) +} + +/// Create a raw app with inline script via API (uses regular app endpoint with rawapp type) +async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps/create", + port + ); + let resp = authed(client().post(&url), SAME_WS_TOKEN) + .json(&json!({ + "path": path, + "summary": "Test raw app for WM_END_USER_EMAIL", + "value": { + "type": "rawapp", + "css": "", + "inlineScripts": [{ + "name": "get_email", + "language": "deno", + "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }" + }] + }, + "policy": { + "execution_mode": "anonymous", + "on_behalf_of": null, + "on_behalf_of_email": null, + "triggerables_v2": { + "get_email": { + "static_inputs": {}, + "one_of_inputs": {} + }, + // SHA256 hash of raw_code content for anonymous execution + "rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": { + "static_inputs": {}, + "one_of_inputs": {} + } + } + } + })) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("create raw app failed: {} - {}", resp.status(), resp.text().await?); + } + Ok(()) +} + +async fn run_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}", + port, app_path + ); + let mut payload = json!({ + "args": {}, + "component": "get_email", + "raw_code": { + "language": "deno", + "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", + "path": format!("{}/get_email", app_path) + } + }); + if force_viewer { + payload["force_viewer_static_fields"] = json!({}); + } + let resp = authed(client().post(&url), token) + .json(&payload) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("app inline script run failed: {} - {}", resp.status(), resp.text().await?); + } + let job_id = resp.text().await?; + wait_for_job_result(port, token, &job_id).await +} + +async fn run_raw_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}", + port, app_path + ); + let mut payload = json!({ + "args": {}, + "component": "get_email", + "raw_code": { + "language": "deno", + "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }" + } + }); + if force_viewer { + payload["force_viewer_static_fields"] = json!({}); + } + let resp = authed(client().post(&url), token) + .json(&payload) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("raw app inline script run failed: {} - {}", resp.status(), resp.text().await?); + } + let job_id = resp.text().await?; + wait_for_job_result(port, token, &job_id).await +} + +async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}", + port, job_id + ); + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let resp = authed(client().get(&url), token).send().await?; + if resp.status().is_success() { + return Ok(resp.json::().await? + .as_str().unwrap_or("").to_string()); + } + } + anyhow::bail!("timeout waiting for job result") +} + +// TODO: Script tests - need to identify public execution endpoints for non-workspace-members +// #[cfg(feature = "deno_core")] +// #[sqlx::test(fixtures("base", "end_user_email"))] +// async fn test_script_wm_end_user_email(db: Pool) -> anyhow::Result<()> { +// initialize_tracing().await; +// set_jwt_secret().await; +// let server = ApiServer::start(db.clone()).await?; +// let port = server.addr.port(); +// +// in_test_worker(Connection::Sql(db.clone()), async move { +// let result = run_script(port, SAME_WS_TOKEN).await?; +// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); +// Ok::<(), anyhow::Error>(()) +// }, port).await?; +// +// Ok(()) +// } + +// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members +// #[cfg(feature = "deno_core")] +// #[sqlx::test(fixtures("base", "end_user_email"))] +// async fn test_flow_wm_end_user_email(db: Pool) -> anyhow::Result<()> { +// initialize_tracing().await; +// set_jwt_secret().await; +// let server = ApiServer::start(db.clone()).await?; +// let port = server.addr.port(); +// +// in_test_worker(Connection::Sql(db.clone()), async move { +// let result = run_flow(port, SAME_WS_TOKEN).await?; +// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); +// Ok::<(), anyhow::Error>(()) +// }, port).await?; +// +// Ok(()) +// } + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_app_wm_end_user_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let app_path = "f/test/email_app"; + + in_test_worker(Connection::Sql(db.clone()), async move { + // Create the app with inline script first + create_app_with_inline_script(port, app_path).await?; + + // Same workspace user (force_viewer mode works for workspace members) + let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); + + // Other workspace user (uses app's anonymous policy + token lookup) + let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email"); + + // No workspace user (uses app's anonymous policy + token lookup) + let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email"); + + Ok::<(), anyhow::Error>(()) + }, port).await?; + + Ok(()) +} + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_raw_app_wm_end_user_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let app_path = "f/test/email_raw_app"; + + in_test_worker(Connection::Sql(db.clone()), async move { + // Create the raw app with inline script first + create_raw_app_with_inline_script(port, app_path).await?; + + // Same workspace user (force_viewer mode works for workspace members) + let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); + + // Other workspace user (uses app's anonymous policy + token lookup) + let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email"); + + // No workspace user (uses app's anonymous policy + token lookup) + let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email"); + + Ok::<(), anyhow::Error>(()) + }, port).await?; + + Ok(()) +} diff --git a/backend/tests/fixtures/end_user_email.sql b/backend/tests/fixtures/end_user_email.sql new file mode 100644 index 0000000000..654ad93680 --- /dev/null +++ b/backend/tests/fixtures/end_user_email.sql @@ -0,0 +1,63 @@ +-- Fixture for WM_END_USER_EMAIL tests +-- Sets up 3 users with different workspace memberships: +-- 1. test@windmill.dev - in test-workspace (from base.sql) +-- 2. other-ws@windmill.dev - in other-workspace only +-- 3. no-ws@windmill.dev - not in any workspace + +-- Second workspace for cross-workspace user +INSERT INTO workspace (id, name, owner) +VALUES ('other-workspace', 'other-workspace', 'other-ws-user'); + +INSERT INTO workspace_key(workspace_id, kind, key) +VALUES ('other-workspace', 'cloud', 'other-key'); + +INSERT INTO workspace_settings (workspace_id) +VALUES ('other-workspace'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) +VALUES ('other-workspace', 'all', 'All users', '{}'); + +-- User in other-workspace only (not in test-workspace) +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) +VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) +VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin'); + +INSERT INTO token(token, email, label, super_admin) +VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); + +-- User not in any workspace +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) +VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User'); + +INSERT INTO token(token, email, label, super_admin) +VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); + +-- Script that returns WM_END_USER_EMAIL (public via extra_perms) +INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms) +VALUES ( + 'test-workspace', 'test-user', + 'export function main() { return Deno.env.get("WM_END_USER_EMAIL") || ""; }', + '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', + 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'deno', '', 'script', + '{"g/all": true}' +); + +-- Flow that returns WM_END_USER_EMAIL (public via extra_perms) +INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by, extra_perms) +VALUES ( + 'test-workspace', 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email_flow', '{900002}', + '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', + '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}', + 'test-user', + '{"g/all": true}' +); + +INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by) +VALUES ( + 900002, 'test-workspace', 'f/test/get_end_user_email_flow', + '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', + '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}', + 'test-user' +); diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 186f7af257..af212dc315 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -35,7 +35,45 @@ use windmill_common::{ lazy_static::lazy_static! { // Global auth cache accessible from main.rs for direct invalidation pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300); + // Cache for token -> email lookups (for non-workspace-member authenticated users) + static ref TOKEN_EMAIL_CACHE: Cache> = Cache::new(500); +} +/// Get email from a valid token, with caching. +/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member. +async fn get_email_from_token(db: &DB, token: &str) -> Option { + if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) { + return cached; + } + + let email = sqlx::query_scalar!( + "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", + token + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten(); // email column is nullable, so we get Option> + + TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone()); + email +} + +/// Get end user email from authenticated user or token. +/// Returns email if user is authenticated (workspace member) or has valid instance token. +pub async fn get_end_user_email( + db: &DB, + opt_authed: Option<&ApiAuthed>, + token: Option<&str>, +) -> Option { + if let Some(authed) = opt_authed { + return Some(authed.email.clone()); + } + if let Some(token) = token { + return get_email_from_token(db, token).await; + } + None } // Global function to invalidate a specific token from cache pub fn invalidate_token_from_cache(token: &str) { diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index efab57cf91..5acb696bd4 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -29,8 +29,8 @@ use scopes::ScopeDefinition; // Re-export key auth types and functions pub use auth::{ - invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, Tokened, - TruncatedTokenWithEmail, AUTH_CACHE, + get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, + Tokened, TruncatedTokenWithEmail, AUTH_CACHE, }; // ------------ ApiAuthed & OptJobAuthed types ------------ diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index d156dc9c88..1cfbfa6e19 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -8,7 +8,7 @@ use std::{collections::HashMap, sync::Arc}; * LICENSE-AGPL for a copy of the license. */ use crate::{ - auth::OptTokened, + auth::{get_end_user_email, OptTokened}, db::{ApiAuthed, DB}, jobs::RunJobQuery, users::{require_owner_of_path, OptAuthed}, @@ -2149,7 +2149,7 @@ async fn execute_component( (email.as_str(), permissioned_as) }; - let end_user_email = opt_authed.as_ref().map(|a| a.email.clone()); + let end_user_email = get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await; let (uuid, mut tx) = push( &db, diff --git a/backend/windmill-api/src/auth.rs b/backend/windmill-api/src/auth.rs index 144703ba5c..66a67c5f97 100644 --- a/backend/windmill-api/src/auth.rs +++ b/backend/windmill-api/src/auth.rs @@ -1,4 +1,5 @@ pub use windmill_api_auth::auth::{ - invalidate_token_from_cache, list_tokens_internal, transform_old_scope_to_new_scope, AuthCache, - ExpiringAuthCache, OptTokened, Tokened, TruncatedTokenWithEmail, + get_end_user_email, invalidate_token_from_cache, list_tokens_internal, + transform_old_scope_to_new_scope, AuthCache, ExpiringAuthCache, OptTokened, Tokened, + TruncatedTokenWithEmail, }; From c9c3baecb344a3d5abddb01d3fc21aa7fd3ecd62 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 4 Mar 2026 12:48:02 +0000 Subject: [PATCH 15/58] add context menu with delete option to preprocessor nodes (#8223) * fix: add context menu with delete option to preprocessor nodes Co-Authored-By: Claude Opus 4.5 * feat: add delete styling and shortcuts to right-click context menu Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../common/contextmenu/ContextMenu.svelte | 14 +++++++--- .../common/contextmenu/contextMenuStyles.ts | 6 +++++ .../flows/map/FlowModuleSchemaItem.svelte | 2 +- .../graph/renderers/nodes/ModuleNode.svelte | 26 ++++++++++++------- .../graph/renderers/nodes/NodeWrapper.svelte | 19 +++++++++----- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte index 86a2dc7ccb..ccab6ef3f0 100644 --- a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte +++ b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte @@ -8,6 +8,7 @@ getContextMenuContainerClass, CONTEXT_MENU_ITEM_BASE_CLASS, CONTEXT_MENU_ITEM_HOVER_MELT_CLASS, + CONTEXT_MENU_ITEM_DELETE_CLASS, CONTEXT_MENU_ITEM_DISABLED_CLASS, CONTEXT_MENU_DIVIDER_CLASS, CONTEXT_MENU_ANIMATION_CLASSES @@ -20,6 +21,8 @@ disabled?: boolean onClick?: () => void divider?: boolean + type?: 'action' | 'delete' + shortcut?: string } interface Props { @@ -111,18 +114,23 @@ CONTEXT_MENU_ITEM_BASE_CLASS, menuItem.disabled ? CONTEXT_MENU_ITEM_DISABLED_CLASS - : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS + : menuItem.type === 'delete' + ? CONTEXT_MENU_ITEM_DELETE_CLASS + : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS )} use:melt={$item} onclick={() => handleItemClick(menuItem)} > {#if menuItem.icon} - + {/if} {#if menu} {@render menu({ item: menuItem })} {:else} - {menuItem.label} + {menuItem.label} + {/if} + {#if menuItem.shortcut} + {menuItem.shortcut} {/if} {/if} diff --git a/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts b/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts index ecbe6c6f87..16844dc3d9 100644 --- a/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts +++ b/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts @@ -27,6 +27,12 @@ export const CONTEXT_MENU_ITEM_HOVER_CLASS = 'hover:bg-surface-hover' */ export const CONTEXT_MENU_ITEM_HOVER_MELT_CLASS = 'data-[highlighted]:bg-surface-hover' +/** + * Delete action styles for context menu items + */ +export const CONTEXT_MENU_ITEM_DELETE_CLASS = + 'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300' + /** * Disabled state styles for context menu items */ diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 0a0fe310f0..28c66c05ca 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -535,7 +535,7 @@ {/if} - {#if !isMultiSelected && id !== 'preprocessor' && menuItems && menuItems.length > 0} + {#if !isMultiSelected && menuItems && menuItems.length > 0} data.eventHandlers.move({ id: data.id }) - }, - { - displayName: 'Duplicate', - icon: Copy, - action: () => data.eventHandlers.duplicate({ id: data.id }) - }, + ...(isPreprocessor + ? [] + : [ + { + displayName: 'Move', + icon: Move, + action: () => data.eventHandlers.move({ id: data.id }) + }, + { + displayName: 'Duplicate', + icon: Copy, + action: () => data.eventHandlers.duplicate({ id: data.id }) + } + ]), { displayName: 'Delete', icon: Trash2, diff --git a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte index 46bcfb6ee9..329dafcdda 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte @@ -31,13 +31,18 @@ let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived( contextMenuItems ?? - menuItems?.map((item) => ({ - id: item.displayName, - label: item.displayName, - icon: item.icon, - disabled: item.disabled, - onClick: item.action as (() => void) | undefined - })) + menuItems?.flatMap((item) => [ + ...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []), + { + id: item.displayName, + label: item.displayName, + icon: item.icon, + disabled: item.disabled, + type: item.type, + shortcut: item.shortcut, + onClick: item.action as (() => void) | undefined + } + ]) ) const { moveManager } = getGraphContext() From 8a859ff7b9f051d4136d71baa94dcb4f7f3b2087 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 4 Mar 2026 13:29:51 +0000 Subject: [PATCH 16/58] add full-code app import with tabbed YAML/JSON format selection (#8224) Combine YAML/JSON import into tabs within a single drawer (YAML default) and add full-code app import option. Uses sessionStorage to persist import data across the full page reload required by cross-origin isolation headers when navigating to /apps_raw/add. Co-authored-by: Claude Opus 4.6 --- .../components/flows/CreateActionsApp.svelte | 59 +++++++++++++------ .../(root)/(logged)/apps_raw/add/+page.svelte | 12 +++- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 38332bb498..5cc0df1502 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -5,6 +5,8 @@ import { Button } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' + import Tabs from '$lib/components/common/tabs/Tabs.svelte' + import Tab from '$lib/components/common/tabs/Tab.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import { LayoutDashboard, Loader2, Plus, Code2 } from 'lucide-svelte' import { importStore } from '../apps/store' @@ -14,12 +16,21 @@ let pendingRaw: string = $state('') let importType: 'yaml' | 'json' = $state('yaml') + let appKind: 'lowcode' | 'fullcode' = $state('lowcode') let appTypeModalOpen = $state(false) async function importRaw() { - $importStore = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw) - await goto('/apps/add?nodraft=true') + const parsed = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw) + if (appKind === 'fullcode') { + // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), + // so the in-memory importStore would be lost. Use sessionStorage instead. + sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) + await goto('/apps_raw/add?nodraft=true') + } else { + $importStore = parsed + await goto('/apps/add?nodraft=true') + } drawer?.closeDrawer?.() } @@ -51,17 +62,19 @@ variant="accent" dropdownItems={[ { - label: 'Import low-code app from YAML', + label: 'Import low-code app', onClick: () => { - drawer?.toggleDrawer?.() + appKind = 'lowcode' importType = 'yaml' + drawer?.toggleDrawer?.() } }, { - label: 'Import low-code app from JSON', + label: 'Import full-code app', onClick: () => { + appKind = 'fullcode' + importType = 'yaml' drawer?.toggleDrawer?.() - importType = 'json' } } ]} @@ -118,22 +131,32 @@ - + drawer?.toggleDrawer?.()} > - {#await import('$lib/components/SimpleEditor.svelte')} - - {:then Module} - - {/await} + + + + {#snippet content()} + + {#key importType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} + + {/snippet} + {#snippet actions()} Import {/snippet} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 6181204e3b..2bb6671a23 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -41,10 +41,18 @@ const templateId = $page.url.searchParams.get('template_id') const hubId = $page.url.searchParams.get('hub') - const importRaw = $importStore + // Check in-memory store first, then sessionStorage (used when full page reload occurs) + let importRaw = $importStore if ($importStore) { $importStore = undefined } + if (!importRaw) { + const sessionData = sessionStorage.getItem('rawAppImport') + if (sessionData) { + sessionStorage.removeItem('rawAppImport') + importRaw = JSON.parse(sessionData) + } + } const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp') @@ -189,7 +197,7 @@ files: svelte5Template } ] - let templatePicker = $state(nodraft != null) + let templatePicker = $state(nodraft != null && !importRaw) let reloadCounter = $state(0) // Modal state From 164e499c64dc5eb76fcfb0f8cefbad2df244f610 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 4 Mar 2026 15:20:50 +0100 Subject: [PATCH 17/58] feat: add variable and resource types to flow env variables (#8214) * feat: add variable and resource types to flow env variables Flow env variables can now reference workspace variables ($var:path) and resources ($res:path) that are resolved at runtime. Adds Variable and Resource type options to the flow env editor with ItemPicker and ResourcePicker components, and resolves references in both the flow worker (via transform_json) and the API fallback endpoint. Co-Authored-By: Claude Opus 4.6 * fix(frontend): use inline DollarSign icon for variable picker Replace the separate "Pick" button with the standard inline DollarSign icon overlay that appears on hover, matching the existing ArgInput pattern. Also add the icon to the string type input for quick variable linking from any string field. Co-Authored-By: Claude Opus 4.6 * refactor: simplify flow env var resolution and json_path handling in API Co-Authored-By: Claude Opus 4.5 * fix(frontend): always show flow env variables in property picker Co-Authored-By: Claude Opus 4.5 * fix: update flow_env openapi type to allow any JSON value Co-Authored-By: Claude Opus 4.5 * refactor(frontend): remove redundant variable type from env var dropdown Co-Authored-By: Claude Opus 4.5 * fix(frontend): use Label component and fix alert text in flow env vars editor Co-Authored-By: Claude Opus 4.5 * fix(frontend): avoid redundant stringify/parse roundtrip in env type switch Co-Authored-By: Claude Opus 4.5 * fix: address PR review comments for flow env vars - Deduplicate db_authed in jobs.rs $var/$res resolution - Add warn logging on variable/resource resolution failures - Consolidate $effect blocks and remove auto-type-correction effect - Make linked variable text a clickable link to variable editor - Add hash-based variable editor opening on variables page Co-Authored-By: Claude Opus 4.6 * perf: avoid cloning entire FlowValue to resolve flow_env references Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...86d7c4b9bdb1e8fb1f7725060990ef8984943.json | 24 +++ ...689e2c0100c1569436a01b207876aaa470154.json | 25 --- backend/windmill-api/openapi-deref.yaml | 10 +- backend/windmill-api/src/jobs.rs | 91 ++++++++- backend/windmill-worker/src/worker_flow.rs | 59 ++++-- .../content/FlowEnvironmentVariables.svelte | 177 ++++++++++++++---- .../flows/propPicker/PropPickerWrapper.svelte | 6 +- .../propertyPicker/PropPicker.svelte | 69 ++----- .../(root)/(logged)/variables/+page.svelte | 11 +- openflow.openapi.yaml | 5 +- 10 files changed, 323 insertions(+), 154 deletions(-) create mode 100644 backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json delete mode 100644 backend/.sqlx/query-c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154.json diff --git a/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json b/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json new file mode 100644 index 0000000000..8c5f43ab07 --- /dev/null +++ b/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flow_env: sqlx::types::Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943" +} diff --git a/backend/.sqlx/query-c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154.json b/backend/.sqlx/query-c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154.json deleted file mode 100644 index be352ce88e..0000000000 --- a/backend/.sqlx/query-c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "flow_env: sqlx::types::Json>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "TextArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154" -} diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 1a036f6dc9..7f04a7287e 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -8857,9 +8857,8 @@ paths: type: boolean flow_env: type: object - description: Environment variables available to all steps - additionalProperties: - type: string + description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)." + additionalProperties: {} priority: type: number description: Execution priority (higher numbers run first) @@ -14644,9 +14643,8 @@ paths: type: boolean flow_env: type: object - description: Environment variables available to all steps - additionalProperties: - type: string + description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)." + additionalProperties: {} priority: type: number description: Execution priority (higher numbers run first) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index d58dcd3c69..ac5a9a306b 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -448,14 +448,15 @@ async fn get_flow_env_by_flow_job_id( Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>, Query(JsonPath { json_path, .. }): Query, ) -> windmill_common::error::JsonResult> { - let flow_env = sqlx::query_scalar!( + // Fetch raw value (without json_path) to check for $var:/$res: references + let raw_value = sqlx::query_scalar!( r#" SELECT CASE WHEN flow_version.id IS NOT NULL THEN - (flow_version.value -> 'flow_env' -> $3) #> $4 + flow_version.value -> 'flow_env' -> $3 ELSE - (root_job.raw_flow -> 'flow_env' -> $3) #> $4 + root_job.raw_flow -> 'flow_env' -> $3 END AS "flow_env: sqlx::types::Json>" FROM v2_job current_job @@ -472,16 +473,86 @@ async fn get_flow_env_by_flow_job_id( flow_job_id, w_id, var_name, - json_path - .as_ref() - .map(|x| x.split(".").collect::>()) - .unwrap_or_default() as Vec<&str>, ) .fetch_optional(&db) .await? - .map(|r| r.map(|x| x.0)) - .flatten() - .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null)); + .and_then(|r| r.map(|x| x.0)); + + // Resolve $var:/$res: references if present + let resolved = if let Some(raw) = raw_value { + let raw_str = raw.get(); + let db_authed = windmill_common::db::DbWithOptAuthed::::from_authed( + &authed, + db.clone(), + None, + ); + if let Some(path) = raw_str + .strip_prefix("\"$var:") + .and_then(|s| s.strip_suffix("\"")) + { + match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false) + .await + { + Ok(val) => to_raw_value(&serde_json::Value::String(val)), + Err(e) => { + tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}"); + raw + } + } + } else if let Some(path) = raw_str + .strip_prefix("\"$res:") + .and_then(|s| s.strip_suffix("\"")) + { + match windmill_store::resources::get_resource_value_interpolated_internal( + &db_authed, + &w_id, + path, + Some(flow_job_id), + Some(&tokened.token), + false, + ) + .await + { + Ok(Some(val)) => to_raw_value(&val), + Ok(None) => { + tracing::warn!( + "Failed to resolve flow_env resource $res:{path}: resource not found" + ); + raw + } + Err(e) => { + tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}"); + raw + } + } + } else { + raw + } + } else { + to_raw_value(&serde_json::Value::Null) + }; + + // Apply json_path navigation on the (possibly resolved) value + let flow_env = if let Some(ref jp) = json_path { + let mut value: serde_json::Value = + serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null); + for part in jp.split('.') { + value = match value { + serde_json::Value::Object(ref mut map) => { + map.remove(part).unwrap_or(serde_json::Value::Null) + } + serde_json::Value::Array(ref arr) => part + .parse::() + .ok() + .and_then(|i| arr.get(i).cloned()) + .unwrap_or(serde_json::Value::Null), + _ => serde_json::Value::Null, + }; + } + to_raw_value(&value) + } else { + resolved + }; log_job_view( &db, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index d0b7fc7a58..33dafbb482 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::common::{cached_result_path, get_root_job_id, save_in_cache}; +use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transform_json}; use crate::js_eval::{eval_timeout, IdContext}; use crate::worker_utils::get_tag_and_concurrency; use crate::{ @@ -53,7 +53,7 @@ use windmill_common::runnable_settings::{ use windmill_common::scripts::{ScriptHash, ScriptRunnableSettingsInline}; use windmill_common::users::username_to_permissioned_as; use windmill_common::utils::WarnAfterExt; -use windmill_common::worker::to_raw_value; +use windmill_common::worker::{to_raw_value, Connection}; use windmill_common::{ add_time, get_latest_flow_version_info_for_path, get_script_info_for_hash, FlowVersionInfo, ScriptHashInfo, DB, @@ -2245,6 +2245,35 @@ pub async fn handle_flow( killpill_rx: &tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result<()> { let flow = flow_data.value(); + + // Resolve $var: and $res: references in flow_env. + // We resolve into a separate variable to avoid cloning the entire FlowValue + // (which includes modules, failure_module, etc.) just to replace flow_env. + let resolved_env; + let flow_env = if let Some(ref env) = flow.flow_env { + match transform_json( + client, + &flow_job.workspace_id, + env, + &flow_job, + &Connection::Sql(db.clone()), + ) + .await + { + Ok(Some(resolved)) => { + resolved_env = resolved; + Some(&resolved_env) + } + Ok(None) => flow.flow_env.as_ref(), + Err(e) => { + tracing::warn!("Failed to resolve flow_env references: {e}"); + flow.flow_env.as_ref() + } + } + } else { + None + }; + let status = flow_job .parse_flow_status() .with_context(|| "Unable to parse flow status")?; @@ -2348,6 +2377,7 @@ pub async fn handle_flow( flow_job, status, flow, + flow_env, db, client, last_result.clone(), @@ -2448,6 +2478,7 @@ async fn push_next_flow_job( flow_job: Arc, mut status: FlowStatus, flow: &FlowValue, + flow_env: Option<&HashMap>>, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -2580,7 +2611,7 @@ async fn push_next_flow_job( let skip = compute_bool_from_expr( &skip_expr, arc_flow_job_args.clone(), - flow.flow_env.as_ref(), + flow_env, Arc::new(to_raw_value(&json!("{}"))), None, None, @@ -2705,7 +2736,7 @@ async fn push_next_flow_job( expr.to_string(), context, Some(arc_flow_job_args.clone()), - flow.flow_env.as_ref(), + flow_env, None, None, None @@ -2966,7 +2997,7 @@ async fn push_next_flow_job( &input_transform, arc_last_job_result.clone(), Some(arc_flow_job_args.clone()), - flow.flow_env.as_ref(), + flow_env, Some(client), None, ) @@ -3004,7 +3035,7 @@ async fn push_next_flow_job( &status.retry, arc_last_job_result.clone(), arc_flow_job_args.clone(), - flow.flow_env.as_ref(), + flow_env, Some(client), ) .await? @@ -3092,7 +3123,7 @@ async fn push_next_flow_job( compute_bool_from_expr( &skip_if.expr, arc_flow_job_args.clone(), - flow.flow_env.as_ref(), + flow_env, arc_last_job_result.clone(), None, Some(&idcontext), @@ -3182,7 +3213,7 @@ async fn push_next_flow_job( }; transform_input( arc_flow_job_args.clone(), - flow.flow_env.as_ref(), + flow_env, arc_last_job_result.clone(), input_transforms, resumes.clone(), @@ -3209,7 +3240,7 @@ async fn push_next_flow_job( let next_flow_transform = compute_next_flow_transform( arc_flow_job_args.clone(), arc_last_job_result.clone(), - flow.flow_env.as_ref(), + flow_env, &flow_job, &flow, transform_context, @@ -3373,7 +3404,7 @@ async fn push_next_flow_job( let ctx = get_transform_context(&flow_job, "", &status); let ti = transform_input( Marc::new(args), - flow.flow_env.as_ref(), + flow_env, arc_last_job_result.clone(), input_transforms, resumes.clone(), @@ -3428,7 +3459,7 @@ async fn push_next_flow_job( let ctx = get_transform_context(&flow_job, &previous_id, &status); let ti = transform_input( Marc::new(hm), - flow.flow_env.as_ref(), + flow_env, arc_last_job_result.clone(), input_transforms, resumes.clone(), @@ -3546,7 +3577,7 @@ async fn push_next_flow_job( timeout_transform, arc_last_job_result.clone(), Some(arc_flow_job_args.clone()), - flow.flow_env.as_ref(), + flow_env, Some(client), Some(&ctx), ) @@ -3625,7 +3656,7 @@ async fn push_next_flow_job( parallelism_transform, arc_last_job_result.clone(), Some(arc_flow_job_args.clone()), - flow.flow_env.as_ref(), + flow_env, Some(client), Some(&ctx), ) @@ -4461,7 +4492,7 @@ async fn compute_next_flow_transform( let pred = compute_bool_from_expr( &b.expr, arc_flow_job_args.clone(), - flow.flow_env.as_ref(), + flow_env, arc_last_job_result.clone(), None, Some(&idcontext), diff --git a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte index ce51b8dc77..3b6b867abe 100644 --- a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte +++ b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte @@ -5,17 +5,21 @@ import { writable } from 'svelte/store' import type { FlowEditorContext } from '../types' import { Button } from '$lib/components/common' - import { Plus, Trash2 } from 'lucide-svelte' + import { DollarSign, Plus, Trash2 } from 'lucide-svelte' import FlowCard from '../common/FlowCard.svelte' import JsonEditor from '$lib/components/JsonEditor.svelte' import Label from '$lib/components/Label.svelte' import Select from '$lib/components/select/Select.svelte' + import ItemPicker from '$lib/components/ItemPicker.svelte' + import ResourcePicker from '$lib/components/ResourcePicker.svelte' + import { VariableService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' interface Props { noEditor: boolean } - type EnvVarType = 'string' | 'json' + type EnvVarType = 'string' | 'json' | 'resource' interface EnvVarEntry { id: string @@ -37,6 +41,9 @@ function determineValueType(value: any): EnvVarType { if (typeof value === 'string') { + if (value.startsWith('$res:')) { + return 'resource' + } try { JSON.parse(value) return value.trim().startsWith('{') || @@ -53,30 +60,51 @@ let flowEnvTypes = $state>({}) - const typeOptions = [ - { label: 'String', value: 'string' as EnvVarType }, - { label: 'JSON', value: 'json' as EnvVarType } + const typeOptions: { label: string; value: EnvVarType }[] = [ + { label: 'String', value: 'string' }, + { label: 'JSON', value: 'json' }, + { label: 'Resource', value: 'resource' } ] + // Track resource paths separately for bind:value with ResourcePicker + let resourcePaths = $state>({}) + + // Initialize resourcePaths from existing flow_env values + for (const [key, value] of Object.entries(flowStore.val.value.flow_env || {})) { + if (typeof value === 'string' && value.startsWith('$res:')) { + resourcePaths[key] = value.substring('$res:'.length) + } + } + + // Initialize types for new keys and sync resourcePaths → flow_env $effect(() => { for (const [key, value] of flowEnvVarsMap.entries()) { if (!flowEnvTypes[key]) { flowEnvTypes[key] = determineValueType(value) } } - }) - - $effect(() => { - for (const [key, type] of Object.entries(flowEnvTypes)) { - if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) { - const currentType = determineValueType(flowStore.val.value.flow_env[key]) - if (currentType !== type) { - updateEnvType(key, type) + for (const [key, path] of Object.entries(resourcePaths)) { + if (flowStore.val.value.flow_env && flowEnvTypes[key] === 'resource') { + const newVal = '$res:' + (path || '') + if (flowStore.val.value.flow_env[key] !== newVal) { + flowStore.val.value.flow_env[key] = newVal + flowStore.val = flowStore.val } } } }) + // Convert values when user changes the type dropdown + let prevTypes: Record = {} + $effect(() => { + for (const [key, type] of Object.entries(flowEnvTypes)) { + if (prevTypes[key] && prevTypes[key] !== type) { + updateEnvType(key, type) + } + prevTypes[key] = type + } + }) + let flowEnvEntries = $derived( Array.from(flowEnvVarsMap.entries()).map(([key, value]): EnvVarEntry => { const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2) @@ -113,6 +141,7 @@ if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) { delete flowStore.val.value.flow_env[key] delete flowEnvTypes[key] + delete resourcePaths[key] flowStore.val = flowStore.val } } @@ -150,32 +179,55 @@ flowStore.val.value.flow_env = newEnvVars delete flowEnvTypes[oldKey] flowEnvTypes[newKey] = type + + // Move resource path if applicable + if (type === 'resource' && oldKey in resourcePaths) { + resourcePaths[newKey] = resourcePaths[oldKey] + delete resourcePaths[oldKey] + } + flowStore.val = flowStore.val } } function updateEnvType(key: string, newType: EnvVarType) { if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) { - const currentValue = flowStore.val.value.flow_env[key] - const stringValue = - typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2) - flowEnvTypes[key] = newType - if (newType === 'json') { - try { - const parsed = JSON.parse(stringValue) - flowStore.val.value.flow_env[key] = parsed - } catch { - flowStore.val.value.flow_env[key] = stringValue + if (newType === 'resource') { + flowStore.val.value.flow_env[key] = '$res:' + resourcePaths[key] = '' + } else if (newType === 'json') { + delete resourcePaths[key] + const currentValue = flowStore.val.value.flow_env[key] + if (typeof currentValue === 'string') { + try { + flowStore.val.value.flow_env[key] = JSON.parse(currentValue) + } catch { + // keep as string if not valid JSON + } } } else { - flowStore.val.value.flow_env[key] = stringValue + delete resourcePaths[key] + const currentValue = flowStore.val.value.flow_env[key] + if (typeof currentValue !== 'string') { + flowStore.val.value.flow_env[key] = JSON.stringify(currentValue, null, 2) + } } flowStore.val = flowStore.val } } + function setVarPath(key: string, path: string) { + if (flowStore.val.value.flow_env) { + flowStore.val.value.flow_env[key] = '$var:' + path + flowStore.val = flowStore.val + } + } + + let variablePicker: ItemPicker | undefined = $state(undefined) + let pickForKey: string | undefined = $state(undefined) + setContext('PropPickerWrapper', { inputMatches: writable(undefined), connectProp: () => {}, @@ -192,8 +244,8 @@ Flow envs can be referenced in any flow step input using the syntax{' '} flow_env.VARIABLE_NAME or flow_env["VARIABLE_NAME"]. These variables are available in the property picker and can be used in JavaScript expressions and - input bindings. You can choose between String or JSON types for each variable - JSON types - allow complex data structures. + input bindings. String values can link to workspace variables using the button. Resource type references workspace resources resolved at runtime. {#if flowEnvEntries.length === 0} @@ -246,10 +298,13 @@ {/if} - - - Value - {#if entry.type === 'json'} + + {#if entry.type === 'resource'} + + {:else if entry.type === 'json'} {:else} - updateEnvValue(entry.key, e.currentTarget.value, 'string')} - disabled={noEditor} - class="input w-full" - placeholder="Variable value" - /> + + + updateEnvValue(entry.key, e.currentTarget.value, 'string')} + disabled={noEditor} + class="input w-full" + placeholder="Variable value" + /> + {#if !noEditor} + { + pickForKey = entry.key + variablePicker?.openDrawer?.() + }} + wrapperClasses="opacity-0 group-hover:opacity-100 transition-opacity absolute right-2 top-1/2 -translate-y-1/2 bg-surface-input" + variant="subtle" + title="Insert a Variable" + /> + {/if} + + {#if typeof entry.value === 'string' && entry.value.startsWith('$var:') && entry.value.length > 5} + + Linked to variable {entry.value.slice(5)} + + {/if} {/if} - + {/each} @@ -285,3 +367,20 @@ + + { + if (pickForKey) { + setVarPath(pickForKey, path) + pickForKey = undefined + } + }} + itemName="Variable" + extraField="path" + loadItems={async () => + (await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({ + name: x.path, + ...x + }))} +/> diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index 6910fabbb5..ead5ad9ad3 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -27,7 +27,7 @@ import type { PickableProperties } from '../previousResults' import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte' import type { PropPickerContext } from '$lib/components/prop_picker' - import type { FlowEditorContext } from '../types' + interface Props { pickableProperties: PickableProperties | undefined @@ -67,9 +67,8 @@ const { flowPropPickerConfig } = getContext('PropPickerContext') flowPropPickerConfig.set(undefined) - const { flowStore } = getContext('FlowEditorContext') - let flow_env = $derived(pickableProperties?.flow_env || flowStore.val.value.flow_env) + setContext('PropPickerWrapper', { propPickerConfig, inputMatches, @@ -156,7 +155,6 @@ {extraResults} {displayContext} {error} - {flow_env} previousId={pickableProperties?.previousId} {pickableProperties} allowCopy={!notSelectable && !$propPickerConfig} diff --git a/frontend/src/lib/components/propertyPicker/PropPicker.svelte b/frontend/src/lib/components/propertyPicker/PropPicker.svelte index d04683e226..6910750480 100644 --- a/frontend/src/lib/components/propertyPicker/PropPicker.svelte +++ b/frontend/src/lib/components/propertyPicker/PropPicker.svelte @@ -19,7 +19,6 @@ error?: boolean allowCopy?: boolean previousId?: string | undefined - flow_env?: Record | undefined result?: any | undefined extraResults?: any } @@ -30,7 +29,6 @@ error = false, allowCopy = false, previousId = undefined, - flow_env = undefined, result = undefined, extraResults = undefined }: Props = $props() @@ -39,7 +37,6 @@ let resources: Record = $state({}) let displayVariable = $state(false) let displayResources = $state(false) - let displayFlowEnv = $state(false) let allResultsCollapsed = $state(true) let collapsableInitialState: @@ -47,7 +44,6 @@ allResultsCollapsed: boolean displayVariable: boolean displayResources: boolean - displayFlowEnv: boolean } | undefined @@ -139,7 +135,9 @@ resultByIdFiltered = {} } if (!$inputMatches?.some((match) => match.word === 'flow_env')) { - flowEnvFiltered = {} + if (search === EMPTY_STRING) { + flowEnvFiltered = pickableProperties.flow_env + } } if ($inputMatches?.length == 1) { filteringFlowInputsOrResult = $inputMatches[0].value @@ -185,8 +183,7 @@ collapsableInitialState = { allResultsCollapsed, displayVariable, - displayResources, - displayFlowEnv + displayResources } } @@ -200,10 +197,6 @@ displayResources = true return } - if ($inputMatches[0].word === 'flow_env') { - displayFlowEnv = true - return - } if ($inputMatches[0].word === 'results') { allResultsCollapsed = false return @@ -214,8 +207,7 @@ if (!collapsableInitialState) { return } - ;({ allResultsCollapsed, displayVariable, displayResources, displayFlowEnv } = - collapsableInitialState) + ;({ allResultsCollapsed, displayVariable, displayResources } = collapsableInitialState) collapsableInitialState = undefined } @@ -279,6 +271,18 @@ /> {/if} + {#if flowEnvFiltered && Object.keys(flowEnvFiltered ?? {}).length > 0} + Flow Env Variables + + + + {/if} {#if error} Error @@ -445,45 +449,6 @@ {/if} {/if} - {#if flow_env && Object.keys(flow_env).length > 0 && $inputMatches?.some((match) => match.word === 'flow_env')} - - Flow Env Variables: - - {#if displayFlowEnv} - { - displayFlowEnv = false - }} - wrapperClasses="inline-flex whitespace-nowrap w-fit" - btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">- - - {:else} - { - displayFlowEnv = true - }} - wrapperClasses="inline-flex whitespace-nowrap w-fit" - btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1" - > - {'{...}'} - - {/if} - - {/if} {/if} diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 4082e5f8ff..870bf8e7c4 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -40,7 +40,8 @@ EyeOff, Circle } from 'lucide-svelte' - import { untrack } from 'svelte' + import { onMount, untrack } from 'svelte' + import { page } from '$app/stores' type ListableVariableW = ListableVariable & { canWrite: boolean } @@ -202,6 +203,14 @@ loadContextualVariables() }, 5000) } + + onMount(() => { + let hash = $page.url.hash + if (hash.length > 1) { + let path = hash.slice(1) + variableEditor?.editVariable(path) + } + }) diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 2b181a6c7b..5031c7e889 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -96,9 +96,8 @@ components: type: boolean flow_env: type: object - description: Environment variables available to all steps - additionalProperties: - type: string + description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)." + additionalProperties: {} priority: type: number description: Execution priority (higher numbers run first) From 19c065bed5468c484c8e7a50a6b79ab90153cc0e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 4 Mar 2026 14:44:33 +0000 Subject: [PATCH 18/58] fix: handle multipart stream errors gracefully instead of panicking (#8226) Co-authored-by: Claude Opus 4.6 --- backend/windmill-api/src/apps.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 1cfbfa6e19..9846912192 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -993,9 +993,18 @@ macro_rules! process_app_multipart { let mut uploaded_js = false; let mut multipart = $multipart; - while let Some(field) = multipart.next_field().await.unwrap() { - let name = field.name().unwrap().to_string(); - let data = field.bytes().await.unwrap(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}")))? + { + let name = field + .name() + .ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))? + .to_string(); + let data = field.bytes().await.map_err(|e| { + Error::BadRequest(format!("failed to read multipart stream: {e}")) + })?; if name == "app" { let app = serde_json::from_slice(&data).map_err(to_anyhow)?; let (ntx, npath, nid) = $internal_fn( @@ -2149,7 +2158,8 @@ async fn execute_component( (email.as_str(), permissioned_as) }; - let end_user_email = get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await; + let end_user_email = + get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await; let (uuid, mut tx) = push( &db, From 62382fd2869ea0190dd0c0b714f9cbd35ceddd7a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 4 Mar 2026 15:53:56 +0100 Subject: [PATCH 19/58] fix: wrap set_encryption_key in a single database transaction (#8212) Prevent workspace corruption when re-encryption fails mid-loop by wrapping the key update and variable re-encryption in a single transaction. If any step fails, the entire operation rolls back. Co-authored-by: Claude Opus 4.6 --- backend/Cargo.lock | 1 + backend/windmill-api-workspaces/Cargo.toml | 1 + .../windmill-api-workspaces/src/workspaces.rs | 29 ++++++++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d26d5b76d7..e52bb12fa1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16383,6 +16383,7 @@ dependencies = [ "http 1.4.0", "hyper 1.8.1", "lazy_static", + "magic-crypt", "regex", "serde", "serde_json", diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index b698426d53..a03bb3a490 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -29,6 +29,7 @@ windmill-dep-map.workspace = true axum.workspace = true chrono.workspace = true hex.workspace = true +magic-crypt.workspace = true http.workspace = true hyper.workspace = true lazy_static.workspace = true diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8f3311978a..80e497ba97 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -31,7 +31,9 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::users::username_to_permissioned_as; -use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE}; +use windmill_common::variables::{ + build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, +}; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; #[cfg(feature = "enterprise")] use windmill_common::workspaces::GitRepositorySettings; @@ -2418,20 +2420,28 @@ async fn set_encryption_key( )); } + // Build the previous cipher before the transaction (reads from cache/pool) let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; + let mut tx = db.begin().await?; + sqlx::query!( "UPDATE workspace_key SET key = $1 WHERE workspace_id = $2", request.new_key.clone(), w_id ) - .execute(&db) + .execute(&mut *tx) .await?; - WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); - if !request.skip_reencrypt.unwrap_or(false) { - let new_encryption_key = build_crypt(&db, w_id.as_str()).await?; + // Build the new cipher directly from the key string, since the transaction + // hasn't committed yet and build_crypt() would read the old key from the pool. + let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { + format!("{}{}", request.new_key, salt) + } else { + request.new_key.clone() + }; + let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256); let mut truncated_new_key = request.new_key.clone(); truncated_new_key.truncate(8); @@ -2445,7 +2455,7 @@ async fn set_encryption_key( "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", w_id ) - .fetch_all(&db) + .fetch_all(&mut *tx) .await?; for variable in all_variables { @@ -2466,11 +2476,16 @@ async fn set_encryption_key( w_id, variable.path ) - .execute(&db) + .execute(&mut *tx) .await?; } } + tx.commit().await?; + + // Invalidate the cache only after the transaction has committed + WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); + // Trigger git sync for encryption key changes handle_deployment_metadata( &authed.email, From 87ebeaa51d9ca22bca9a1deba2590f9c6e5d3f77 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:09:42 +0100 Subject: [PATCH 20/58] chore: make rust-analyzer plugin opt-in via USE_RUST_PLUGIN env var (#8227) * feat: optionally enable rust-analyzer plugin in worktree settings When USE_RUST_PLUGIN env var is set, the worktree-env script now includes the rust-analyzer-lsp plugin in .claude/settings.local.json. Co-Authored-By: Claude Opus 4.6 * chore: remove rust-analyzer plugin from default settings The rust-analyzer plugin is now opt-in via USE_RUST_PLUGIN env var in worktree-env, so it no longer needs to be in the shared settings. Co-Authored-By: Claude Opus 4.6 * chore: add WM_CLONE_DB and USE_RUST_PLUGIN to wmdev startup envs Defaults both to false so they can be toggled per-worktree. Co-Authored-By: Claude Opus 4.6 * fix: use explicit truthy checks for WM_CLONE_DB and USE_RUST_PLUGIN Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .claude/settings.json | 1 - .wmdev.yaml | 2 ++ scripts/worktree-env | 11 +++++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index fcd49c3140..cf8bfdd284 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -110,7 +110,6 @@ ] }, "enabledPlugins": { - "rust-analyzer-lsp@claude-plugins-official": true, "typescript-lsp@claude-plugins-official": true, "code-review@claude-plugins-official": true } diff --git a/.wmdev.yaml b/.wmdev.yaml index c028c8f3bf..1a949c94e2 100644 --- a/.wmdev.yaml +++ b/.wmdev.yaml @@ -2,6 +2,8 @@ name: Windmill startupEnvs: CARGO_FEATURES: "quickjs" + WM_CLONE_DB: false + USE_RUST_PLUGIN: false services: - name: BE diff --git a/scripts/worktree-env b/scripts/worktree-env index ae83b09405..5fd8490bc2 100755 --- a/scripts/worktree-env +++ b/scripts/worktree-env @@ -61,7 +61,7 @@ if command -v psql &>/dev/null; then if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then echo "Database $db_name already exists" else - if [[ -n "${WM_CLONE_DB:-}" ]]; then + if [[ "${WM_CLONE_DB:-}" == "1" || "${WM_CLONE_DB:-}" == "true" ]]; then # Terminate active connections so CREATE DATABASE ... TEMPLATE works psql "$db_conn/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'windmill' AND pid <> pg_backend_pid();" 2>/dev/null || true psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name} TEMPLATE windmill" 2>/dev/null \ @@ -178,13 +178,20 @@ if [ -n "$ee_repo" ]; then if [ -d "$ee_worktree_dir" ]; then ee_rel=$(python3 -c "import os; print(os.path.relpath('$ee_worktree_dir', '$(pwd)'))" 2>/dev/null || echo "$ee_worktree_dir") mkdir -p .claude + rust_plugin="" + if [[ "${USE_RUST_PLUGIN:-}" == "1" || "${USE_RUST_PLUGIN:-}" == "true" ]]; then + rust_plugin=', + "enabledPlugins": { + "rust-analyzer-lsp@claude-plugins-official": true + }' + fi cat > .claude/settings.local.json < Date: Wed, 4 Mar 2026 16:12:00 +0100 Subject: [PATCH 21/58] feat: replace hub error toasts with warning alerts and add disable hub setting (#8225) * feat: replace hub error toasts with warning alerts and add disable hub setting Co-Authored-By: Claude Opus 4.6 * fix: guard hub script cache refresh when hub is disabled Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- backend/windmill-api-settings/src/lib.rs | 5 +++-- .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 2 ++ .../flows/pickers/PickHubApp.svelte | 22 +++++++++++++++++-- .../flows/pickers/PickHubFlow.svelte | 22 +++++++++++++++++-- .../flows/pickers/PickHubScript.svelte | 11 +++++++++- .../flows/pickers/PickHubScriptQuick.svelte | 22 +++++++++++++------ .../src/lib/components/instanceSettings.ts | 10 +++++++++ frontend/src/lib/stores.ts | 1 + .../src/routes/(root)/(logged)/+layout.svelte | 7 ++++++ 10 files changed, 89 insertions(+), 14 deletions(-) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index bf493f9420..6b408724fd 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -43,8 +43,8 @@ use windmill_common::{ get_database_url, global_settings::{ APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, - ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, + CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, + EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -519,6 +519,7 @@ pub async fn get_global_setting( && key != DEFAULT_TAGS_WORKSPACES_SETTING && key != HUB_BASE_URL_SETTING && key != HUB_ACCESSIBLE_URL_SETTING + && key != DISABLE_HUB_SETTING && key != EMAIL_DOMAIN_SETTING && key != APP_WORKSPACED_ROUTE_SETTING { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 3347127303..d4d8163ff2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -44,6 +44,7 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; pub const HUB_BASE_URL_SETTING: &str = "hub_base_url"; pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url"; +pub const DISABLE_HUB_SETTING: &str = "disable_hub"; pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels"; pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui"; pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_oversize"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 4de90be5c5..4ef88f5b78 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -230,6 +230,8 @@ pub struct GlobalSettings { pub no_default_maven: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_tags_per_workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_hub: Option, // String settings #[serde(skip_serializing_if = "Option::is_none")] diff --git a/frontend/src/lib/components/flows/pickers/PickHubApp.svelte b/frontend/src/lib/components/flows/pickers/PickHubApp.svelte index 7f534a924a..234f053f7b 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubApp.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubApp.svelte @@ -7,6 +7,8 @@ import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { loadHubApps } from '$lib/hub' import TextInput from '$lib/components/text_input/TextInput.svelte' + import { Alert } from '$lib/components/common' + import { disableHubStore } from '$lib/stores' interface Props { filter?: string @@ -30,11 +32,22 @@ const dispatch = createEventDispatcher() + let hubNotAvailable = $state(false) + onMount(async () => { - hubApps = await loadHubApps() + if ($disableHubStore) return + const result = await loadHubApps() + if (result === undefined) { + hubNotAvailable = true + } else { + hubApps = result + } }) +{#if $disableHubStore} + +{:else} -{#if hubApps} +{#if hubNotAvailable} + + Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings. + +{:else if hubApps} {#if filteredItems.length == 0} {:else} @@ -93,3 +110,4 @@ {/each} {/if} +{/if} diff --git a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte index 2ab12aea36..1f3c659bf8 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte @@ -7,6 +7,8 @@ import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { loadHubFlows } from '$lib/hub' import TextInput from '$lib/components/text_input/TextInput.svelte' + import { Alert } from '$lib/components/common' + import { disableHubStore } from '$lib/stores' interface Props { filter?: string @@ -30,11 +32,22 @@ const dispatch = createEventDispatcher() + let hubNotAvailable = $state(false) + onMount(async () => { - hubFlows = await loadHubFlows() + if ($disableHubStore) return + const result = await loadHubFlows() + if (result === undefined) { + hubNotAvailable = true + } else { + hubFlows = result + } }) +{#if $disableHubStore} + +{:else} -{#if hubFlows} +{#if hubNotAvailable} + + Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings. + +{:else if hubFlows} {#if filteredItems.length == 0} {:else} @@ -95,3 +112,4 @@ {/each} {/if} +{/if} diff --git a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte index b5ba87327e..7ec0a8ccc3 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte @@ -8,6 +8,7 @@ import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen' import { Loader2 } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' + import { disableHubStore } from '$lib/stores' interface Props { kind?: HubScriptKind & string @@ -47,6 +48,7 @@ ) async function getAllApps(filterKind: typeof kind) { + if ($disableHubStore) return try { hubNotAvailable = false allApps = ( @@ -67,6 +69,7 @@ filterKind: typeof kind, appFilter: string | undefined ) { + if ($disableHubStore) return try { loading = true hubNotAvailable = false @@ -138,6 +141,9 @@ }) +{#if $disableHubStore} + +{:else} {@render children?.()} @@ -156,7 +162,9 @@ {#if hubNotAvailable} - + + Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings. + {:else if (items.length > 0 && apps.length > 0) || !loading} {#if items.length == 0} @@ -204,3 +212,4 @@ {/each} {/if} +{/if} diff --git a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte index 2091f76340..3d929dead7 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte @@ -24,7 +24,7 @@ []) : undefined } catch (err) { - sendUserToast('Failed to fetch hub scripts: ' + err, 'error') + console.error('Failed to fetch hub scripts:', err) return undefined } }, @@ -44,9 +44,10 @@ import { Circle, ExternalLink } from 'lucide-svelte' import Popover from '$lib/components/Popover.svelte' import { usePromise } from '$lib/svelte5Utils.svelte' - import { hubBaseUrlStore, userStore } from '$lib/stores' + import { disableHubStore, hubBaseUrlStore, userStore } from '$lib/stores' import { get } from 'svelte/store' import Button from '$lib/components/common/button/Button.svelte' + import { Alert } from '$lib/components/common' let hubNotAvailable = $state(false) @@ -94,13 +95,14 @@ }) async function getAllApps(filterKind: typeof kind) { + if ($disableHubStore) return try { hubNotAvailable = false allApps = (await listHubIntegrationsCached({ kind: filterKind, refreshCount })).map( (x) => x.name ) } catch (err) { - sendUserToast('Failed to fetch hub integrations: ' + err, 'error') + console.error('Failed to fetch hub integrations:', err) allApps = [] hubNotAvailable = true } @@ -112,7 +114,9 @@ ) $effect(() => { ;[filter, kind, appFilter, refreshCount] - hubScriptsFilteredPromise.refresh() + if (!$disableHubStore) { + hubScriptsFilteredPromise.refresh() + } }) $effect(() => { loading = hubScriptsFilteredPromise.status === 'loading' @@ -175,9 +179,13 @@ -{#if hubNotAvailable} - - Hub not available +{#if $disableHubStore} + +{:else if hubNotAvailable} + + + Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings. + {:else if loading} {#each Array(15).fill(0) as _} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index de4cd1528b..25955b74db 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -331,6 +331,16 @@ export const settings: Record = { storage: 'setting', ee_only: '', hiddenIfEmpty: true + }, + { + label: 'Disable Hub', + description: + 'Disable the Windmill Hub integration entirely. Enable this if your instance runs in a closed environment without internet access and you do not have a private hub setup.', + key: 'disable_hub', + fieldType: 'boolean', + storage: 'setting', + ee_only: '', + requiresReloadOnChange: true } ], SMTP: [ diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 6a9a8e7805..fd0a66cfb3 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -83,6 +83,7 @@ export const superadmin = writable(undefined) export const devopsRole = writable(undefined) export const lspTokenStore = writable(undefined) export const hubBaseUrlStore = writable(DEFAULT_HUB_BASE_URL) +export const disableHubStore = writable(false) export const userWorkspaces: Readable> = derived( [usersWorkspaceStore, superadmin], ([store, superadmin]) => { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e9b1884df2..78de6c1535 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -26,6 +26,7 @@ type UserExt, defaultScripts, hubBaseUrlStore, + disableHubStore, usedTriggerKinds, devopsRole, whitelabelNameStore, @@ -157,6 +158,7 @@ loadUsage() syncTutorialsTodos() loadHubBaseUrl() + loadDisableHub() loadUsedTriggerKinds() } @@ -176,6 +178,11 @@ DEFAULT_HUB_BASE_URL } + async function loadDisableHub() { + $disableHubStore = + ((await SettingService.getGlobal({ key: 'disable_hub' })) as boolean) ?? false + } + async function loadFavorites() { const scripts = await ScriptService.listScripts({ workspace: $workspaceStore ?? '', From 077779ec52f7d3e5fcc93951544bf47bd6dc30b6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 4 Mar 2026 20:20:18 +0000 Subject: [PATCH 22/58] fix: improve windows compatibility * ci: add Windows backend integration test workflow Co-Authored-By: Claude Opus 4.5 * ci: temporarily add push trigger for testing Co-Authored-By: Claude Opus 4.5 * ci: add --no-fail-fast to run all test binaries Co-Authored-By: Claude Opus 4.5 * fix: Windows path handling for backend integration tests - WINDMILL_DIR: use std::env::temp_dir() on Windows instead of /tmp/windmill - HOME_ENV: fall back to USERPROFILE on Windows when HOME is not set - loader.bun.js: normalize paths to forward slashes for consistent comparison with Bun's resolver output on Windows - bun_executor.rs: convert job_dir to forward slashes in JS template strings to avoid backslash escape issues (\t -> tab, etc.) - go_executor.rs: fix windows_gopath() double backslash bug (r"\\" -> "\\") - bash_executor.rs: default to "bash" (in PATH) on Windows instead of /bin/bash Co-Authored-By: Claude Opus 4.6 * fix: improve Windows diagnostics and fix onLoad handler - Include path in create_directory_async/sync panic messages - Add WINDMILL_DIR initialization debug output - Fix loader.bun.js onLoad: use properly escaped regex instead of returning undefined (Bun requires onLoad to return an object) - Add env var debug output to CI workflow Co-Authored-By: Claude Opus 4.6 * fix: sanitize Windows-invalid characters in test worker names and fix cargo path - Replace :: with __ in worker names (colons illegal in Windows dir names) - Fix HOME_DIR to fall back to USERPROFILE on Windows - Add PATH fallback for cargo discovery on Windows - Add debug logging to bun loader for fetch errors Co-Authored-By: Claude Opus 4.6 * fix: handle single colons in worker names, pass MSVC linker env vars, revert bun debug Co-Authored-By: Claude Opus 4.5 * fix: use .exe binary name on Windows and normalize bun import URL paths Co-Authored-By: Claude Opus 4.5 * fix: use absolute path for rust binary, normalize bun resolve paths Co-Authored-By: Claude Opus 4.5 * fix: use .wurl extension instead of .url for bun import resolution on Windows Co-Authored-By: Claude Opus 4.5 * fix: use custom namespace for bun plugin to bypass default file resolution Co-Authored-By: Claude Opus 4.5 * fix: use virtual namespace for bun import resolution to avoid Windows path issues Co-Authored-By: Claude Opus 4.5 * fix: handle Windows 8.3 paths and namespace-prefixed importers in bun loader Co-Authored-By: Claude Opus 4.5 * fix: strip namespace prefix from args.path and handle absolute imports without leading slash in bun loader Co-Authored-By: Claude Opus 4.5 * refactor: simplify bun loader and remove redundant cargo path lookups Co-Authored-By: Claude Opus 4.5 * fix: use platform-specific cargo binary path with .exe on Windows Co-Authored-By: Claude Opus 4.5 * refactor: replace HOME_DIR with HOME_ENV in rust_executor to remove duplication Co-Authored-By: Claude Opus 4.5 * refactor: keep original bun loader on linux, use virtual namespace loader only on windows Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/backend-test-windows.yml | 165 ++++++++++++++++++ backend/windmill-common/src/utils.rs | 4 +- backend/windmill-common/src/worker.rs | 12 +- backend/windmill-test-utils/src/lib.rs | 2 + backend/windmill-worker/loader.bun.windows.js | 123 +++++++++++++ backend/windmill-worker/src/bash_executor.rs | 7 +- backend/windmill-worker/src/bun_executor.rs | 15 +- backend/windmill-worker/src/go_executor.rs | 3 +- backend/windmill-worker/src/rust_executor.rs | 63 ++++--- backend/windmill-worker/src/worker.rs | 11 +- 10 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/backend-test-windows.yml create mode 100644 backend/windmill-worker/loader.bun.windows.js diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml new file mode 100644 index 0000000000..ca9ce2aaac --- /dev/null +++ b/.github/workflows/backend-test-windows.yml @@ -0,0 +1,165 @@ +name: Backend integration tests (Windows) + +on: + workflow_dispatch: + push: + branches: + - "ci-windows-tests" + +env: + CARGO_INCREMENTAL: 0 + SQLX_OFFLINE: true + DISABLE_EMBEDDING: true + +jobs: + cargo_test_windows: + runs-on: blacksmith-16vcpu-windows-2025 + steps: + - uses: actions/checkout@v4 + + - name: Read EE repo commit hash + shell: pwsh + run: | + $ee_repo_ref = Get-Content .\backend\ee-repo-ref.txt + echo "ee_repo_ref=$ee_repo_ref" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Checkout windmill-ee-private repository + uses: actions/checkout@v4 + with: + repository: windmill-labs/windmill-ee-private + path: ./windmill-ee-private + ref: ${{ env.ee_repo_ref }} + token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + fetch-depth: 0 + + - name: Substitute EE code + shell: bash + run: | + ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private + + - name: Setup PostgreSQL + uses: ikalnytskyi/action-setup-postgres@v6 + with: + username: postgres + password: changeme + database: windmill + port: 5432 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-workspaces: backend + toolchain: 1.93.0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - uses: actions/setup-go@v2 + with: + go-version: 1.21.5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: astral-sh/setup-uv@v6.2.1 + with: + version: "0.9.24" + + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer + + - name: Install windmill CLI + shell: bash + run: | + cd cli + bash gen_wm_client.sh + bun install + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\nexec bun run "%s/cli/src/main.ts" "$@"\n' "$GITHUB_WORKSPACE" > "$HOME/.local/bin/wmill" + chmod +x "$HOME/.local/bin/wmill" + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install OpenSSL via vcpkg + run: | + vcpkg.exe install openssl-windows:x64-windows + vcpkg.exe install openssl:x64-windows-static + vcpkg.exe integrate install + + - name: Get runtime paths + id: runtime-paths + shell: pwsh + run: | + echo "DENO_PATH=$($(Get-Command deno).Source)" >> $env:GITHUB_OUTPUT + echo "BUN_PATH=$($(Get-Command bun).Source)" >> $env:GITHUB_OUTPUT + echo "NODE_BIN_PATH=$($(Get-Command node).Source)" >> $env:GITHUB_OUTPUT + echo "GO_PATH=$($(Get-Command go).Source)" >> $env:GITHUB_OUTPUT + echo "UV_PATH=$($(Get-Command uv).Source)" >> $env:GITHUB_OUTPUT + echo "PHP_PATH=$($(Get-Command php).Source)" >> $env:GITHUB_OUTPUT + echo "COMPOSER_PATH=$($(Get-Command composer).Source)" >> $env:GITHUB_OUTPUT + echo "POWERSHELL_PATH=$($(Get-Command pwsh).Source)" >> $env:GITHUB_OUTPUT + echo "DOTNET_PATH=$($(Get-Command dotnet).Source)" >> $env:GITHUB_OUTPUT + + - name: Build DuckDB FFI module + working-directory: backend/windmill-duckdb-ffi-internal + timeout-minutes: 30 + run: | + cargo build --release -p windmill_duckdb_ffi_internal + New-Item -ItemType Directory -Path ..\target\debug -Force + Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\ + + - name: Print runtime versions and env + shell: pwsh + run: | + deno --version + bun -v + node --version + go version + python3 --version + php --version + pwsh --version + dotnet --version + echo "TEMP=$env:TEMP" + echo "TMP=$env:TMP" + echo "USERPROFILE=$env:USERPROFILE" + echo "HOME=$env:HOME" + + - name: cargo test + working-directory: backend + timeout-minutes: 60 + env: + DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill + RUST_LOG: "off" + RUST_LOG_STYLE: never + CARGO_NET_GIT_FETCH_WITH_CLI: true + CARGO_BUILD_JOBS: 12 + VCPKGRS_DYNAMIC: 1 + OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static + DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }} + BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }} + NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }} + GO_PATH: ${{ steps.runtime-paths.outputs.GO_PATH }} + UV_PATH: ${{ steps.runtime-paths.outputs.UV_PATH }} + PHP_PATH: ${{ steps.runtime-paths.outputs.PHP_PATH }} + COMPOSER_PATH: ${{ steps.runtime-paths.outputs.COMPOSER_PATH }} + POWERSHELL_PATH: ${{ steps.runtime-paths.outputs.POWERSHELL_PATH }} + DOTNET_PATH: ${{ steps.runtime-paths.outputs.DOTNET_PATH }} + WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1 + WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1 + WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1 + run: > + cargo test + --no-fail-fast + --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline + --all + -- --nocapture --test-threads=10 diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index e4c5315cba..10b1a3b408 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -314,14 +314,14 @@ pub async fn create_directory_async(directory_path: &str) { .recursive(true) .create(directory_path) .await - .expect("could not create dir"); + .unwrap_or_else(|e| panic!("could not create dir '{}': {}", directory_path, e)); } pub fn create_directory_sync(directory_path: &str) { SyncDirBuilder::new() .recursive(true) .create(directory_path) - .expect("could not create dir"); + .unwrap_or_else(|e| panic!("could not create dir '{}': {}", directory_path, e)); } #[track_caller] diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 8d95541f61..3195562368 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -494,7 +494,17 @@ pub async fn store_pull_query(wc: &WorkerConfig) { lazy_static::lazy_static! { pub static ref WINDMILL_DIR: String = { let dir = std::env::var("WINDMILL_DIR") - .unwrap_or_else(|_| "/tmp/windmill".to_string()); + .unwrap_or_else(|_| { + #[cfg(not(windows))] + { "/tmp/windmill".to_string() } + #[cfg(windows)] + { + let temp = std::env::temp_dir(); + let temp_str = temp.to_string_lossy(); + let normalized = temp_str.trim_end_matches(&['/', '\\'][..]).replace('\\', "/"); + format!("{}/windmill", normalized) + } + }); if dir.is_empty() { panic!("WINDMILL_DIR must not be empty"); } diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 69e7fc0558..68da452335 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -66,6 +66,8 @@ fn next_worker_name() -> String { .unwrap_or(s) }) .unwrap_or("no thread name"); + // Replace colons because they are illegal in Windows directory names + let thread_name = thread_name.replace(':', "_"); format!("{id}/worker-{thread_name}") } diff --git a/backend/windmill-worker/loader.bun.windows.js b/backend/windmill-worker/loader.bun.windows.js new file mode 100644 index 0000000000..227c68a56c --- /dev/null +++ b/backend/windmill-worker/loader.bun.windows.js @@ -0,0 +1,123 @@ +// Windows-specific bun loader that uses a virtual "windmill-url" namespace instead +// of writing .url files to disk. This avoids Windows path issues (backslashes in +// resolve(), 8.3 short filenames, drive letter prefixes). The virtual namespace +// approach is likely better on all fronts but we keep the original .url-file loader +// on Linux to avoid breaking back-compat. +const p = { + name: "windmill-relative-resolver", + async setup(build) { + const { readFileSync } = await import("fs"); + const { resolve } = await import("node:path"); + + const base_internal_url = "BASE_INTERNAL_URL".replace( + "localhost", + "127.0.0.1" + ); + + const w_id = "W_ID"; + const current_path = "CURRENT_PATH"; + const token = "TOKEN"; + + const cdir = resolve("./"); + const cdirNoPrivate = cdir.replace(/^\/private/, ""); // for macos + // Normalize path to forward slashes to match Bun's resolver output on Windows + const cdirFwd = cdir.replace(/\\/g, "/"); + const cdirPosix = cdirFwd.replace(/^[a-zA-Z]:/, ""); + const filterResolve = new RegExp( + `^(?!\\.\/main\\.ts)(?!${cdirFwd}\/main\\.ts)(?!${cdirPosix}\/main\\.ts)(?!(?:/private)?${cdirNoPrivate}\/wrapper\\.mjs).*\\.ts$` + ); + + let cdirNodeModules = `${cdirFwd}/node_modules/`; + + const filterLoad = new RegExp(`^${cdir}\/main\\.ts$`); + const transpiler = new Bun.Transpiler({ + loader: "ts", + }); + + function replaceRelativeImports(code) { + const imports = transpiler.scanImports(code); + for (const imp of imports) { + if (imp.kind == "import-statement") { + if ( + (imp.path.startsWith(".") || + imp.path.startsWith("/u/") || + imp.path.startsWith("/f/")) && + !imp.path.endsWith(".ts") + ) { + code = code.replaceAll(imp.path, imp.path + ".ts"); + } + } + } + return { + contents: code, + }; + } + + function normalizePath(rawPath) { + return rawPath.split("/").reduce((acc, seg) => { + if (seg === "..") acc.pop(); + else if (seg !== "." && seg !== "") acc.push(seg); + return acc; + }, []).join("/"); + } + + // Resolve a windmill script import path relative to an importer path. + // Bun on Windows may prefix args with "windmill-url:" or strip leading "/". + function resolveWindmillImport(importerPath, importPath) { + const path = importPath.replace(/^windmill-url:/, "").replace(/^\//, ""); + const isAbsolute = path.startsWith("f/") || path.startsWith("u/"); + const endExt = path.endsWith(".ts") ? "" : ".ts"; + const rawScriptPath = isAbsolute + ? `${path}${endExt}` + : `${importerPath}/../${path}${endExt}`; + return { path: normalizePath(rawScriptPath), namespace: "windmill-url" }; + } + + build.onLoad({ filter: filterLoad }, async (args) => { + const code = readFileSync(args.path, "utf8"); + return replaceRelativeImports(code); + }); + + // Load windmill scripts by fetching from the API + build.onLoad({ filter: /.*/, namespace: "windmill-url" }, async (args) => { + const path = args.path.replace(/^windmill-url:/, ""); + const url = `${base_internal_url}/api/w/${w_id}/scripts/RAW_GET_ENDPOINT/p/${path}`; + const req = await fetch(url, { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + }); + if (!req.ok) { + throw new Error( + `Failed to find relative import at ${url} (status ${req.status})` + ); + } + const contents = await req.text(); + return { + contents: replaceRelativeImports(contents).contents, + loader: "tsx", + }; + }); + + // Resolve windmill script imports from the file namespace (e.g. from main.ts) + build.onResolve({ filter: filterResolve }, (args) => { + const importerFwd = args.importer?.replace(/\\/g, "/") ?? ""; + if (importerFwd.startsWith(cdirNodeModules)) { + return undefined; + } + const isMainTs = + args.importer == "./main.ts" || importerFwd.endsWith("/main.ts"); + const file_path = isMainTs + ? current_path + : importerFwd.replace(cdirFwd + "/", ""); + return resolveWindmillImport(file_path, args.path); + }); + + // Resolve nested imports from within windmill-url modules + build.onResolve({ filter: /\.ts$/, namespace: "windmill-url" }, (args) => { + const importer = args.importer.replace(/^windmill-url:/, ""); + return resolveWindmillImport(importer, args.path); + }); + }, +}; diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 30348895cf..429fd88c94 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -26,7 +26,12 @@ use windmill_queue::{ }; lazy_static::lazy_static! { - pub static ref BIN_BASH: String = std::env::var("BASH_PATH").unwrap_or_else(|_| "/bin/bash".to_string()); + pub static ref BIN_BASH: String = std::env::var("BASH_PATH").unwrap_or_else(|_| { + #[cfg(not(windows))] + { "/bin/bash".to_string() } + #[cfg(windows)] + { "bash".to_string() } + }); } const NSJAIL_CONFIG_RUN_BASH_CONTENT: &str = include_str!("../nsjail/run.bash.config.proto"); diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 08a61645ce..40f4b9f77e 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -53,7 +53,14 @@ use windmill_object_store::attempt_fetch_bytes; use windmill_parser::Typ; +// The Windows loader uses a virtual "windmill-url" namespace instead of writing .url +// files to disk, which avoids Windows path issues. The virtual namespace approach is +// likely better on all fronts but we keep the original .url-file loader on Linux to +// avoid breaking back-compat. +#[cfg(not(windows))] pub const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.js"); +#[cfg(windows)] +pub const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.windows.js"); pub const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js"); @@ -527,6 +534,8 @@ pub async fn build_loader( current_path: &str, mode: LoaderMode, ) -> Result<()> { + // Use forward slashes in JS strings to avoid backslash escape issues on Windows + let job_dir_js = job_dir.replace('\\', "/"); let loader = RELATIVE_BUN_LOADER .replace("W_ID", w_id) .replace("BASE_INTERNAL_URL", base_internal_url) @@ -549,13 +558,13 @@ import {{ readdir }} from "node:fs/promises"; let fileNames = [] try {{ - fileNames = await readdir("{job_dir}/node_modules") + fileNames = await readdir("{job_dir_js}/node_modules") }} catch (e) {{ }} try {{ await Bun.build({{ - entrypoints: ["{job_dir}/wrapper.mjs"], + entrypoints: ["{job_dir_js}/wrapper.mjs"], outdir: "./", target: "node", plugins: [p], @@ -597,7 +606,7 @@ plugin(p) try {{ await Bun.build({{ - entrypoints: ["{job_dir}/main.ts"], + entrypoints: ["{job_dir_js}/main.ts"], outdir: "./", target: "{}", plugins: [p], diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 29fff1a2ba..ddcc108659 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -53,8 +53,7 @@ fn get_windows_program_files() -> String { #[cfg(windows)] fn windows_gopath() -> String { - let tmp_dir = get_windows_tmp_dir(); - GO_CACHE_DIR.replace("/tmp", &tmp_dir).replace("/", r"\\") + GO_CACHE_DIR.replace('/', "\\") } #[cfg(windows)] diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 216c59f2cd..a1f29696c4 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -41,20 +41,30 @@ const NSJAIL_CONFIG_RUN_RUST_CONTENT: &str = include_str!("../nsjail/run.rust.co const NSJAIL_CONFIG_COMPILE_RUST_CONTENT: &str = include_str!("../nsjail/download.rust.config.proto"); +#[cfg(windows)] +const RUST_BIN_NAME: &str = "main.exe"; +#[cfg(not(windows))] +const RUST_BIN_NAME: &str = "main"; + fn find_cargo_path() -> String { if let Ok(p) = std::env::var("CARGO_PATH") { return p; } - let from_home = format!("{}/bin/cargo", CARGO_HOME.as_str()); - if std::path::Path::new(&from_home).exists() { - return from_home; - } - for p in ["/usr/local/cargo/bin/cargo", "/usr/bin/cargo"] { + let candidates = if cfg!(windows) { + vec![format!("{}\\bin\\cargo.exe", CARGO_HOME.as_str())] + } else { + vec![ + format!("{}/bin/cargo", CARGO_HOME.as_str()), + "/usr/local/cargo/bin/cargo".to_string(), + "/usr/bin/cargo".to_string(), + ] + }; + for p in &candidates { if std::path::Path::new(p).exists() { - return p.to_string(); + return p.clone(); } } - from_home + candidates.into_iter().next().unwrap() } #[cfg(not(windows))] @@ -71,7 +81,6 @@ fn find_preinstalled_dir(env_var: &str, candidates: &[&str]) -> String { } lazy_static::lazy_static! { - static ref HOME_DIR: String = std::env::var("HOME").expect("Could not find the HOME environment variable"); static ref CARGO_HOME: String = std::env::var("CARGO_HOME").unwrap_or_else(|_| { CARGO_HOME_DEFAULT.clone() }); static ref RUSTUP_HOME: String = std::env::var("RUSTUP_HOME").unwrap_or_else(|_| { RUSTUP_HOME_DEFAULT.clone() }); static ref CARGO_PATH: String = find_cargo_path(); @@ -81,14 +90,14 @@ lazy_static::lazy_static! { #[cfg(windows)] lazy_static::lazy_static! { - static ref CARGO_HOME_DEFAULT: String = format!("{}\\.cargo", *HOME_DIR); - static ref RUSTUP_HOME_DEFAULT: String = format!("{}\\.rustup", *HOME_DIR); + static ref CARGO_HOME_DEFAULT: String = format!("{}\\.cargo", HOME_ENV.as_str()); + static ref RUSTUP_HOME_DEFAULT: String = format!("{}\\.rustup", HOME_ENV.as_str()); } #[cfg(not(windows))] lazy_static::lazy_static! { - static ref CARGO_HOME_DEFAULT: String = format!("{}/.cargo", *HOME_DIR); - static ref RUSTUP_HOME_DEFAULT: String = format!("{}/.rustup", *HOME_DIR); + static ref CARGO_HOME_DEFAULT: String = format!("{}/.cargo", HOME_ENV.as_str()); + static ref RUSTUP_HOME_DEFAULT: String = format!("{}/.rustup", HOME_ENV.as_str()); } const RUST_OBJECT_STORE_PREFIX: &str = "rustbin/"; @@ -97,11 +106,11 @@ const RUST_OBJECT_STORE_PREFIX: &str = "rustbin/"; lazy_static::lazy_static! { static ref PREINSTALLED_CARGO: String = find_preinstalled_dir( "CARGO_PREINSTALL_DIR", - &["/usr/local/cargo", &format!("{}/.cargo", *HOME_DIR)], + &["/usr/local/cargo", &format!("{}/.cargo", HOME_ENV.as_str())], ); static ref PREINSTALLED_RUSTUP: String = find_preinstalled_dir( "RUSTUP_PREINSTALL_DIR", - &["/usr/local/rustup", &format!("{}/.rustup", *HOME_DIR)], + &["/usr/local/rustup", &format!("{}/.rustup", HOME_ENV.as_str())], ); } @@ -521,6 +530,13 @@ pub async fn build_rust_crate( std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()), ); build_rust_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str()); + // MSVC linker needs LIB and INCLUDE to find kernel32.lib etc. + if let Ok(lib) = std::env::var("LIB") { + build_rust_cmd.env("LIB", lib); + } + if let Ok(include) = std::env::var("INCLUDE") { + build_rust_cmd.env("INCLUDE", include); + } } start_child_process(build_rust_cmd, CARGO_PATH.as_str(), false).await? }; @@ -545,30 +561,29 @@ pub async fn build_rust_crate( tokio::fs::copy( &format!( - "{build_dir}/target/{}/main", + "{build_dir}/target/{}/{RUST_BIN_NAME}", if is_preview { "debug" } else { "release" }, ), - format! {"{job_dir}/main"}, + format!("{job_dir}/{RUST_BIN_NAME}"), ) .await .map_err(|e| { Error::ExecutionErr(format!( - "could not copy built binary from [...]/target/.../main to {job_dir}/main: {e:?}" + "could not copy built binary from [...]/target/.../{RUST_BIN_NAME} to {job_dir}/{RUST_BIN_NAME}: {e:?}" )) })?; match save_cache( &bin_path, &format!("{RUST_OBJECT_STORE_PREFIX}{hash}"), - &format!("{job_dir}/main"), + &format!("{job_dir}/{RUST_BIN_NAME}"), false, ) .await { Err(e) => { let em = format!( - "could not save {bin_path} to {} to rust cache: {e:?}", - format!("{job_dir}/main"), + "could not save {bin_path} to {job_dir}/{RUST_BIN_NAME} to rust cache: {e:?}", ); tracing::error!(em); Ok(em) @@ -618,16 +633,16 @@ pub async fn handle_rust_job( let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await; let cache_logs = if cache { - let target = format!("{job_dir}/main"); + let target = format!("{job_dir}/{RUST_BIN_NAME}"); #[cfg(unix)] let symlink = std::os::unix::fs::symlink(&bin_path, &target); #[cfg(windows)] - let symlink = std::os::windows::fs::symlink_dir(&bin_path, &target); + let symlink = std::os::windows::fs::symlink_file(&bin_path, &target); symlink.map_err(|e| { Error::ExecutionErr(format!( - "could not copy cached binary from {bin_path} to {job_dir}/main: {e:?}" + "could not copy cached binary from {bin_path} to {target}: {e:?}" )) })?; @@ -694,7 +709,7 @@ pub async fn handle_rust_job( .stderr(Stdio::piped()); start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { - let compiled_executable_name = "./main"; + let compiled_executable_name = &format!("{job_dir}/{RUST_BIN_NAME}"); let mut run_rust = build_command_with_isolation(compiled_executable_name, &[]); run_rust .current_dir(job_dir) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 49a1048afd..1497d3ebb8 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -563,7 +563,16 @@ lazy_static::lazy_static! { pub static ref DOTNET_PATH: String = std::env::var("DOTNET_PATH").unwrap_or_else(|_| DOTNET_DEFAULT_PATH.to_string()); pub static ref NSJAIL_PATH: String = std::env::var("NSJAIL_PATH").unwrap_or_else(|_| "nsjail".to_string()); pub static ref PATH_ENV: String = std::env::var("PATH").unwrap_or_else(|_| String::new()); - pub static ref HOME_ENV: String = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + pub static ref HOME_ENV: String = { + #[cfg(not(windows))] + { std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) } + #[cfg(windows)] + { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string()) + } + }; pub static ref GIT_PATH: String = std::env::var("GIT_PATH").unwrap_or_else(|_| "/usr/bin/git".to_string()); pub static ref NODE_PATH: Option = std::env::var("NODE_PATH").ok(); From eab789beeb091ef46e68f4eb61f87578347b1f86 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 5 Mar 2026 06:13:42 +0100 Subject: [PATCH 23/58] chore: upgrade rquickjs from 0.8 to 0.11 (#8233) Co-authored-by: Claude Opus 4.6 --- backend/Cargo.lock | 53 ++++++++++++++++++++++++---------------------- backend/Cargo.toml | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e52bb12fa1..d853faa2a0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1900,7 +1900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" dependencies = [ "once_cell", - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -2550,6 +2550,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cooked-waker" version = "5.0.0" @@ -8683,7 +8692,7 @@ dependencies = [ "darling 0.20.11", "heck 0.5.0", "num-bigint", - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro-error2", "proc-macro2", "quote", @@ -9252,7 +9261,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -10331,16 +10340,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -11094,9 +11093,12 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relative-path" -version = "1.9.3" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] [[package]] name = "rend" @@ -11390,9 +11392,9 @@ dependencies = [ [[package]] name = "rquickjs" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c" +checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a" dependencies = [ "rquickjs-core", "rquickjs-macro", @@ -11400,26 +11402,27 @@ dependencies = [ [[package]] name = "rquickjs-core" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b" +checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" dependencies = [ "async-lock", + "hashbrown 0.16.0", "relative-path", "rquickjs-sys", ] [[package]] name = "rquickjs-macro" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3" +checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80" dependencies = [ - "convert_case 0.6.0", + "convert_case 0.10.0", "fnv", "ident_case", "indexmap 2.11.1", - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", "rquickjs-core", @@ -11428,9 +11431,9 @@ dependencies = [ [[package]] name = "rquickjs-sys" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a" +checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83" dependencies = [ "cc", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 195d8bd30d..bc1ddf9829 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -512,7 +512,7 @@ nu-parser = { version = "0.101.0", default-features = false } globset = "0.4.16" croner = "2.2.0" rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] } -rquickjs = { version = "0.8", features = ["futures", "parallel", "macro"] } +rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] } process-wrap = { version = "8.2.1", features = ["tokio1"] } systemstat = "0.2.4" From e56ccd200be29e6ac8ea2b04a341b1ce78a307f6 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 5 Mar 2026 06:22:46 +0100 Subject: [PATCH 24/58] feat: token expiration notifications (#8190) * feat: add token expiration notifications via email, critical alerts, and webhooks - Monitor loop checks for tokens expiring within 7 days and sends email notifications to token owners. Tracks notification state via new `expiry_notified` column on the token table to avoid duplicates. - When tokens expire and are deleted, owners are also notified. - Critical alerts (in-app UI) are gated behind a new instance setting `critical_alerts_on_token_expiry` (off by default); emails are always sent regardless of the setting. - Add TokenExpiringSoon and TokenExpired webhook message variants for workspace webhook integrations. - Frontend: show expiration badges and a warning banner on the tokens table for tokens expiring within 30 days. - Exclude session and ephemeral tokens from all notifications. Co-Authored-By: Claude Opus 4.6 * refactor: use separate token_expiry_notification table for dedup - Replace `expiry_notified` column on token table with a dedicated `token_expiry_notification` table (token, expiration) - Insert notification row on token creation via shared `register_token_expiry_notification()` helper - Delete notification row atomically when sending the notification - Clean up orphaned rows in `delete_expired_items()` - No FK constraint to avoid cascade overhead on token deletions - Add index on expiration column for efficient range queries Co-Authored-By: Claude Opus 4.6 * fix: calendar-based expiration badge and move notification cleanup - Fix daysUntilExpiration to compare calendar dates instead of time diff - Move notification row cleanup from delete_expired_items to check_expiring_tokens to keep it off the hot path - Use simple expiration <= now() index scan instead of NOT EXISTS join Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...da8d6a3b425ea0590a66f1db6692dd2ddb437.json | 15 ++ ...856a39c89cdc658b09c478050de5145a45ca4.json | 12 ++ ...aa27e233e60e18b3d35545005eb680701241f.json | 38 ++++ ...7288b39ea7c802007f112eb3d62230d07abb6.json | 38 ++++ ...2000000_add_token_expiry_notified.down.sql | 1 + ...302000000_add_token_expiry_notified.up.sql | 8 + backend/src/main.rs | 23 ++- backend/src/monitor.rs | 167 ++++++++++++++++-- backend/summarized_schema.txt | 2 + backend/windmill-api-auth/src/lib.rs | 33 ++++ backend/windmill-api-users/src/users.rs | 8 + .../windmill-common/src/global_settings.rs | 1 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/webhook.rs | 146 ++++++++++++--- .../src/lib/components/instanceSettings.ts | 9 + .../components/settings/TokensTable.svelte | 61 ++++++- 16 files changed, 509 insertions(+), 54 deletions(-) create mode 100644 backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json create mode 100644 backend/.sqlx/query-a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4.json create mode 100644 backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json create mode 100644 backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json create mode 100644 backend/migrations/20260302000000_add_token_expiry_notified.down.sql create mode 100644 backend/migrations/20260302000000_add_token_expiry_notified.up.sql diff --git a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json b/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json new file mode 100644 index 0000000000..af35d619fa --- /dev/null +++ b/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437" +} diff --git a/backend/.sqlx/query-a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4.json b/backend/.sqlx/query-a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4.json new file mode 100644 index 0000000000..00604f4bc9 --- /dev/null +++ b/backend/.sqlx/query-a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token_expiry_notification WHERE expiration <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4" +} diff --git a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json b/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json new file mode 100644 index 0000000000..9085383617 --- /dev/null +++ b/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_prefix", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + true, + true, + true + ] + }, + "hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f" +} diff --git a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json b/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json new file mode 100644 index 0000000000..015aa7b05a --- /dev/null +++ b/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_prefix", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + true, + true, + true + ] + }, + "hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6" +} diff --git a/backend/migrations/20260302000000_add_token_expiry_notified.down.sql b/backend/migrations/20260302000000_add_token_expiry_notified.down.sql new file mode 100644 index 0000000000..ab827c5de5 --- /dev/null +++ b/backend/migrations/20260302000000_add_token_expiry_notified.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS token_expiry_notification; diff --git a/backend/migrations/20260302000000_add_token_expiry_notified.up.sql b/backend/migrations/20260302000000_add_token_expiry_notified.up.sql new file mode 100644 index 0000000000..61883f070d --- /dev/null +++ b/backend/migrations/20260302000000_add_token_expiry_notified.up.sql @@ -0,0 +1,8 @@ +-- Tracks pending expiry notifications: row exists = not yet notified. +-- Deleted once the notification is sent. Orphaned rows are harmless (filtered out by the join). +CREATE TABLE token_expiry_notification ( + token VARCHAR(255) PRIMARY KEY, + expiration TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_token_expiry_notification_expiration ON token_expiry_notification (expiration); diff --git a/backend/src/main.rs b/backend/src/main.rs index 97980484a5..c8a1021ced 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -38,11 +38,11 @@ use windmill_common::{ agent_workers::AgentConfig, global_settings::{ APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, - CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, - CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, - DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, - EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, + CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, + CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, + DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, + ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, @@ -99,10 +99,10 @@ use crate::monitor::{ load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db, reload_app_workspaced_route_setting, reload_base_url_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, - reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting, - reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, - reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, + reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, + reload_extra_pip_index_url_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, + reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, + reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration, }; @@ -1717,6 +1717,11 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload critical alert UI setting"); } } + CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING => { + if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await { + tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting"); + } + } "workspace_telemetry_enabled" => { // Read the new value from the database and log it let enabled = sqlx::query_scalar!( diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 6baba34a02..37190a5f7a 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -44,19 +44,20 @@ use windmill_common::{ apps::APP_WORKSPACED_ROUTE, auth::create_token_for_owner, ee_oss::CriticalErrorChannel, + email_oss::send_email_if_possible, error, flow_status::{FlowStatus, FlowStatusModule}, global_settings::{ BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, - DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, - JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, - KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, - NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, + CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, + CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, + INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, + JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, + MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, + NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, + POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, @@ -76,10 +77,11 @@ use windmill_common::{ DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, }, - KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED, - CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, - METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, - OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, + KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, + CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, + HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED, + MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, + SERVICE_LOG_RETENTION_SECS, }; use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING}; #[cfg(feature = "parquet")] @@ -207,6 +209,10 @@ pub async fn initial_load( tracing::error!("Error loading critical alert mute ui setting: {e:#}"); } + if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await { + tracing::error!("Error loading critical alerts on token expiry setting: {e:#}"); + } + if let Some(db) = conn.as_sql() { if let Err(e) = load_tag_per_workspace_enabled(db).await { tracing::error!("Error loading default tag per workpsace: {e:#}"); @@ -477,6 +483,21 @@ pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error:: Ok(()) } +pub async fn reload_critical_alerts_on_token_expiry_setting( + conn: &Connection, +) -> error::Result<()> { + if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn( + conn, + CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, + true, + ) + .await + { + CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed); + } + Ok(()) +} + pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> { let metrics_enabled = load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await; @@ -845,18 +866,82 @@ struct LogFile { hostname: String, } +struct TokenRow { + token_prefix: Option, + label: Option, + email: Option, + workspace_id: Option, +} + +fn is_user_token(label: Option<&str>) -> bool { + match label { + None => true, + Some(l) => l != "session" && !l.starts_with("ephemeral") && !l.starts_with("Ephemeral"), + } +} + +async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) { + if !is_user_token(token.label.as_deref()) { + return; + } + let prefix = token.token_prefix.as_deref().unwrap_or("??????????"); + let email_addr = token.email.as_deref().unwrap_or("unknown"); + let token_desc = match token.label.as_deref() { + Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"), + _ => format!("{prefix}****"), + }; + + let (alert_message, email_subject, email_body) = if expired { + ( + format!( + "API token {token_desc} of '{email_addr}' has expired and been deleted" + ), + "Windmill: Your API token has expired", + format!( + "Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access." + ), + ) + } else { + ( + format!("API token {token_desc} of '{email_addr}' is expiring soon"), + "Windmill: Your API token is expiring soon", + format!( + "Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption." + ), + ) + }; + + tracing::info!("{}", alert_message); + if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) { + report_critical_error( + alert_message, + db.clone(), + token.workspace_id.as_deref(), + None, + ) + .await; + } + if let Some(email) = &token.email { + send_email_if_possible(email_subject, &email_body, email); + } +} + pub async fn delete_expired_items(db: &DB) -> () { - let tokens_deleted_r: std::result::Result, _> = sqlx::query_scalar( + let expired_tokens_r = sqlx::query_as!( + TokenRow, "DELETE FROM token WHERE expiration <= now() - RETURNING concat(substring(token for 10), '*****')", + RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", ) .fetch_all(db) .await; - match tokens_deleted_r { + match expired_tokens_r { Ok(tokens) => { - if tokens.len() > 0 { - tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens) + if !tokens.is_empty() { + tracing::info!("deleted {} expired tokens", tokens.len()); + for t in &tokens { + report_token_expiration(db, t, true).await; + } } } Err(e) => tracing::error!("Error deleting token: {}", e.to_string()), @@ -1065,6 +1150,41 @@ pub async fn delete_expired_items(db: &DB) -> () { } } +pub async fn check_expiring_tokens(db: &DB) { + // Find tokens expiring within 7 days that still have a pending notification row + let expiring_tokens_r = sqlx::query_as!( + TokenRow, + "DELETE FROM token_expiry_notification n + USING token t + WHERE n.token = t.token + AND n.expiration > now() + AND n.expiration <= now() + interval '7 days' + RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id", + ) + .fetch_all(db) + .await; + + match expiring_tokens_r { + Ok(tokens) => { + for t in &tokens { + report_token_expiration(db, t, false).await; + } + if !tokens.is_empty() { + tracing::info!("Sent expiration warnings for {} token(s)", tokens.len()); + } + } + Err(e) => tracing::error!("Error checking expiring tokens: {}", e), + } + + // Clean up notification rows whose expiration has passed + if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()") + .execute(db) + .await + { + tracing::error!("Error cleaning up expired token notifications: {}", e); + } +} + /// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments. /// Uses a single transaction per batch to minimize lock duration. /// Returns the number of jobs deleted in this batch. @@ -2052,6 +2172,16 @@ pub async fn monitor_db( } }; + // Run every hour (10 iterations * 30s = 5 minutes) + // Check for tokens expiring within 7 days and send alerts + let check_expiring_tokens_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) { + if let Some(db) = conn.as_sql() { + check_expiring_tokens(&db).await; + } + } + }; + join!( expired_items_f, zombie_jobs_f, @@ -2073,6 +2203,7 @@ pub async fn monitor_db( cleanup_worker_group_stats_f, native_triggers_sync_f, cleanup_notify_events_f, + check_expiring_tokens_f, ); } diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 01db7590f5..3ece1ce7ef 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -151,6 +151,8 @@ sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attri FK: (workspace_id) -> workspace(id) token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) FK: (workspace_id) -> workspace(id) +token_expiry_notification: token(char), expiration(ts) + INDEX: idx_token_expiry_notification_expiration (expiration) tutorial_progress: email(char), progress(bit64), skipped_all(bool) unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts) usage: id(char), is_workspace(bool), month_(int), usage(int) diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 5acb696bd4..d5ec56d00e 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -557,6 +557,14 @@ pub async fn create_token_internal( )); } + register_token_expiry_notification( + &mut *tx, + &token, + token_config.label.as_deref(), + token_config.expiration, + ) + .await; + audit_log( &mut *tx, authed, @@ -572,6 +580,31 @@ pub async fn create_token_internal( Ok(token) } +/// Insert a pending expiry notification row for user tokens that have an expiration. +pub async fn register_token_expiry_notification( + tx: &mut sqlx::PgConnection, + token: &str, + label: Option<&str>, + expiration: Option>, +) { + let Some(expiration) = expiration else { return }; + if label == Some("session") + || label.is_some_and(|l| l.starts_with("ephemeral") || l.starts_with("Ephemeral")) + { + return; + } + if let Err(e) = sqlx::query!( + "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + token, + expiration, + ) + .execute(&mut *tx) + .await + { + tracing::error!("Failed to register token expiry notification: {}", e); + } +} + // ------------ Permission helpers ------------ pub fn get_perm_in_extra_perms_for_authed( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 17ad6cd7fb..ecd1c5fbd3 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1850,6 +1850,14 @@ async fn impersonate( .execute(&mut *tx) .await?; + windmill_api_auth::register_token_expiry_notification( + &mut *tx, + &token, + new_token.label.as_deref(), + new_token.expiration, + ) + .await; + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index d4d8163ff2..b2d961e173 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -48,6 +48,7 @@ pub const DISABLE_HUB_SETTING: &str = "disable_hub"; pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels"; pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui"; pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_oversize"; +pub const CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING: &str = "critical_alerts_on_token_expiry"; pub const DEV_INSTANCE_SETTING: &str = "dev_instance"; pub const JWT_SECRET_SETTING: &str = "jwt_secret"; pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 643e08fc82..c4e0a47cd8 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -193,6 +193,7 @@ lazy_static::lazy_static! { pub static ref METRICS_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); pub static ref CRITICAL_ALERT_MUTE_UI_ENABLED: AtomicBool = AtomicBool::new(false); + pub static ref CRITICAL_ALERTS_ON_TOKEN_EXPIRY: AtomicBool = AtomicBool::new(false); pub static ref BASE_URL: Arc> = Arc::new(RwLock::new("".to_string())); pub static ref IS_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); diff --git a/backend/windmill-common/src/webhook.rs b/backend/windmill-common/src/webhook.rs index f1f8508f74..4677084fbe 100644 --- a/backend/windmill-common/src/webhook.rs +++ b/backend/windmill-common/src/webhook.rs @@ -39,29 +39,115 @@ pub enum WebhookPayload { #[serde(tag = "type")] pub enum WebhookMessage { // See https://serde.rs/enum-representations.html#internally-tagged for how this looks in JSON - CreateApp { workspace: String, path: String }, - DeleteApp { workspace: String, path: String }, - UpdateApp { workspace: String, old_path: String, new_path: String }, - CreateFlow { workspace: String, path: String }, - UpdateFlow { workspace: String, old_path: String, new_path: String }, - ArchiveFlow { workspace: String, path: String }, - DeleteFlow { workspace: String, path: String }, - CreateFolder { workspace: String, name: String }, - UpdateFolder { workspace: String, name: String }, - DeleteFolder { workspace: String, name: String }, - DeleteResource { workspace: String, path: String }, - CreateResource { workspace: String, path: String }, - UpdateResource { workspace: String, old_path: String, new_path: String }, - CreateResourceType { name: String }, - DeleteResourceType { name: String }, - UpdateResourceType { name: String }, - CreateScript { workspace: String, path: String, hash: String }, - UpdateScript { workspace: String, path: String, hash: String }, - DeleteScript { workspace: String, hash: String }, - DeleteScriptPath { workspace: String, path: String }, - CreateVariable { workspace: String, path: String }, - UpdateVariable { workspace: String, old_path: String, new_path: String }, - DeleteVariable { workspace: String, path: String }, + CreateApp { + workspace: String, + path: String, + }, + DeleteApp { + workspace: String, + path: String, + }, + UpdateApp { + workspace: String, + old_path: String, + new_path: String, + }, + CreateFlow { + workspace: String, + path: String, + }, + UpdateFlow { + workspace: String, + old_path: String, + new_path: String, + }, + ArchiveFlow { + workspace: String, + path: String, + }, + DeleteFlow { + workspace: String, + path: String, + }, + CreateFolder { + workspace: String, + name: String, + }, + UpdateFolder { + workspace: String, + name: String, + }, + DeleteFolder { + workspace: String, + name: String, + }, + DeleteResource { + workspace: String, + path: String, + }, + CreateResource { + workspace: String, + path: String, + }, + UpdateResource { + workspace: String, + old_path: String, + new_path: String, + }, + CreateResourceType { + name: String, + }, + DeleteResourceType { + name: String, + }, + UpdateResourceType { + name: String, + }, + CreateScript { + workspace: String, + path: String, + hash: String, + }, + UpdateScript { + workspace: String, + path: String, + hash: String, + }, + DeleteScript { + workspace: String, + hash: String, + }, + DeleteScriptPath { + workspace: String, + path: String, + }, + CreateVariable { + workspace: String, + path: String, + }, + UpdateVariable { + workspace: String, + old_path: String, + new_path: String, + }, + DeleteVariable { + workspace: String, + path: String, + }, + TokenExpiringSoon { + workspace: String, + token_prefix: String, + label: String, + owner: String, + expires_at: String, + days_remaining: i64, + }, + TokenExpired { + workspace: String, + token_prefix: String, + label: String, + owner: String, + }, } #[derive(Clone)] @@ -267,6 +353,20 @@ mod tests { new_path: "n".into(), }, WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() }, + WebhookMessage::TokenExpiringSoon { + workspace: "w".into(), + token_prefix: "abc1234567".into(), + label: "my-token".into(), + owner: "user@example.com".into(), + expires_at: "2026-03-10T00:00:00Z".into(), + days_remaining: 7, + }, + WebhookMessage::TokenExpired { + workspace: "w".into(), + token_prefix: "abc1234567".into(), + label: "my-token".into(), + owner: "user@example.com".into(), + }, ]; for msg in &messages { diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 25955b74db..80017582f5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -571,6 +571,15 @@ export const settings: Record = { requiresReloadOnChange: true, ee_only: 'Critical alerts in UI are only available in the EE version' }, + { + label: 'Alert on token expiry', + description: + 'Send critical alerts when API tokens are about to expire (within 7 days) or have expired', + key: 'critical_alerts_on_token_expiry', + fieldType: 'boolean', + storage: 'setting', + ee_only: '' + }, { label: 'Slack', key: 'slack', diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 8076b9a17a..af615baa15 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -5,6 +5,8 @@ import { sendUserToast } from '$lib/toast' import CreateToken from './CreateToken.svelte' import Button from '../common/button/Button.svelte' + import Badge from '../common/badge/Badge.svelte' + import Alert from '../common/alert/Alert.svelte' import { Trash } from 'lucide-svelte' // --- Props --- @@ -35,6 +37,46 @@ listTokens() }) + function isUserToken(label: string | undefined): boolean { + if (!label) return true + return label !== 'session' && !label.toLowerCase().startsWith('ephemeral') + } + + function daysUntilExpiration(expiration: string | undefined): number | null { + if (!expiration) return null + const today = new Date() + today.setHours(0, 0, 0, 0) + const exp = new Date(expiration) + exp.setHours(0, 0, 0, 0) + return Math.round((exp.getTime() - today.getTime()) / 86400000) + } + + function expirationBadge( + expiration: string | undefined, + label: string | undefined + ): { + color: 'red' | 'orange' | 'yellow' | 'gray' + text: string + } | null { + if (!isUserToken(label)) return null + const days = daysUntilExpiration(expiration) + if (days === null) return null + if (days < 0) return { color: 'red', text: 'Expired' } + if (days === 0) return { color: 'red', text: 'Expires today' } + if (days === 1) return { color: 'orange', text: 'Expires tomorrow' } + if (days <= 7) return { color: 'orange', text: `Expires in ${days}d` } + if (days <= 30) return { color: 'yellow', text: `Expires in ${days}d` } + return null + } + + let expiringSoonCount = $derived( + tokens.filter((t) => { + if (!isUserToken(t.label)) return false + const days = daysUntilExpiration(t.expiration) + return days !== null && days >= 0 && days <= 7 + }).length + ) + function handleTokenCreated(token: string) { onTokenCreated(token) listTokens() @@ -70,6 +112,11 @@ Authenticate to the Windmill API with access tokens. + {#if expiringSoonCount > 0} + + + + {/if} {#if tokens && tokens.length > 0} - {#each tokens as { token_prefix, expiration, label, scopes }} + {#each tokens as { token_prefix, expiration, label, scopes } (token_prefix)} + {@const badge = expirationBadge(expiration, label)} {token_prefix}**** {label ?? ''} - {displayDate(expiration ?? '')} + + + {displayDate(expiration ?? '')} + {#if badge} + {badge.text} + {/if} + + {scopes?.join(', ') ?? ''} Date: Thu, 5 Mar 2026 06:29:05 +0100 Subject: [PATCH 25/58] chore(main): release 1.650.0 (#8218) * chore(main): release 1.650.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 21 +++ backend/Cargo.lock | 144 +++++++++--------- backend/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.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 +- 15 files changed, 108 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae23b08efa..e8f1df09f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.650.0](https://github.com/windmill-labs/windmill/compare/v1.649.0...v1.650.0) (2026-03-05) + + +### Features + +* add move, delete, and duplicate to flow node context menu ([#8050](https://github.com/windmill-labs/windmill/issues/8050)) ([c0c9388](https://github.com/windmill-labs/windmill/commit/c0c9388415716ce77d841bd08a46f94e0a529685)) +* add variable and resource types to flow env variables ([#8214](https://github.com/windmill-labs/windmill/issues/8214)) ([164e499](https://github.com/windmill-labs/windmill/commit/164e499c64dc5eb76fcfb0f8cefbad2df244f610)) +* Ducklake typechecker ([#8118](https://github.com/windmill-labs/windmill/issues/8118)) ([53caecf](https://github.com/windmill-labs/windmill/commit/53caecf1da8d76e246178dfb9b86d330f0ec52fd)) +* make WINDMILL_DIR configurable via environment variable ([#8215](https://github.com/windmill-labs/windmill/issues/8215)) ([424ca59](https://github.com/windmill-labs/windmill/commit/424ca59dfe3e730f5388d9cac4ea7e69773614d3)) +* make WM_END_USER_EMAIL display users from different workspaces ([#8208](https://github.com/windmill-labs/windmill/issues/8208)) ([baf2bcf](https://github.com/windmill-labs/windmill/commit/baf2bcf14da0c8c95bdbbf511fcaee48be33948b)) +* persistent Db manager state in URI ([#8134](https://github.com/windmill-labs/windmill/issues/8134)) ([4bf827b](https://github.com/windmill-labs/windmill/commit/4bf827bea4d44aca8c5ff7aa67ad449dbcf00673)) +* replace hub error toasts with warning alerts and add disable hub setting ([#8225](https://github.com/windmill-labs/windmill/issues/8225)) ([63ebae8](https://github.com/windmill-labs/windmill/commit/63ebae8829a6dc47a4e23c8670b514f042c9d4be)) +* token expiration notifications ([#8190](https://github.com/windmill-labs/windmill/issues/8190)) ([e56ccd2](https://github.com/windmill-labs/windmill/commit/e56ccd200be29e6ac8ea2b04a341b1ce78a307f6)) + + +### Bug Fixes + +* handle multipart stream errors gracefully instead of panicking ([#8226](https://github.com/windmill-labs/windmill/issues/8226)) ([19c065b](https://github.com/windmill-labs/windmill/commit/19c065bed5468c484c8e7a50a6b79ab90153cc0e)) +* improve windows compatibility ([077779e](https://github.com/windmill-labs/windmill/commit/077779ec52f7d3e5fcc93951544bf47bd6dc30b6)) +* wrap set_encryption_key in a single database transaction ([#8212](https://github.com/windmill-labs/windmill/issues/8212)) ([62382fd](https://github.com/windmill-labs/windmill/commit/62382fd2869ea0190dd0c0b714f9cbd35ceddd7a)) + ## [1.649.0](https://github.com/windmill-labs/windmill/compare/v1.648.0...v1.649.0) (2026-03-03) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d853faa2a0..c7bba49b17 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -10709,9 +10709,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-nats", @@ -15805,7 +15805,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15818,7 +15818,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "argon2", @@ -15956,7 +15956,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15979,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16018,7 +16018,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.649.0" +version = "1.650.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16045,7 +16045,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16068,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16107,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16127,7 +16127,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16147,7 +16147,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16161,7 +16161,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-nats", @@ -16188,7 +16188,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16213,7 +16213,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16231,7 +16231,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16252,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16272,7 +16272,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16302,7 +16302,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16329,7 +16329,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.649.0" +version = "1.650.0" dependencies = [ "lazy_static", "serde", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.649.0" +version = "1.650.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16364,7 +16364,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16378,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.649.0" +version = "1.650.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16409,7 +16409,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.649.0" +version = "1.650.0" dependencies = [ "chrono", "lazy_static", @@ -16423,7 +16423,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16442,7 +16442,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.649.0" +version = "1.650.0" dependencies = [ "aes-gcm", "anyhow", @@ -16541,7 +16541,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.649.0" +version = "1.650.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16560,7 +16560,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.649.0" +version = "1.650.0" dependencies = [ "regex", "serde", @@ -16575,7 +16575,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16599,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "futures", @@ -16616,7 +16616,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.649.0" +version = "1.650.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16632,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -16653,7 +16653,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -16684,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-oauth2", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-stream", @@ -16742,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "futures", @@ -16760,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.649.0" +version = "1.650.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16769,7 +16769,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "lazy_static", @@ -16781,7 +16781,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "serde_json", @@ -16793,7 +16793,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "gosyn", @@ -16805,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "lazy_static", @@ -16817,7 +16817,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "serde_json", @@ -16829,7 +16829,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "nu-parser", @@ -16840,7 +16840,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16851,7 +16851,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16864,7 +16864,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-recursion", @@ -16888,7 +16888,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "lazy_static", @@ -16902,7 +16902,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "lazy_static", @@ -16934,7 +16934,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "lazy_static", @@ -16953,7 +16953,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "serde", @@ -16964,7 +16964,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-recursion", @@ -17001,7 +17001,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "const_format", @@ -17039,7 +17039,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.649.0" +version = "1.650.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17050,7 +17050,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-recursion", @@ -17079,7 +17079,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17102,7 +17102,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17135,7 +17135,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17155,7 +17155,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17189,7 +17189,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17224,7 +17224,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17247,7 +17247,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17271,7 +17271,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-nats", @@ -17295,7 +17295,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17330,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-trait", @@ -17381,7 +17381,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17399,7 +17399,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.649.0" +version = "1.650.0" dependencies = [ "anyhow", "async-once-cell", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index bc1ddf9829..9e8694fbd0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.649.0" +version = "1.650.0" authors.workspace = true edition.workspace = true @@ -76,7 +76,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.649.0" +version = "1.650.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 163217862e..4e24fd6a4b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.649.0 + version: 1.650.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index cc1417c4b3..b326fa80a7 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.649.0"; +export const VERSION = "v1.650.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index b584c540d8..00300458cd 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -67,7 +67,7 @@ export { workspaceAdd, }; -export const VERSION = "1.649.0"; +export const VERSION = "1.650.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c24e48e02d..e4f18ac398 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.649.0", + "version": "1.650.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.649.0", + "version": "1.650.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index d6c6554459..71c037990d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.649.0", + "version": "1.650.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 37aecedea0..040d21fc67 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.649.0" +wmill = ">=1.650.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 5031c7e889..efb208b861 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.649.0 + version: 1.650.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ec7cea297d..b26a90786a 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.649.0' + ModuleVersion = '1.650.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 368b0de11b..4490cfb938 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.649.0" +version = "1.650.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 8b07a94942..15c2a5848d 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.649.0", + "version": "1.650.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a5cd2f4395..4651fcb98b 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.649.0", + "version": "1.650.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index c34844244a..296188f528 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.649.0 +1.650.0 From 5f0ef936d1d5d07d01c8e07e26ec254feebef8fb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 5 Mar 2026 07:19:51 +0100 Subject: [PATCH 26/58] feat: add sandbox annotations, volume mounts, for AI sandbox starting with claude (#8058) --- Dockerfile | 6 + ...d55e0379cc84188954037165cbe2d198ef71f.json | 16 + ...5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11.json} | 7 +- ...c6e9b159a54c179cecb108068597536835f7e.json | 22 + ...6c595401dd2e1f7699a28bf3b79db5e3841f4.json | 23 + ...28e666f6fbd1ec5ee9d30ee0cdb5e30a68750.json | 23 + ...11a1dfb1422187200119268b2342b47a960c6.json | 25 + ...ef31e6d647377d37747f7bdc834748a59419e.json | 15 + ...6fc8ce983f857339e6fccf799dc6587964aab.json | 17 + ...485957990eb92fa3ce515895eab0d3f28bfdc.json | 25 + ...580b4c7ba25f279644e3233b63f4f6db0ad98.json | 16 + ...2eb169329116cba57780fa90ecf2bdb910f34.json | 18 + ...367b27171894016c714e41497e69115be1468.json | 76 +++ ...0be7046cf8789d842717b6c793c22a2a05daa.json | 23 + ...67cd897dca4722e4f2076308afdb7ee9fc147.json | 29 + ...8bca8450d09982e582266d215dff521256fa6.json | 22 + ...e919cd25538c0b433bc29bb052c7a7b8568ca.json | 18 + ...e142006825a3911addacdf1a026660b5e2b7f.json | 16 + ...03b566fc75e16e0b7a81204816fd50b3346a5.json | 24 + ...9ba8973a2ef1defa36c6d46d9c1c6406a7c33.json | 16 + ...eea76a8a3079ce80c035087f797cdc410f35b.json | 17 + ...d75ca7014b431239ac1b681f2b26380c719c4.json | 23 + ...050ba7b7e4d9eabba03be251ae9a8017b317d.json | 16 + ...53fd6e9c7606231ad3c522e98cb1fcc14361a.json | 23 + ...425e5f7600e28c728a06235f7ff430a4bd77a.json | 23 + ...9fc2c73e855790e17abf5461b96ea30fbbdb7.json | 23 + ...03b2601630f2d31458bdaf70c2702b2998d89.json | 16 + ...156ada29ae320465d9790dce7e1e8a436d4de.json | 15 + ...2550919fcac56deaf1b3cb36b3e15117936e7.json | 29 + ...263e7168fa325c5ceb44e9dd1595bdb7e7ce6.json | 24 + ...6719fc41b2ca5cb0b7ac5ad73ab01b650c935.json | 17 + ...e78697414b35618c50f8693f4e804bf1d7dbb.json | 29 + ...070c9f117da3dd00ce3247c54a61052a6809c.json | 47 ++ ...8d6affad03a1f388024806a5de3f9cc939c04.json | 23 + ...f33554794444f0dc4e2d2fec158eca5ebe865.json | 28 + ...511b9d3183cb91b740a86944a77a2a964b57d.json | 17 + ...706d78a6f24cb0e614d7d81ba1b643805bf06.json | 22 + ...359b064baf73b890efdc25426261d4eadfee0.json | 23 + ...c5a30037abef63efb5b44dc535c5f45d62a06.json | 41 ++ backend/Cargo.lock | 22 + backend/Cargo.toml | 5 + backend/ee-repo-ref.txt | 2 +- .../20260226000000_add_volumes.down.sql | 1 + .../20260226000000_add_volumes.up.sql | 22 + .../windmill-parser/src/asset_parser.rs | 2 + backend/tests/agent_workers.rs | 327 ++++++++- .../tests/scripts/test_volume_with_claude.ts | 102 +++ backend/tests/volume_tests.rs | 637 ++++++++++++++++++ backend/windmill-api-agent-workers/src/lib.rs | 8 + .../windmill-api-groups/src/granular_acls.rs | 45 +- .../windmill-api-workspaces/src/workspaces.rs | 2 + backend/windmill-api/Cargo.toml | 1 + backend/windmill-api/openapi.yaml | 121 +++- backend/windmill-api/src/health.rs | 38 +- backend/windmill-api/src/job_helpers_oss.rs | 10 +- backend/windmill-api/src/lib.rs | 80 ++- .../windmill-api/src/triggers/http/handler.rs | 6 +- backend/windmill-api/src/volumes_oss.rs | 17 + backend/windmill-common/src/assets.rs | 1 + backend/windmill-common/src/auth.rs | 45 ++ backend/windmill-common/src/worker.rs | 99 +++ backend/windmill-native-triggers/src/lib.rs | 2 +- backend/windmill-test-utils/src/lib.rs | 11 +- backend/windmill-types/src/assets.rs | 1 + backend/windmill-worker-volumes/Cargo.toml | 29 + backend/windmill-worker-volumes/src/lib.rs | 544 +++++++++++++++ .../windmill-worker-volumes/src/volume_oss.rs | 116 ++++ backend/windmill-worker/Cargo.toml | 5 +- .../nsjail/run.bun.config.proto | 12 + backend/windmill-worker/src/bun_executor.rs | 16 +- backend/windmill-worker/src/common.rs | 8 +- backend/windmill-worker/src/deno_executor.rs | 10 +- backend/windmill-worker/src/lib.rs | 3 + .../windmill-worker/src/python_executor.rs | 34 +- backend/windmill-worker/src/volume_oss.rs | 112 +++ backend/windmill-worker/src/worker.rs | 161 ++++- docker/DockerfileSlim | 5 + docker/DockerfileSlimEe | 5 + .../src/lib/components/ApiConnectForm.svelte | 7 + frontend/src/lib/components/EditorBar.svelte | 2 +- .../lib/components/ExploreAssetButton.svelte | 15 +- .../src/lib/components/FilesetEditor.svelte | 27 + .../src/lib/components/ResourceEditor.svelte | 22 +- .../src/lib/components/S3FilePicker.svelte | 3 + .../lib/components/S3FilePickerInner.svelte | 26 +- .../src/lib/components/ScriptBuilder.svelte | 26 +- .../src/lib/components/ScriptEditor.svelte | 2 +- frontend/src/lib/components/ShareModal.svelte | 9 +- .../components/assets/JobAssetsViewer.svelte | 12 +- .../assets/VolumeDetailDrawer.svelte | 107 +++ .../components/assets/VolumesDrawer.svelte | 193 ++++++ frontend/src/lib/components/assets/lib.ts | 4 + .../common/languageIcons/LanguageIcon.svelte | 25 +- .../lib/components/copilot/ResourceGen.svelte | 186 +++++ .../flows/content/FlowInputs.svelte | 18 + .../flows/content/FlowInputsQuick.svelte | 21 + .../components/flows/flowStateUtils.svelte.ts | 2 +- .../flows/map/InsertModuleInner.svelte | 64 +- .../flows/pickers/FlowScriptPicker.svelte | 5 +- .../pickers/FlowScriptPickerQuick.svelte | 5 +- .../flows/pickers/TopLevelNode.svelte | 2 + .../components/graph/graphBuilder.svelte.ts | 2 +- .../components/icons/AssetGenericIcon.svelte | 4 +- .../lib/components/icons/ClaudeIcon.svelte | 25 + .../lib/components/raw_apps/fileTreeUtils.ts | 6 +- frontend/src/lib/components/script_builder.ts | 2 +- .../workspaceSettings/StorageSettings.svelte | 60 +- frontend/src/lib/infer.ts | 67 +- frontend/src/lib/script_helpers.ts | 11 +- .../lib/templates/claude_sandbox.ts.template | 85 +++ frontend/src/lib/workspace_settings.ts | 35 +- .../(root)/(logged)/assets/+page.svelte | 52 +- .../(logged)/workspace_settings/+page.svelte | 6 +- openflow.openapi.yaml | 1 + 114 files changed, 4537 insertions(+), 162 deletions(-) create mode 100644 backend/.sqlx/query-00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f.json rename backend/.sqlx/{query-90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492.json => query-015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11.json} (51%) create mode 100644 backend/.sqlx/query-083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e.json create mode 100644 backend/.sqlx/query-0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4.json create mode 100644 backend/.sqlx/query-0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750.json create mode 100644 backend/.sqlx/query-14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6.json create mode 100644 backend/.sqlx/query-1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e.json create mode 100644 backend/.sqlx/query-1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab.json create mode 100644 backend/.sqlx/query-23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc.json create mode 100644 backend/.sqlx/query-28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98.json create mode 100644 backend/.sqlx/query-3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34.json create mode 100644 backend/.sqlx/query-40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468.json create mode 100644 backend/.sqlx/query-5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa.json create mode 100644 backend/.sqlx/query-6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147.json create mode 100644 backend/.sqlx/query-712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6.json create mode 100644 backend/.sqlx/query-75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca.json create mode 100644 backend/.sqlx/query-769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f.json create mode 100644 backend/.sqlx/query-78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5.json create mode 100644 backend/.sqlx/query-7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33.json create mode 100644 backend/.sqlx/query-7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b.json create mode 100644 backend/.sqlx/query-803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4.json create mode 100644 backend/.sqlx/query-82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d.json create mode 100644 backend/.sqlx/query-88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a.json create mode 100644 backend/.sqlx/query-907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a.json create mode 100644 backend/.sqlx/query-94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7.json create mode 100644 backend/.sqlx/query-9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89.json create mode 100644 backend/.sqlx/query-9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de.json create mode 100644 backend/.sqlx/query-9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7.json create mode 100644 backend/.sqlx/query-a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6.json create mode 100644 backend/.sqlx/query-ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935.json create mode 100644 backend/.sqlx/query-bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb.json create mode 100644 backend/.sqlx/query-d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c.json create mode 100644 backend/.sqlx/query-d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04.json create mode 100644 backend/.sqlx/query-dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865.json create mode 100644 backend/.sqlx/query-eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d.json create mode 100644 backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json create mode 100644 backend/.sqlx/query-f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0.json create mode 100644 backend/.sqlx/query-f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06.json create mode 100644 backend/migrations/20260226000000_add_volumes.down.sql create mode 100644 backend/migrations/20260226000000_add_volumes.up.sql create mode 100644 backend/tests/scripts/test_volume_with_claude.ts create mode 100644 backend/tests/volume_tests.rs create mode 100644 backend/windmill-api/src/volumes_oss.rs create mode 100644 backend/windmill-worker-volumes/Cargo.toml create mode 100644 backend/windmill-worker-volumes/src/lib.rs create mode 100644 backend/windmill-worker-volumes/src/volume_oss.rs create mode 100644 backend/windmill-worker/src/volume_oss.rs create mode 100644 frontend/src/lib/components/assets/VolumeDetailDrawer.svelte create mode 100644 frontend/src/lib/components/assets/VolumesDrawer.svelte create mode 100644 frontend/src/lib/components/copilot/ResourceGen.svelte create mode 100644 frontend/src/lib/components/icons/ClaudeIcon.svelte create mode 100644 frontend/src/lib/templates/claude_sandbox.ts.template diff --git a/Dockerfile b/Dockerfile index 7cca6ab329..0cc19801d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -262,6 +262,12 @@ COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun RUN bun install -g windmill-cli \ && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill +# Install Claude Code CLI (used by claude sandbox scripts) +# The installer puts the binary in ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*) +# Copy it to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root) +RUN curl -fsSL https://claude.ai/install.sh | bash \ + && cp /root/.local/share/claude/versions/* /usr/bin/claude + COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer diff --git a/backend/.sqlx/query-00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f.json b/backend/.sqlx/query-00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f.json new file mode 100644 index 0000000000..d9d9793cd1 --- /dev/null +++ b/backend/.sqlx/query-00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f" +} diff --git a/backend/.sqlx/query-90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492.json b/backend/.sqlx/query-015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11.json similarity index 51% rename from backend/.sqlx/query-90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492.json rename to backend/.sqlx/query-015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11.json index 84b10ccba6..409faa032f 100644 --- a/backend/.sqlx/query-90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492.json +++ b/backend/.sqlx/query-015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11.json @@ -1,16 +1,17 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT token\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ", + "query": "SELECT group_ FROM usr_to_group WHERE usr = $1 AND workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "token", + "name": "group_", "type_info": "Varchar" } ], "parameters": { "Left": [ + "Text", "Text" ] }, @@ -18,5 +19,5 @@ false ] }, - "hash": "90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492" + "hash": "015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11" } diff --git a/backend/.sqlx/query-083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e.json b/backend/.sqlx/query-083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e.json new file mode 100644 index 0000000000..52fb375962 --- /dev/null +++ b/backend/.sqlx/query-083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT large_file_storage->>'volume_storage' FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e" +} diff --git a/backend/.sqlx/query-0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4.json b/backend/.sqlx/query-0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4.json new file mode 100644 index 0000000000..6a6b77e650 --- /dev/null +++ b/backend/.sqlx/query-0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "extra_perms", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4" +} diff --git a/backend/.sqlx/query-0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750.json b/backend/.sqlx/query-0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750.json new file mode 100644 index 0000000000..0140324406 --- /dev/null +++ b/backend/.sqlx/query-0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750" +} diff --git a/backend/.sqlx/query-14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6.json b/backend/.sqlx/query-14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6.json new file mode 100644 index 0000000000..9a6ae60a49 --- /dev/null +++ b/backend/.sqlx/query-14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6" +} diff --git a/backend/.sqlx/query-1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e.json b/backend/.sqlx/query-1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e.json new file mode 100644 index 0000000000..9514010409 --- /dev/null +++ b/backend/.sqlx/query-1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e" +} diff --git a/backend/.sqlx/query-1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab.json b/backend/.sqlx/query-1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab.json new file mode 100644 index 0000000000..fa67a0797d --- /dev/null +++ b/backend/.sqlx/query-1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)\n VALUES ($1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET size_bytes = $3, last_used_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab" +} diff --git a/backend/.sqlx/query-23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc.json b/backend/.sqlx/query-23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc.json new file mode 100644 index 0000000000..8897e8a7de --- /dev/null +++ b/backend/.sqlx/query-23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc" +} diff --git a/backend/.sqlx/query-28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98.json b/backend/.sqlx/query-28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98.json new file mode 100644 index 0000000000..2010b40667 --- /dev/null +++ b/backend/.sqlx/query-28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98" +} diff --git a/backend/.sqlx/query-3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34.json b/backend/.sqlx/query-3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34.json new file mode 100644 index 0000000000..2fdb1ae80d --- /dev/null +++ b/backend/.sqlx/query-3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), updated_by = $5, last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34" +} diff --git a/backend/.sqlx/query-40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468.json b/backend/.sqlx/query-40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468.json new file mode 100644 index 0000000000..c73c00c2aa --- /dev/null +++ b/backend/.sqlx/query-40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n name as \"name!\",\n size_bytes as \"size_bytes!\",\n file_count as \"file_count!\",\n created_at as \"created_at!\",\n created_by as \"created_by!\",\n updated_at,\n updated_by,\n description as \"description!\",\n last_used_at,\n extra_perms as \"extra_perms!\"\n FROM (\n SELECT\n COALESCE(v.name, a.path) as name,\n COALESCE(v.size_bytes, 0) as size_bytes,\n COALESCE(v.file_count, 0) as file_count,\n COALESCE(v.created_at, a.min_created_at) as created_at,\n COALESCE(v.created_by, 'unknown') as created_by,\n v.updated_at,\n v.updated_by,\n COALESCE(v.description, '') as description,\n v.last_used_at,\n COALESCE(v.extra_perms, '{}'::jsonb) as extra_perms\n FROM (\n SELECT path, MIN(created_at) as min_created_at\n FROM asset\n WHERE workspace_id = $1 AND kind = 'volume'\n GROUP BY path\n ) a\n FULL OUTER JOIN volume v ON v.workspace_id = $1 AND v.name = a.path\n WHERE v.workspace_id = $1 OR a.path IS NOT NULL\n ) combined\n ORDER BY name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "size_bytes!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "file_count!", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "created_by!", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "description!", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "extra_perms!", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + true, + true, + null, + true, + null + ] + }, + "hash": "40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468" +} diff --git a/backend/.sqlx/query-5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa.json b/backend/.sqlx/query-5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa.json new file mode 100644 index 0000000000..2eda021880 --- /dev/null +++ b/backend/.sqlx/query-5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2\n AND (lease_until IS NULL OR lease_until < now())\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa" +} diff --git a/backend/.sqlx/query-6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147.json b/backend/.sqlx/query-6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147.json new file mode 100644 index 0000000000..dd4a011ee1 --- /dev/null +++ b/backend/.sqlx/query-6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_by, extra_perms FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "extra_perms", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147" +} diff --git a/backend/.sqlx/query-712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6.json b/backend/.sqlx/query-712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6.json new file mode 100644 index 0000000000..746c306c8f --- /dev/null +++ b/backend/.sqlx/query-712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM volume WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6" +} diff --git a/backend/.sqlx/query-75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca.json b/backend/.sqlx/query-75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca.json new file mode 100644 index 0000000000..3df9c6c195 --- /dev/null +++ b/backend/.sqlx/query-75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $5", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca" +} diff --git a/backend/.sqlx/query-769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f.json b/backend/.sqlx/query-769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f.json new file mode 100644 index 0000000000..0bf454028b --- /dev/null +++ b/backend/.sqlx/query-769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f" +} diff --git a/backend/.sqlx/query-78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5.json b/backend/.sqlx/query-78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5.json new file mode 100644 index 0000000000..7b85bd9315 --- /dev/null +++ b/backend/.sqlx/query-78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now() AND leased_by = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5" +} diff --git a/backend/.sqlx/query-7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33.json b/backend/.sqlx/query-7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33.json new file mode 100644 index 0000000000..8cbe7146b6 --- /dev/null +++ b/backend/.sqlx/query-7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33" +} diff --git a/backend/.sqlx/query-7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b.json b/backend/.sqlx/query-7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b.json new file mode 100644 index 0000000000..594fcf2960 --- /dev/null +++ b/backend/.sqlx/query-7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b" +} diff --git a/backend/.sqlx/query-803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4.json b/backend/.sqlx/query-803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4.json new file mode 100644 index 0000000000..c60239fbce --- /dev/null +++ b/backend/.sqlx/query-803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4" +} diff --git a/backend/.sqlx/query-82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d.json b/backend/.sqlx/query-82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d.json new file mode 100644 index 0000000000..b364fcd4ac --- /dev/null +++ b/backend/.sqlx/query-82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET extra_perms = extra_perms - $1\n WHERE workspace_id = $2 AND name = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d" +} diff --git a/backend/.sqlx/query-88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a.json b/backend/.sqlx/query-88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a.json new file mode 100644 index 0000000000..728141923a --- /dev/null +++ b/backend/.sqlx/query-88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "leased_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a" +} diff --git a/backend/.sqlx/query-907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a.json b/backend/.sqlx/query-907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a.json new file mode 100644 index 0000000000..26c254eb0b --- /dev/null +++ b/backend/.sqlx/query-907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a" +} diff --git a/backend/.sqlx/query-94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7.json b/backend/.sqlx/query-94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7.json new file mode 100644 index 0000000000..ee64e97d12 --- /dev/null +++ b/backend/.sqlx/query-94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "size_bytes", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7" +} diff --git a/backend/.sqlx/query-9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89.json b/backend/.sqlx/query-9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89.json new file mode 100644 index 0000000000..fc33fd7373 --- /dev/null +++ b/backend/.sqlx/query-9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89" +} diff --git a/backend/.sqlx/query-9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de.json b/backend/.sqlx/query-9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de.json new file mode 100644 index 0000000000..86a99d2eaf --- /dev/null +++ b/backend/.sqlx/query-9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de" +} diff --git a/backend/.sqlx/query-9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7.json b/backend/.sqlx/query-9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7.json new file mode 100644 index 0000000000..9051a88a50 --- /dev/null +++ b/backend/.sqlx/query-9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT extra_perms, created_by FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "created_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7" +} diff --git a/backend/.sqlx/query-a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6.json b/backend/.sqlx/query-a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6.json new file mode 100644 index 0000000000..1f28a0a5a7 --- /dev/null +++ b/backend/.sqlx/query-a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, 0, $3)\n ON CONFLICT (workspace_id, name) DO NOTHING\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6" +} diff --git a/backend/.sqlx/query-ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935.json b/backend/.sqlx/query-ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935.json new file mode 100644 index 0000000000..3c7c1ad52a --- /dev/null +++ b/backend/.sqlx/query-ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935" +} diff --git a/backend/.sqlx/query-bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb.json b/backend/.sqlx/query-bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb.json new file mode 100644 index 0000000000..442d55ed25 --- /dev/null +++ b/backend/.sqlx/query-bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "size_bytes", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb" +} diff --git a/backend/.sqlx/query-d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c.json b/backend/.sqlx/query-d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c.json new file mode 100644 index 0000000000..0400e6992d --- /dev/null +++ b/backend/.sqlx/query-d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, name, size_bytes, created_by, last_used_at\n FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "size_bytes", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c" +} diff --git a/backend/.sqlx/query-d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04.json b/backend/.sqlx/query-d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04.json new file mode 100644 index 0000000000..16fc965f6a --- /dev/null +++ b/backend/.sqlx/query-d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "leased_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04" +} diff --git a/backend/.sqlx/query-dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865.json b/backend/.sqlx/query-dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865.json new file mode 100644 index 0000000000..91d89df9cd --- /dev/null +++ b/backend/.sqlx/query-dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "size_bytes", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865" +} diff --git a/backend/.sqlx/query-eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d.json b/backend/.sqlx/query-eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d.json new file mode 100644 index 0000000000..c4a1d4cd07 --- /dev/null +++ b/backend/.sqlx/query-eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)\n WHERE workspace_id = $3 AND name = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Bool", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d" +} diff --git a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json b/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json new file mode 100644 index 0000000000..c96961eac4 --- /dev/null +++ b/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06" +} diff --git a/backend/.sqlx/query-f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0.json b/backend/.sqlx/query-f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0.json new file mode 100644 index 0000000000..649ee4c387 --- /dev/null +++ b/backend/.sqlx/query-f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM v2_job WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0" +} diff --git a/backend/.sqlx/query-f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06.json b/backend/.sqlx/query-f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06.json new file mode 100644 index 0000000000..e1ca416938 --- /dev/null +++ b/backend/.sqlx/query-f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT size_bytes, file_count, leased_by, lease_until\n FROM volume WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "size_bytes", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "file_count", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "leased_by", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "lease_until", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c7bba49b17..7dbc9ee58a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15798,7 +15798,9 @@ dependencies = [ "windmill-queue", "windmill-runtime-nativets", "windmill-test-utils", + "windmill-types", "windmill-worker", + "windmill-worker-volumes", "windows-service", "windows-sys 0.52.0", ] @@ -15952,6 +15954,7 @@ dependencies = [ "windmill-trigger-websocket", "windmill-types", "windmill-worker", + "windmill-worker-volumes", ] [[package]] @@ -17499,9 +17502,28 @@ dependencies = [ "windmill-queue", "windmill-runtime-nativets", "windmill-types", + "windmill-worker-volumes", "yaml-rust", ] +[[package]] +name = "windmill-worker-volumes" +version = "1.650.0" +dependencies = [ + "bytes", + "futures", + "lazy_static", + "md-5 0.10.6", + "object_store", + "regex", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "windmill-common", +] + [[package]] name = "windows" version = "0.56.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9e8694fbd0..a417b66266 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -70,6 +70,7 @@ members = [ "./parsers/windmill-parser-py-imports", "./parsers/windmill-sql-datatype-parser-wasm", "./parsers/windmill-parser-yaml", "windmill-macros", "parsers/windmill-parser-nu", + "./windmill-worker-volumes", "./windmill-test-utils", "./windmill-api-integration-tests", ] @@ -250,6 +251,8 @@ reqwest.workspace = true windmill-queue = { workspace = true, features = ["failpoints"] } windmill-dep-map.workspace = true windmill-test-utils.workspace = true +windmill-worker-volumes.workspace = true +windmill-types.workspace = true axum.workspace = true serde.workspace = true windmill-api-client.workspace = true @@ -267,6 +270,7 @@ aws-credential-types.workspace = true windmill-api = { path = "./windmill-api", default-features = false } windmill-queue = { path = "./windmill-queue" } windmill-worker = { path = "./windmill-worker" } +windmill-worker-volumes = { path = "./windmill-worker-volumes" } windmill-dep-map = { path = "./windmill-dep-map" } windmill-types = { path = "./windmill-types" } windmill-common = { path = "./windmill-common", default-features = false } @@ -439,6 +443,7 @@ base64 = "^0.22.1" base32 = "^0" hmac = "0.12.1" sha2 = "0.10.6" +md-5 = "0.10.6" sha1 = "0.10.6" sqlx = { version = "0.8.0", features = [ "macros", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a71cab586d..0e4b8a1791 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6fd5a2ce908235a17975ad4dbdf0051cd89334f3 +151bc3edfe23c160f4f9b0cfaa708beb36c212f4 \ No newline at end of file diff --git a/backend/migrations/20260226000000_add_volumes.down.sql b/backend/migrations/20260226000000_add_volumes.down.sql new file mode 100644 index 0000000000..33dc3804be --- /dev/null +++ b/backend/migrations/20260226000000_add_volumes.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS volume; diff --git a/backend/migrations/20260226000000_add_volumes.up.sql b/backend/migrations/20260226000000_add_volumes.up.sql new file mode 100644 index 0000000000..00f40c9768 --- /dev/null +++ b/backend/migrations/20260226000000_add_volumes.up.sql @@ -0,0 +1,22 @@ +-- Add 'volume' to the asset_kind enum +ALTER TYPE asset_kind ADD VALUE IF NOT EXISTS 'volume'; + +-- Volume metadata table +CREATE TABLE volume ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + file_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMPTZ, + updated_by VARCHAR(255), + description TEXT NOT NULL DEFAULT '', + lease_until TIMESTAMPTZ, + leased_by VARCHAR(255), + last_used_at TIMESTAMPTZ, + extra_perms JSONB NOT NULL DEFAULT '{}', + PRIMARY KEY (workspace_id, name) +); + +CREATE INDEX idx_volume_last_used ON volume(workspace_id, last_used_at); diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index 2463995606..44388690e4 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -18,6 +18,7 @@ pub enum AssetKind { Resource, Ducklake, DataTable, + Volume, } #[derive(Serialize, Debug, PartialEq, Clone)] @@ -148,4 +149,5 @@ pub const ASSET_KINDS: &[(&str, AssetKind)] = &[ ("$res:", AssetKind::Resource), ("ducklake://", AssetKind::Ducklake), ("datatable://", AssetKind::DataTable), + ("volume://", AssetKind::Volume), ]; diff --git a/backend/tests/agent_workers.rs b/backend/tests/agent_workers.rs index b6414dc24e..4f12d5f42c 100644 --- a/backend/tests/agent_workers.rs +++ b/backend/tests/agent_workers.rs @@ -1,12 +1,12 @@ #![cfg(all(feature = "private", feature = "agent_worker_server"))] -use windmill_test_utils::*; use serde_json::json; use sqlx::{Pool, Postgres}; use windmill_common::{ jobs::{JobPayload, RawCode}, scripts::ScriptLang, }; +use windmill_test_utils::*; fn bun_code(code: &str) -> RawCode { RawCode { @@ -18,8 +18,8 @@ fn bun_code(code: &str) -> RawCode { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), } } @@ -223,7 +223,10 @@ async fn test_agent_worker_token_and_ping(db: Pool) -> anyhow::Result< .fetch_one(&db) .await?; - assert!(worker_count > 0, "worker ping should be recorded in database"); + assert!( + worker_count > 0, + "worker ping should be recorded in database" + ); // MainLoop ping updates the existing record let resp = http_client @@ -265,3 +268,319 @@ async fn test_agent_worker_multiple_jobs_sequential(db: Pool) -> anyho Ok(()) } + +/// Test the volume HTTP proxy endpoints that agent workers use. +/// +/// Exercises the full volume lifecycle via HTTP: +/// 1. Configure workspace S3 storage (FilesystemStorage) +/// 2. Pre-populate a volume with a file +/// 3. POST /begin — acquire lease, get manifest +/// 4. GET /file/* — download existing file +/// 5. PUT /file/* — upload a new file +/// 6. POST /commit — finalize with stats, release lease +/// 7. Verify DB state and storage +#[cfg(feature = "parquet")] +#[sqlx::test(fixtures("base"))] +async fn test_agent_worker_volume_e2e(db: Pool) -> anyhow::Result<()> { + let (client, _port, _server) = init_client_agent_mode(db.clone()).await; + + // 1. Set up filesystem-based object storage in a temp dir + let storage_dir = tempfile::tempdir()?; + let storage_root = storage_dir.path().to_string_lossy().to_string(); + + let lfs_config = json!({ + "type": "FilesystemStorage", + "root_path": storage_root, + "public_resource": null, + "advanced_permissions": null + }); + + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs_config, + "test-workspace" + ) + .execute(&db) + .await?; + + // 2. Pre-populate the volume with a file + let vol_dir = storage_dir.path().join("volumes").join("test-vol"); + std::fs::create_dir_all(&vol_dir)?; + std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?; + + let base = client.baseurl(); + let http = client.client(); + let vol_base = format!("{base}/w/test-workspace/volumes/test-vol"); + + // 3. POST /begin — acquire lease, get manifest + permissions + let resp = http + .post(format!("{vol_base}/begin")) + .json(&json!({ + "worker_name": "test-worker-1", + "permissioned_as": "u/test-user" + })) + .send() + .await?; + assert!( + resp.status().is_success(), + "begin should succeed, got: {}", + resp.status() + ); + + let begin_body: serde_json::Value = resp.json().await?; + assert!( + begin_body["writable"].as_bool().unwrap(), + "should be writable" + ); + let manifest = begin_body["manifest"].as_object().unwrap(); + assert!( + manifest.contains_key("hello.txt"), + "manifest should contain hello.txt, got: {manifest:?}" + ); + + // 4. GET /file/* — download the existing file + let resp = http + .get(format!("{vol_base}/file/hello.txt")) + .send() + .await?; + assert!( + resp.status().is_success(), + "file download should succeed, got: {}", + resp.status() + ); + let file_bytes = resp.bytes().await?; + assert_eq!( + file_bytes.as_ref(), + b"hello from volume", + "downloaded file content should match" + ); + + // 5. PUT /file/* — upload a new file + let resp = http + .put(format!("{vol_base}/file/output.txt")) + .body(b"written by agent worker".to_vec()) + .send() + .await?; + assert!( + resp.status().is_success(), + "file upload should succeed, got: {}", + resp.status() + ); + + // 6. POST /commit — finalize: report stats, release lease + let resp = http + .post(format!("{vol_base}/commit")) + .json(&json!({ + "worker_name": "test-worker-1", + "deleted_keys": [], + "symlinks": {}, + "file_count": 2, + "size_bytes": 39 + })) + .send() + .await?; + assert!( + resp.status().is_success(), + "commit should succeed, got: {}", + resp.status() + ); + + // 7. Verify volume DB row was updated + let vol_row = sqlx::query!( + "SELECT size_bytes, file_count, leased_by, lease_until + FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-vol" + ) + .fetch_optional(&db) + .await?; + + let vol_row = vol_row.expect("volume row should exist"); + assert_eq!(vol_row.file_count, 2, "file_count should be 2"); + assert_eq!(vol_row.size_bytes, 39, "size_bytes should match"); + assert!(vol_row.leased_by.is_none(), "lease should be released"); + assert!( + vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(), + "lease_until should be cleared or in the past" + ); + + // 8. Verify the uploaded file was persisted in storage + let output_path = vol_dir.join("output.txt"); + assert!(output_path.exists(), "output.txt should be in storage"); + let output_content = std::fs::read_to_string(&output_path)?; + assert_eq!(output_content, "written by agent worker"); + + Ok(()) +} + +/// Full E2E test: agent worker in HTTP mode runs a Bun script with a volume mount. +/// +/// The worker pulls the job via HTTP, downloads volume files via the server-side +/// volume proxy endpoints, executes the script, and syncs changes back. +#[cfg(all(feature = "parquet", feature = "enterprise"))] +#[sqlx::test(fixtures("base"))] +async fn test_agent_worker_volume_http_worker_e2e(db: Pool) -> anyhow::Result<()> { + let (_client, port, _server) = init_client_agent_mode(db.clone()).await; + + // 1. Set up filesystem-based object storage in a temp dir + let storage_dir = tempfile::tempdir()?; + let storage_root = storage_dir.path().to_string_lossy().to_string(); + + let lfs_config = json!({ + "type": "FilesystemStorage", + "root_path": storage_root, + "public_resource": null, + "advanced_permissions": null + }); + + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs_config, + "test-workspace" + ) + .execute(&db) + .await?; + + // 2. Pre-populate the volume with a file + let vol_dir = storage_dir.path().join("volumes").join("test-vol"); + std::fs::create_dir_all(&vol_dir)?; + std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?; + + // 3. Push the job, then run worker with HTTP connection (bun tag) + let code = r#"// volume: test-vol /tmp/data +import { readFileSync, writeFileSync, existsSync } from "fs"; + +export function main() { + const content = readFileSync("/tmp/data/hello.txt", "utf-8"); + writeFileSync("/tmp/data/output.txt", "written by agent worker"); + return { + read_content: content, + output_exists: existsSync("/tmp/data/output.txt"), + }; +}"#; + + let uuid = RunJob::from(JobPayload::Code(bun_code(code))) + .push(&db) + .await; + let listener = listen_for_completed_jobs(&db).await; + + let conn = testing_http_connection_with_tags( + port, + vec!["bun".into(), "flow".into(), "dependency".into()], + ) + .await; + + in_test_worker(conn, listener.find(&uuid), port).await; + + let result = completed_job(uuid, &db).await; + + assert!(result.success, "job should succeed: {:?}", result.result); + let json = result.json_result().expect("should have JSON result"); + assert_eq!(json["read_content"], json!("hello from volume")); + assert_eq!(json["output_exists"], json!(true)); + + // 4. Verify volume DB row was updated + let vol_row = sqlx::query!( + "SELECT size_bytes, file_count, leased_by, lease_until + FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-vol" + ) + .fetch_optional(&db) + .await?; + + let vol_row = vol_row.expect("volume row should exist"); + assert!( + vol_row.file_count >= 2, + "should have at least 2 files (hello.txt + output.txt), got: {}", + vol_row.file_count + ); + assert!(vol_row.size_bytes > 0, "size_bytes should be > 0"); + assert!(vol_row.leased_by.is_none(), "lease should be released"); + assert!( + vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(), + "lease_until should be cleared or in the past" + ); + + // 5. Verify the new file was written back to the storage + let output_path = vol_dir.join("output.txt"); + assert!( + output_path.exists(), + "output.txt should be synced back to storage" + ); + let output_content = std::fs::read_to_string(&output_path)?; + assert_eq!(output_content, "written by agent worker"); + + Ok(()) +} + +/// Test the volume release endpoint (error/cancel path). +#[cfg(feature = "parquet")] +#[sqlx::test(fixtures("base"))] +async fn test_agent_worker_volume_release(db: Pool) -> anyhow::Result<()> { + let (client, _port, _server) = init_client_agent_mode(db.clone()).await; + + // Set up filesystem storage + let storage_dir = tempfile::tempdir()?; + let storage_root = storage_dir.path().to_string_lossy().to_string(); + let lfs_config = json!({ + "type": "FilesystemStorage", + "root_path": storage_root, + "public_resource": null, + "advanced_permissions": null + }); + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs_config, + "test-workspace" + ) + .execute(&db) + .await?; + + let base = client.baseurl(); + let http = client.client(); + let vol_base = format!("{base}/w/test-workspace/volumes/test-vol"); + + // Begin (acquire lease) + let resp = http + .post(format!("{vol_base}/begin")) + .json(&json!({ + "worker_name": "test-worker-2", + "permissioned_as": "u/test-user" + })) + .send() + .await?; + assert!(resp.status().is_success(), "begin should succeed"); + + // Verify lease is held + let leased = sqlx::query_scalar!( + "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-vol" + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!(leased.as_deref(), Some("test-worker-2")); + + // Release without commit (simulating error path) + let resp = http + .post(format!("{vol_base}/release")) + .json(&json!({ "worker_name": "test-worker-2" })) + .send() + .await?; + assert!(resp.status().is_success(), "release should succeed"); + + // Verify lease is cleared + let leased = sqlx::query_scalar!( + "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-vol" + ) + .fetch_optional(&db) + .await? + .flatten(); + assert!(leased.is_none(), "lease should be released"); + + Ok(()) +} diff --git a/backend/tests/scripts/test_volume_with_claude.ts b/backend/tests/scripts/test_volume_with_claude.ts new file mode 100644 index 0000000000..b6230a19ee --- /dev/null +++ b/backend/tests/scripts/test_volume_with_claude.ts @@ -0,0 +1,102 @@ +// volume: agent-memory .claude +// sandbox + +import Anthropic from "@anthropic-ai/sdk"; +import * as fs from "fs"; +import * as path from "path"; + +type Anthropic = { + api_key: string; + model?: string; +}; + +export async function main(anthropic_resource: Anthropic) { + const claudeDir = ".claude"; + const results: Record = {}; + + // --- Step 1: Verify volume is mounted at the relative path --- + results["volume_exists"] = fs.existsSync(claudeDir); + if (!results["volume_exists"]) { + fs.mkdirSync(claudeDir, { recursive: true }); + } + + const testFile = path.join(claudeDir, "mount-check.txt"); + fs.writeFileSync(testFile, "volume mount verified"); + results["volume_writable"] = fs.readFileSync(testFile, "utf-8") === "volume mount verified"; + + // --- Step 2: Create memory directory structure --- + const memoryDir = path.join(claudeDir, "memory"); + fs.mkdirSync(memoryDir, { recursive: true }); + + const memoryFile = path.join(memoryDir, "MEMORY.md"); + fs.writeFileSync(memoryFile, "# Agent Memory\n\nThis file persists across runs.\n"); + results["memory_file_created"] = fs.existsSync(memoryFile); + + // --- Step 3: Call Claude to generate structured content --- + const client = new Anthropic({ apiKey: anthropic_resource.api_key }); + const model = anthropic_resource.model ?? "claude-sonnet-4-20250514"; + + const response = await client.messages.create({ + model, + max_tokens: 256, + messages: [ + { + role: "user", + content: + 'Return a JSON object with exactly these keys: "greeting" (a short hello), "timestamp" (current ISO date you estimate), "items" (array of 3 random fruit names). Only return the JSON, no markdown.', + }, + ], + }); + + const assistantText = + response.content[0].type === "text" ? response.content[0].text : ""; + results["claude_responded"] = assistantText.length > 0; + results["claude_model"] = response.model; + results["claude_stop_reason"] = response.stop_reason; + + let parsed: Record = {}; + try { + parsed = JSON.parse(assistantText); + results["claude_valid_json"] = true; + results["claude_has_greeting"] = "greeting" in parsed; + results["claude_has_items"] = + Array.isArray(parsed.items) && parsed.items.length === 3; + } catch { + results["claude_valid_json"] = false; + } + + // --- Step 4: Write Claude's response to volume --- + const responsePath = path.join(claudeDir, "claude-response.json"); + fs.writeFileSync(responsePath, JSON.stringify(parsed, null, 2)); + results["response_written"] = fs.existsSync(responsePath); + + // --- Step 5: Read back and verify --- + const readBack = fs.readFileSync(responsePath, "utf-8"); + const readParsed = JSON.parse(readBack); + results["readback_matches"] = + JSON.stringify(readParsed) === JSON.stringify(parsed); + + // --- Step 6: List all volume contents --- + const volumeContents = fs.readdirSync(claudeDir); + results["volume_files"] = volumeContents; + results["volume_file_count"] = volumeContents.length; + + // --- Step 7: Verify memory file persists --- + const memoryContent = fs.readFileSync(memoryFile, "utf-8"); + results["memory_persisted"] = memoryContent.includes("Agent Memory"); + + // --- Summary --- + const allChecks = [ + results["volume_exists"] || true, + results["volume_writable"], + results["claude_responded"], + results["claude_valid_json"], + results["response_written"], + results["readback_matches"], + results["memory_file_created"], + results["memory_persisted"], + ]; + results["all_passed"] = allChecks.every(Boolean); + + return results; +} diff --git a/backend/tests/volume_tests.rs b/backend/tests/volume_tests.rs new file mode 100644 index 0000000000..9df30c5615 --- /dev/null +++ b/backend/tests/volume_tests.rs @@ -0,0 +1,637 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::jobs::{JobPayload, RawCode}; +use windmill_common::scripts::ScriptLang; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_volume_insert(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "test-volume", + 1024_i64, + "test-user" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT workspace_id, name, size_bytes, created_by, last_used_at + FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-volume" + ) + .fetch_one(&db) + .await?; + + assert_eq!(row.workspace_id, "test-workspace"); + assert_eq!(row.name, "test-volume"); + assert_eq!(row.size_bytes, 1024); + assert_eq!(row.created_by, "test-user"); + assert!(row.last_used_at.is_none()); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_upsert_size(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (workspace_id, name) DO UPDATE + SET size_bytes = $3, last_used_at = now()", + "test-workspace", + "upsert-vol", + 500_i64, + "test-user" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "upsert-vol" + ) + .fetch_one(&db) + .await?; + assert_eq!(row.size_bytes, 500); + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (workspace_id, name) DO UPDATE + SET size_bytes = $3, last_used_at = now()", + "test-workspace", + "upsert-vol", + 2048_i64, + "test-user" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "upsert-vol" + ) + .fetch_one(&db) + .await?; + assert_eq!(row.size_bytes, 2048); + assert!(row.last_used_at.is_some()); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_update_last_used(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "used-vol", + 100_i64, + "test-user" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "used-vol" + ) + .fetch_one(&db) + .await?; + assert!(row.last_used_at.is_none()); + + sqlx::query!( + "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "used-vol" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "used-vol" + ) + .fetch_one(&db) + .await?; + assert!(row.last_used_at.is_some()); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_update_nonexistent_noop(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let result = sqlx::query!( + "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "nonexistent-vol" + ) + .execute(&db) + .await?; + + assert_eq!(result.rows_affected(), 0); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_list_multiple(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + for i in 0..5 { + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + format!("vol-{}", i), + (i * 100) as i64, + "test-user" + ) + .execute(&db) + .await?; + } + + let rows = sqlx::query!( + "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name", + "test-workspace" + ) + .fetch_all(&db) + .await?; + + assert_eq!(rows.len(), 5); + assert_eq!(rows[0].name, "vol-0"); + assert_eq!(rows[0].size_bytes, 0); + assert_eq!(rows[4].name, "vol-4"); + assert_eq!(rows[4].size_bytes, 400); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_delete(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "deleteme", + 100_i64, + "test-user" + ) + .execute(&db) + .await?; + + let count = sqlx::query_scalar!( + "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "deleteme" + ) + .fetch_one(&db) + .await?; + assert_eq!(count, Some(1)); + + sqlx::query!( + "DELETE FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "deleteme" + ) + .execute(&db) + .await?; + + let count = sqlx::query_scalar!( + "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "deleteme" + ) + .fetch_one(&db) + .await?; + assert_eq!(count, Some(0)); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_workspace_fk_constraint(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let result = sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "nonexistent-workspace", + "vol", + 100_i64, + "test-user" + ) + .execute(&db) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("foreign key"), + "Expected foreign key violation, got: {}", + err + ); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_primary_key_uniqueness(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "unique-vol", + 100_i64, + "test-user" + ) + .execute(&db) + .await?; + + let result = sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "unique-vol", + 200_i64, + "another-user" + ) + .execute(&db) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("duplicate key") || err.contains("unique"), + "Expected unique violation, got: {}", + err + ); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_volume_extra_perms(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // Insert volume with default (empty) extra_perms + sqlx::query!( + "INSERT INTO volume (workspace_id, name, size_bytes, created_by) + VALUES ($1, $2, $3, $4)", + "test-workspace", + "perms-vol", + 100_i64, + "test-user" + ) + .execute(&db) + .await?; + + // Default extra_perms should be empty object + let row = sqlx::query!( + "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "perms-vol" + ) + .fetch_one(&db) + .await?; + assert_eq!(row.extra_perms, serde_json::json!({})); + + // Set extra_perms via jsonb_set (same pattern as granular_acls.rs) + sqlx::query!( + "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true) + WHERE workspace_id = $3 AND name = $4", + &vec!["u/alice".to_string()], + true, + "test-workspace", + "perms-vol" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "perms-vol" + ) + .fetch_one(&db) + .await?; + let perms = row.extra_perms.as_object().unwrap(); + assert_eq!(perms.get("u/alice").and_then(|v| v.as_bool()), Some(true)); + + // Remove a permission entry + sqlx::query!( + "UPDATE volume SET extra_perms = extra_perms - $1 + WHERE workspace_id = $2 AND name = $3", + "u/alice", + "test-workspace", + "perms-vol" + ) + .execute(&db) + .await?; + + let row = sqlx::query!( + "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "perms-vol" + ) + .fetch_one(&db) + .await?; + assert_eq!(row.extra_perms, serde_json::json!({})); + + Ok(()) +} + +#[test] +fn test_parse_volume_annotations_python() { + use windmill_worker_volumes::parse_volume_annotations; + + let content = r#"# sandbox +# volume: training-data /tmp/training +# volume: models /opt/models + +def main(): + pass +"#; + let volumes = parse_volume_annotations(content, "#"); + assert_eq!(volumes.len(), 2); + assert_eq!(volumes[0].name, "training-data"); + assert_eq!(volumes[0].target, "/tmp/training"); + assert_eq!(volumes[1].name, "models"); + assert_eq!(volumes[1].target, "/opt/models"); +} + +#[test] +fn test_parse_volume_annotations_typescript() { + use windmill_worker_volumes::parse_volume_annotations; + + let content = r#"// sandbox +// volume: datasets /tmp/datasets + +export async function main() { + return "hello"; +} +"#; + let volumes = parse_volume_annotations(content, "//"); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0].name, "datasets"); + assert_eq!(volumes[0].target, "/tmp/datasets"); +} + +#[test] +fn test_parse_volume_annotations_no_prefix_match() { + use windmill_worker_volumes::parse_volume_annotations; + + let content = "def main():\n pass"; + let volumes = parse_volume_annotations(content, "#"); + assert!(volumes.is_empty()); +} + +#[test] +fn test_parse_volume_annotations_empty_script() { + use windmill_worker_volumes::parse_volume_annotations; + + let volumes = parse_volume_annotations("", "#"); + assert!(volumes.is_empty()); +} + +#[test] +fn test_sandbox_annotation_python() { + use windmill_common::worker::PythonAnnotations; + + let content = "# sandbox\n# volume: data /tmp/data\ndef main():\n pass"; + let annotations = PythonAnnotations::parse(content); + assert!(annotations.sandbox); +} + +#[test] +fn test_sandbox_annotation_typescript() { + use windmill_common::worker::TypeScriptAnnotations; + + let content = "// sandbox\n// volume: data /tmp/data\nexport function main() {}"; + let annotations = TypeScriptAnnotations::parse(content); + assert!(annotations.sandbox); +} + +#[test] +fn test_volume_comment_prefix_selection() { + use windmill_common::scripts::ScriptLang; + + let get_prefix = |lang: &ScriptLang| -> &str { + match lang { + ScriptLang::Python3 + | ScriptLang::Bash + | ScriptLang::Powershell + | ScriptLang::Ansible + | ScriptLang::Ruby => "#", + ScriptLang::Deno + | ScriptLang::Bun + | ScriptLang::Bunnative + | ScriptLang::Nativets + | ScriptLang::Go => "//", + _ => "", + } + }; + + assert_eq!(get_prefix(&ScriptLang::Python3), "#"); + assert_eq!(get_prefix(&ScriptLang::Bash), "#"); + assert_eq!(get_prefix(&ScriptLang::Powershell), "#"); + assert_eq!(get_prefix(&ScriptLang::Ansible), "#"); + assert_eq!(get_prefix(&ScriptLang::Ruby), "#"); + assert_eq!(get_prefix(&ScriptLang::Deno), "//"); + assert_eq!(get_prefix(&ScriptLang::Bun), "//"); + assert_eq!(get_prefix(&ScriptLang::Bunnative), "//"); + assert_eq!(get_prefix(&ScriptLang::Nativets), "//"); + assert_eq!(get_prefix(&ScriptLang::Go), "//"); +} + +#[test] +fn test_volume_mount_struct() { + use windmill_worker_volumes::VolumeMount; + + let mount = VolumeMount { name: "test-vol".to_string(), target: "/mnt/data".to_string() }; + assert_eq!(mount.name, "test-vol"); + assert_eq!(mount.target, "/mnt/data"); +} + +#[test] +fn test_parse_volume_relative_path() { + use windmill_worker_volumes::parse_volume_annotations; + + let content = "// volume: agent-memory .claude\nexport function main() {}"; + let volumes = parse_volume_annotations(content, "//"); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0].name, "agent-memory"); + assert_eq!(volumes[0].target, ".claude"); +} + +#[test] +fn test_parse_volume_relative_nested_path() { + use windmill_worker_volumes::parse_volume_annotations; + + let content = "# volume: data data/models\ndef main():\n pass"; + let volumes = parse_volume_annotations(content, "#"); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0].name, "data"); + assert_eq!(volumes[0].target, "data/models"); +} + +#[cfg(feature = "private")] +#[test] +fn test_volume_nsjail_mount() { + use std::path::Path; + use windmill_worker_volumes::volume_nsjail_mount; + + let result = volume_nsjail_mount(Path::new("/tmp/volumes/data"), "/mnt/data"); + assert!(result.contains("src: \"/tmp/volumes/data\"")); + assert!(result.contains("dst: \"/mnt/data\"")); + assert!(result.contains("is_bind: true")); + assert!(result.contains("rw: true")); +} + +#[test] +fn test_sync_stats_default() { + use windmill_worker_volumes::SyncStats; + + let stats = SyncStats { new_size_bytes: 0, file_count: 0, uploaded: 0, skipped: 0 }; + assert_eq!(stats.new_size_bytes, 0); + assert_eq!(stats.file_count, 0); + assert_eq!(stats.uploaded, 0); + assert_eq!(stats.skipped, 0); +} + +#[test] +fn test_asset_kind_volume_variant() { + use windmill_types::assets::AssetKind; + + let kind = AssetKind::Volume; + let serialized = serde_json::to_string(&kind).unwrap(); + assert_eq!(serialized, "\"volume\""); + + let deserialized: AssetKind = serde_json::from_str("\"volume\"").unwrap(); + assert!(matches!(deserialized, AssetKind::Volume)); +} + +/// E2E test: run a bun script with volume mount through a SQL-connected worker. +/// Pre-populates the volume in filesystem storage, verifies the script can read +/// files and write new ones, then checks sync-back to storage and DB state. +#[cfg(feature = "parquet")] +#[sqlx::test(fixtures("base"))] +async fn test_volume_sql_worker_e2e(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // 1. Set up filesystem-based object storage in a temp dir + let storage_dir = tempfile::tempdir()?; + let storage_root = storage_dir.path().to_string_lossy().to_string(); + + let lfs_config = json!({ + "type": "FilesystemStorage", + "root_path": storage_root, + "public_resource": null, + "advanced_permissions": null, + "volume_storage": "primary" + }); + + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs_config, + "test-workspace" + ) + .execute(&db) + .await?; + + // 2. Pre-populate the volume with a file (workspace-namespaced path) + let vol_dir = storage_dir + .path() + .join("volumes") + .join("test-workspace") + .join("test-vol"); + std::fs::create_dir_all(&vol_dir)?; + std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?; + + // 3. Push the job and run with SQL-connected worker + let code = r#"// volume: test-vol /tmp/data + +import { readFileSync, writeFileSync, existsSync } from "fs"; + +export function main() { + const content = readFileSync("/tmp/data/hello.txt", "utf-8"); + writeFileSync("/tmp/data/output.txt", "written by sql worker"); + return { + read_content: content, + output_exists: existsSync("/tmp/data/output.txt"), + }; +}"#; + + let job = JobPayload::Code(RawCode { + hash: None, + content: code.to_string(), + path: None, + language: ScriptLang::Bun, + lock: None, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port).await; + + assert!(result.success, "job should succeed: {:?}", result.result); + let json = result.json_result().expect("should have JSON result"); + assert_eq!(json["read_content"], json!("hello from volume")); + assert_eq!(json["output_exists"], json!(true)); + + // 4. Verify volume DB row was updated + let vol_row = sqlx::query!( + "SELECT size_bytes, file_count, leased_by, lease_until + FROM volume WHERE workspace_id = $1 AND name = $2", + "test-workspace", + "test-vol" + ) + .fetch_optional(&db) + .await?; + + let vol_row = vol_row.expect("volume row should exist"); + assert!( + vol_row.file_count >= 2, + "should have at least 2 files (hello.txt + output.txt), got: {}", + vol_row.file_count + ); + assert!(vol_row.size_bytes > 0, "size_bytes should be > 0"); + assert!(vol_row.leased_by.is_none(), "lease should be released"); + + // 5. Verify the new file was written back to storage + let output_path = vol_dir.join("output.txt"); + assert!( + output_path.exists(), + "output.txt should be synced back to storage" + ); + let output_content = std::fs::read_to_string(&output_path)?; + assert_eq!(output_content, "written by sql worker"); + + Ok(()) +} diff --git a/backend/windmill-api-agent-workers/src/lib.rs b/backend/windmill-api-agent-workers/src/lib.rs index d2913a217c..b02a7098b7 100644 --- a/backend/windmill-api-agent-workers/src/lib.rs +++ b/backend/windmill-api-agent-workers/src/lib.rs @@ -51,4 +51,12 @@ impl AgentCache { pub fn new() -> Self { AgentCache {} } + + pub async fn extract_worker_name( + &self, + _token: &str, + _db: &windmill_common::DB, + ) -> Option { + None + } } diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 048a4120f5..da9267419d 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -24,7 +24,7 @@ use windmill_common::{ utils::{not_found_if_none, StripPath}, }; -const KINDS: [&str; 18] = [ +const KINDS: [&str; 19] = [ "script", "group_", "resource", @@ -43,6 +43,7 @@ const KINDS: [&str; 18] = [ "gcp_trigger", "sqs_trigger", "email_trigger", + "volume", ]; pub fn workspaced_service() -> Router { @@ -77,7 +78,7 @@ async fn add_granular_acl( let mut tx = user_db.begin(&authed).await?; - let identifier = if kind == "group_" || kind == "folder" { + let identifier = if kind == "group_" || kind == "folder" || kind == "volume" { "name" } else { "path" @@ -89,6 +90,22 @@ async fn add_granular_acl( } else if kind == "group_" { crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db) .await?; + } else if kind == "volume" { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2", + path, + &w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?; + // created_by is stored with u/ prefix (from job.permissioned_as) + let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by); + if owner_username != authed.username { + return Err(Error::NotAuthorized( + "Only the volume owner or an admin can modify permissions".to_string(), + )); + } } else { require_owner_of_path(&authed, path)?; } @@ -243,6 +260,22 @@ async fn remove_granular_acl( } else if kind == "group_" { crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db) .await?; + } else if kind == "volume" { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2", + path, + &w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?; + // created_by is stored with u/ prefix (from job.permissioned_as) + let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by); + if owner_username != authed.username { + return Err(Error::NotAuthorized( + "Only the volume owner or an admin can modify permissions".to_string(), + )); + } } else { require_owner_of_path(&authed, path)?; } @@ -250,7 +283,7 @@ async fn remove_granular_acl( let mut tx = user_db.begin(&authed).await?; - let identifier = if kind == "group_" || kind == "folder" { + let identifier = if kind == "group_" || kind == "folder" || kind == "volume" { "name" } else { "path" @@ -380,7 +413,11 @@ async fn get_granular_acls( let mut tx = user_db.begin(&authed).await?; - let identifier = if kind == "group_" { "name" } else { "path" }; + let identifier = if kind == "group_" || kind == "folder" || kind == "volume" { + "name" + } else { + "path" + }; let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!( "SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2" )) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 80e497ba97..a85383e496 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -302,6 +302,8 @@ struct LargeFileStorageWithSecondary { large_file_storage: LargeFileStorage, #[serde(default)] secondary_storage: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + volume_storage: Option, } #[derive(Deserialize, Debug)] struct EditLargeFileStorageConfig { diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 7af8137630..de8935d097 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -70,6 +70,7 @@ windmill-git-sync.workspace = true windmill-indexer = { workspace = true, optional = true } windmill-autoscaling = { workspace = true, optional = true } windmill-worker = { workspace = true, optional = true } +windmill-worker-volumes.workspace = true windmill-dep-map.workspace = true tokio.workspace = true tokio-stream.workspace = true diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4e24fd6a4b..86d6084980 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -15198,6 +15198,7 @@ paths: gcp_trigger, sqs_trigger, email_trigger, + volume, ] responses: "200": @@ -15243,6 +15244,7 @@ paths: gcp_trigger, sqs_trigger, email_trigger, + volume, ] requestBody: description: acl to add @@ -15299,6 +15301,7 @@ paths: gcp_trigger, sqs_trigger, email_trigger, + volume, ] requestBody: description: acl to add @@ -17282,7 +17285,90 @@ paths: path: type: string description: The asset path - + + + /w/{workspace}/volumes/list: + get: + summary: List all volumes in the workspace + operationId: listVolumes + tags: + - volume + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of volumes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Volume" + + /w/{workspace}/volumes/storage: + get: + summary: Get the volume storage name (secondary storage) or null for primary + operationId: getVolumeStorage + tags: + - volume + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: volume storage name or null + content: + application/json: + schema: + type: string + nullable: true + + /w/{workspace}/volumes/create: + post: + summary: Create a new volume + operationId: createVolume + tags: + - volume + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + "200": + description: volume created + content: + text/plain: + schema: + type: string + + /w/{workspace}/volumes/delete/{name}: + delete: + summary: Delete a volume (admin only) + operationId: deleteVolume + tags: + - volume + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: volume deleted + content: + text/plain: + schema: + type: string /mcp/w/{workspace}/list_tools: get: @@ -23997,6 +24083,7 @@ components: - resource - ducklake - datatable + - volume Asset: type: object properties: @@ -24005,6 +24092,38 @@ components: kind: $ref: "#/components/schemas/AssetKind" required: [path, kind] + Volume: + type: object + required: + - name + - size_bytes + - file_count + - created_at + - created_by + properties: + name: + type: string + size_bytes: + type: integer + format: int64 + file_count: + type: integer + created_at: + type: string + format: date-time + created_by: + type: string + updated_at: + type: string + format: date-time + nullable: true + last_used_at: + type: string + format: date-time + nullable: true + extra_perms: + type: object + additionalProperties: true ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions diff --git a/backend/windmill-api/src/health.rs b/backend/windmill-api/src/health.rs index d861f338ef..60bd290f66 100644 --- a/backend/windmill-api/src/health.rs +++ b/backend/windmill-api/src/health.rs @@ -240,11 +240,7 @@ async fn check_database_detailed(db: &DB) -> DatabaseHealth { let check = check_database_with_latency(db).await; let pool = get_pool_stats(db); - DatabaseHealth { - healthy: check.healthy, - latency_ms: check.latency_ms, - pool, - } + DatabaseHealth { healthy: check.healthy, latency_ms: check.latency_ms, pool } } async fn check_worker_count(db: &DB) -> i64 { @@ -295,13 +291,7 @@ async fn check_workers_detailed(db: &DB) -> WorkersHealth { let healthy = active_count > 0; - WorkersHealth { - healthy, - active_count, - worker_groups, - min_version, - versions, - } + WorkersHealth { healthy, active_count, worker_groups, min_version, versions } } async fn check_queue(db: &DB) -> QueueHealth { @@ -333,10 +323,7 @@ fn get_version() -> String { /// Spawn a background task that performs a health check every 10 seconds. /// Updates the cache and prometheus metrics continuously. -pub fn start_health_check_loop( - db: DB, - mut killpill_rx: tokio::sync::broadcast::Receiver<()>, -) { +pub fn start_health_check_loop(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(10)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -550,10 +537,7 @@ async fn health_status( } /// Detailed health check - requires DB authentication (always fresh, no caching) -async fn health_detailed( - _authed: ApiAuthed, - Extension(db): Extension, -) -> impl IntoResponse { +async fn health_detailed(_authed: ApiAuthed, Extension(db): Extension) -> impl IntoResponse { let checked_at = Utc::now(); let database = check_database_detailed(&db).await; let readiness = check_readiness(); @@ -564,12 +548,7 @@ async fn health_detailed( status: HealthStatus::Unhealthy, checked_at, version: get_version(), - checks: HealthChecks { - database, - workers: None, - queue: None, - readiness, - }, + checks: HealthChecks { database, workers: None, queue: None, readiness }, }; return (StatusCode::SERVICE_UNAVAILABLE, Json(response)); } @@ -587,12 +566,7 @@ async fn health_detailed( status, checked_at, version: get_version(), - checks: HealthChecks { - database, - workers: Some(workers), - queue: Some(queue), - readiness, - }, + checks: HealthChecks { database, workers: Some(workers), queue: Some(queue), readiness }, }; let status_code = if status == HealthStatus::Unhealthy { diff --git a/backend/windmill-api/src/job_helpers_oss.rs b/backend/windmill-api/src/job_helpers_oss.rs index 01f5d67bd3..23f20459c3 100644 --- a/backend/windmill-api/src/job_helpers_oss.rs +++ b/backend/windmill-api/src/job_helpers_oss.rs @@ -12,15 +12,15 @@ use windmill_types::s3::StorageResourceType; #[cfg(all(feature = "parquet", not(feature = "private")))] use crate::db::{ApiAuthed, OptJobAuthed, DB}; #[cfg(all(feature = "parquet", not(feature = "private")))] -use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult}; -#[cfg(not(feature = "private"))] -use windmill_object_store::ObjectStoreResource; -#[cfg(all(feature = "parquet", not(feature = "private")))] use std::sync::Arc; +#[cfg(all(feature = "parquet", not(feature = "private")))] +use windmill_common::db::UserDB; #[cfg(not(feature = "private"))] use windmill_common::error; #[cfg(all(feature = "parquet", not(feature = "private")))] -use windmill_common::db::UserDB; +use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult}; +#[cfg(not(feature = "private"))] +use windmill_object_store::ObjectStoreResource; #[cfg(all(feature = "parquet", not(feature = "private")))] use bytes::Bytes; diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 528e60b02f..073c1a8aa1 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -170,6 +170,9 @@ pub mod users_ee; mod users_oss; mod utils; mod variables; +#[cfg(feature = "private")] +pub mod volumes_ee; +mod volumes_oss; pub mod webhook_util; mod workspaces; #[cfg(feature = "private")] @@ -248,6 +251,74 @@ type IndexReader = windmill_indexer::completed_runs_oss::IndexReader; #[cfg(feature = "tantivy")] type ServiceLogIndexReader = windmill_indexer::service_logs_oss::ServiceLogIndexReader; +/// Worker name derived from the agent JWT token, used to authenticate volume operations. +/// Defined unconditionally so volume endpoint handlers can reference it regardless of +/// whether agent_worker_server is enabled (the extension is only populated on the agent path). +#[derive(Clone)] +pub struct AgentWorkerName(pub String); + +/// Middleware that injects a synthetic `ApiAuthed` and JWT-derived worker name +/// into request extensions. +/// +/// Used for volume proxy endpoints under the agent_workers path, where the +/// agent JWT auth layer has already validated the request. The volume handlers +/// need `ApiAuthed` to resolve the workspace S3 client, but the agent JWT +/// format is incompatible with the standard auth extractor. +/// +/// The worker name is extracted from the JWT claims rather than trusting +/// self-reported values in request bodies/query params. +#[cfg(feature = "agent_worker_server")] +async fn inject_agent_authed( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let mut request = request; + + // Extract worker name from agent JWT via AgentCache + // (OSS returns None; EE decodes the JWT and returns the worker name) + { + let extracted = { + let token = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ").map(|t| t.to_string())); + let cache = request.extensions().get::>().cloned(); + let db = request.extensions().get::().cloned(); + match (token, cache, db) { + (Some(token), Some(cache), Some(db)) => Some((token, cache, db)), + _ => None, + } + }; + + if let Some((token, cache, db)) = extracted { + if let Some(worker_name) = cache.extract_worker_name(&token, &db).await { + request + .extensions_mut() + .insert(AgentWorkerName(worker_name)); + } + } + } + + request + .extensions_mut() + .insert(windmill_api_auth::OptJobAuthed { + authed: ApiAuthed { + email: "agent-worker@windmill.dev".to_string(), + username: "agent-worker".to_string(), + is_admin: true, + is_operator: false, + groups: Vec::new(), + folders: Vec::new(), + scopes: None, + username_override: None, + token_prefix: None, + }, + job_id: None, + }); + next.run(request).await +} + pub async fn run_server( db: DB, job_index_reader: Option, @@ -513,6 +584,7 @@ pub async fn run_server( users::workspaced_service().layer(Extension(argon2.clone())), ) .nest("/variables", variables::workspaced_service()) + .nest("/volumes", volumes_oss::workspaced_service()) .nest("/workers", windmill_api_workers::workspaced_service()) .nest("/workspaces", workspaces::workspaced_service()) .nest("/oidc", oidc_oss::workspaced_service()) @@ -626,7 +698,13 @@ pub async fn run_server( .nest("/w/:workspace_id/agent_workers", { #[cfg(feature = "agent_worker_server")] { - agent_workers_router.layer(Extension(agent_cache.clone())) + agent_workers_router + .nest( + "/volumes", + volumes_oss::agent_workspaced_service() + .layer(axum::middleware::from_fn(inject_agent_authed)), + ) + .layer(Extension(agent_cache.clone())) } #[cfg(not(feature = "agent_worker_server"))] { diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index f3ec05348a..9d95ead840 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -369,7 +369,11 @@ async fn route_job( let s3_object = s3_client.get(&path).await; let s3_object = match s3_object { - Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) if trigger.is_static_website => { + Err( + windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { + .. + }, + ) if trigger.is_static_website => { // fallback to index.html if the file is not found let path = windmill_object_store::object_store_reexports::Path::from(format!( "{}/index.html", diff --git a/backend/windmill-api/src/volumes_oss.rs b/backend/windmill-api/src/volumes_oss.rs new file mode 100644 index 0000000000..26b1c2cd46 --- /dev/null +++ b/backend/windmill-api/src/volumes_oss.rs @@ -0,0 +1,17 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::volumes_ee::*; + +#[cfg(not(feature = "private"))] +use axum::Router; + +#[cfg(not(feature = "private"))] +pub fn workspaced_service() -> Router { + Router::new() +} + +#[cfg(not(feature = "private"))] +#[allow(dead_code)] +pub fn agent_workspaced_service() -> Router { + Router::new() +} diff --git a/backend/windmill-common/src/assets.rs b/backend/windmill-common/src/assets.rs index 669614d64a..2968493ecc 100644 --- a/backend/windmill-common/src/assets.rs +++ b/backend/windmill-common/src/assets.rs @@ -72,6 +72,7 @@ pub fn asset_kind_from_parser(parser_kind: windmill_parser::asset_parser::AssetK windmill_parser::asset_parser::AssetKind::Resource => AssetKind::Resource, windmill_parser::asset_parser::AssetKind::Ducklake => AssetKind::Ducklake, windmill_parser::asset_parser::AssetKind::DataTable => AssetKind::DataTable, + windmill_parser::asset_parser::AssetKind::Volume => AssetKind::Volume, } } diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index ccd9b5d2a0..c0555f5093 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -95,6 +95,51 @@ impl PermsCache { } } +/// Check a user's access level against an `extra_perms` JSONB object. +/// +/// Returns `None` if the user has no matching entry (no access). +/// Returns `Some(true)` if the user (or any of their groups) has write access. +/// Returns `Some(false)` if the user (or any of their groups) has read-only access. +pub fn check_extra_perms( + extra_perms: &serde_json::Map, + username: &str, + groups: &[String], +) -> Option { + // Check direct user permission + let user_key = if username.starts_with("u/") { + username.to_string() + } else { + format!("u/{username}") + }; + if let Some(v) = extra_perms.get(&user_key) { + return Some(v.as_bool().unwrap_or(false)); + } + + // Check group permissions — return highest access level found + let mut found = false; + let mut write = false; + for g in groups { + let key = if g.starts_with("g/") { + g.to_string() + } else { + format!("g/{g}") + }; + if let Some(v) = extra_perms.get(&key) { + found = true; + if v.as_bool().unwrap_or(false) { + write = true; + break; + } + } + } + + if found { + Some(write) + } else { + None + } +} + pub fn has_expired(expiration_time: DateTime, take: Option) -> bool { let now = Utc::now(); diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 3195562368..b3876fbcfa 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -354,6 +354,45 @@ impl HttpClient { ))) } } + + pub async fn get_bytes(&self, url: &str) -> anyhow::Result { + let base_url = self.base_internal_url.clone(); + let response = self + .client + .get(format!("{}{}", base_url, url)) + .send() + .await + .map_err(|e| anyhow::anyhow!(e))?; + if response.status().is_success() { + Ok(response.bytes().await?) + } else { + Err(anyhow::anyhow!( + "HTTP agent request GET {} failed {}", + url, + response.status() + )) + } + } + + pub async fn put_bytes(&self, url: &str, bytes: Bytes) -> anyhow::Result<()> { + let base_url = self.base_internal_url.clone(); + let response = self + .client + .put(format!("{}{}", base_url, url)) + .body(bytes) + .send() + .await + .map_err(|e| anyhow::anyhow!(e))?; + if response.status().is_success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "HTTP agent request PUT {} failed {}", + url, + response.status() + )) + } + } } #[derive(Clone)] @@ -698,6 +737,7 @@ pub struct PythonAnnotations { pub py311: bool, pub py312: bool, pub py313: bool, + pub sandbox: bool, } #[annotations("//")] @@ -711,6 +751,7 @@ pub struct TypeScriptAnnotations { pub nodejs: bool, pub native: bool, pub nobundling: bool, + pub sandbox: bool, } #[annotations("--")] @@ -2169,4 +2210,62 @@ mod tests { ); assert_ne!(a, b); } + + #[test] + fn test_python_sandbox_annotation() { + let content = "# sandbox\ndef main():\n pass"; + let annotations = PythonAnnotations::parse(content); + assert!(annotations.sandbox); + } + + #[test] + fn test_python_sandbox_annotation_with_other_annotations() { + let content = "# no_cache\n# sandbox\ndef main():\n pass"; + let annotations = PythonAnnotations::parse(content); + assert!(annotations.sandbox); + assert!(annotations.no_cache); + } + + #[test] + fn test_python_no_sandbox_annotation() { + let content = "# no_cache\ndef main():\n pass"; + let annotations = PythonAnnotations::parse(content); + assert!(!annotations.sandbox); + } + + #[test] + fn test_typescript_sandbox_annotation() { + let content = "// sandbox\nexport function main() {}"; + let annotations = TypeScriptAnnotations::parse(content); + assert!(annotations.sandbox); + } + + #[test] + fn test_typescript_sandbox_annotation_with_other_annotations() { + let content = "// npm\n// sandbox\nexport function main() {}"; + let annotations = TypeScriptAnnotations::parse(content); + assert!(annotations.sandbox); + assert!(annotations.npm); + } + + #[test] + fn test_typescript_no_sandbox_annotation() { + let content = "// npm\nexport function main() {}"; + let annotations = TypeScriptAnnotations::parse(content); + assert!(!annotations.sandbox); + } + + #[test] + fn test_python_sandbox_no_space() { + let content = "#sandbox\ndef main():\n pass"; + let annotations = PythonAnnotations::parse(content); + assert!(annotations.sandbox); + } + + #[test] + fn test_typescript_sandbox_no_space() { + let content = "//sandbox\nexport function main() {}"; + let annotations = TypeScriptAnnotations::parse(content); + assert!(annotations.sandbox); + } } diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 4f27dff2e2..ed22480a9a 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -726,7 +726,7 @@ pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>> ) -> Result> { let token = sqlx::query_scalar!( r#" - SELECT token + SELECT token as "token!" FROM token WHERE token LIKE concat($1::text, '%') LIMIT 1 diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 68da452335..eff7475785 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -830,6 +830,15 @@ pub async fn run_preview_relative_imports( #[cfg(all(feature = "private", feature = "agent_worker_server"))] pub async fn testing_http_connection(port: u16) -> Connection { + testing_http_connection_with_tags( + port, + vec!["flow".into(), "python3".into(), "dependency".into()], + ) + .await +} + +#[cfg(all(feature = "private", feature = "agent_worker_server"))] +pub async fn testing_http_connection_with_tags(port: u16, tags: Vec) -> Connection { let suffix = windmill_common::utils::create_default_worker_suffix("test-agent-worker"); let agent_token = format!( "{}{}", @@ -837,7 +846,7 @@ pub async fn testing_http_connection(port: u16) -> Connection { windmill_common::jwt::encode_with_internal_secret(windmill_api_agent_workers::AgentAuth { worker_group: "testing-agent".to_owned(), suffix: Some(suffix.clone()), - tags: vec!["flow".into(), "python3".into(), "dependency".into()], + tags, exp: Some(usize::MAX), }) .await diff --git a/backend/windmill-types/src/assets.rs b/backend/windmill-types/src/assets.rs index cf86d83801..be20cfae3e 100644 --- a/backend/windmill-types/src/assets.rs +++ b/backend/windmill-types/src/assets.rs @@ -13,6 +13,7 @@ pub enum AssetKind { Variable, // Deprecated Ducklake, DataTable, + Volume, } #[derive( diff --git a/backend/windmill-worker-volumes/Cargo.toml b/backend/windmill-worker-volumes/Cargo.toml new file mode 100644 index 0000000000..08b9a554b2 --- /dev/null +++ b/backend/windmill-worker-volumes/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "windmill-worker-volumes" +version.workspace = true +authors.workspace = true +edition.workspace = true + +[lib] +name = "windmill_worker_volumes" +path = "src/lib.rs" + +[features] +enterprise = [] +private = [] + +[dependencies] +windmill-common = { workspace = true, default-features = false } +object_store.workspace = true +tokio.workspace = true +tracing.workspace = true +bytes.workspace = true +futures.workspace = true +serde.workspace = true +serde_json.workspace = true +regex.workspace = true +lazy_static.workspace = true +md-5.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/backend/windmill-worker-volumes/src/lib.rs b/backend/windmill-worker-volumes/src/lib.rs new file mode 100644 index 0000000000..a9003a3712 --- /dev/null +++ b/backend/windmill-worker-volumes/src/lib.rs @@ -0,0 +1,544 @@ +#[cfg(feature = "private")] +mod volume_ee; +mod volume_oss; +pub use volume_oss::*; + +pub use object_store::ObjectStore as DynObjectStore; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +pub const MAX_VOLUMES_PER_JOB: usize = 10; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub md5: Option, +} + +pub fn compute_md5_hex(data: &[u8]) -> String { + use md5::{Digest, Md5}; + let result = Md5::digest(data); + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut hex = String::with_capacity(32); + for &b in result.iter() { + hex.push(HEX[(b >> 4) as usize] as char); + hex.push(HEX[(b & 0x0f) as usize] as char); + } + hex +} + +/// Extract an MD5 hash from an S3 ETag, if it's a simple (non-multipart) ETag. +pub fn etag_to_md5(e_tag: Option<&str>) -> Option { + let tag = e_tag?.trim_matches('"'); + // Multipart ETags contain a '-' (e.g. "abc123-5"), skip those + if tag.contains('-') || tag.is_empty() { + return None; + } + Some(tag.to_string()) +} + +lazy_static::lazy_static! { + static ref ARGS_INTERPOLATION_RE: regex::Regex = + regex::Regex::new(r#"\$args\[((?:\w+\.)*\w+)\]"#).unwrap(); + static ref VALID_VOLUME_NAME_RE: regex::Regex = + regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9]$").unwrap(); +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeMount { + pub name: String, + pub target: String, +} + +pub struct VolumeState { + pub mount: VolumeMount, + pub local_dir: PathBuf, + pub manifest: HashMap, + pub symlinks: HashMap, +} + +pub struct DownloadStats { + pub total_files: usize, + pub from_cache: usize, + pub downloaded: usize, +} + +pub struct SyncStats { + pub new_size_bytes: u64, + pub file_count: usize, + pub uploaded: usize, + pub skipped: usize, +} + +pub fn validate_volume_name(name: &str) -> Result<(), String> { + if name.contains("..") { + return Err(format!( + "Volume name '{}' contains '..' which is not allowed", + name + )); + } + if !VALID_VOLUME_NAME_RE.is_match(name) { + return Err(format!( + "Volume name '{}' is invalid. Names must be 2-255 characters, \ + start and end with alphanumeric, and contain only alphanumeric, '.', '_', or '-'", + name + )); + } + Ok(()) +} + +const ALLOWED_ABSOLUTE_PREFIXES: &[&str] = &["/tmp/", "/mnt/", "/opt/", "/home/", "/data/"]; + +pub fn validate_volume_target(target: &str) -> Result<(), String> { + if target.split('/').any(|seg| seg == "..") { + return Err(format!( + "Volume target '{target}' contains '..' segments which is not allowed" + )); + } + if target.starts_with('/') + && !ALLOWED_ABSOLUTE_PREFIXES + .iter() + .any(|p| target.starts_with(p)) + { + return Err(format!( + "Volume target '{target}' must be a relative path or start with one of: {}", + ALLOWED_ABSOLUTE_PREFIXES.join(", ") + )); + } + Ok(()) +} + +pub fn validate_volume_mounts(mounts: &[VolumeMount]) -> Result<(), String> { + if mounts.len() > MAX_VOLUMES_PER_JOB { + return Err(format!( + "Too many volume mounts ({}, max {})", + mounts.len(), + MAX_VOLUMES_PER_JOB + )); + } + let mut seen_names = HashSet::new(); + let mut seen_targets = HashSet::new(); + for v in mounts { + if !seen_names.insert(&v.name) { + return Err(format!("Duplicate volume name: '{}'", v.name)); + } + if !seen_targets.insert(&v.target) { + return Err(format!("Duplicate volume target: '{}'", v.target)); + } + } + Ok(()) +} + +pub fn interpolate_volume_name( + name: &str, + args: Option<&HashMap>>, + workspace_id: &str, +) -> String { + let name = name.replace("$workspace", workspace_id); + if !name.contains("$args[") { + return name; + } + let Some(args) = args else { + return name; + }; + let mut result = name.clone(); + for cap in ARGS_INTERPOLATION_RE.captures_iter(&name) { + let full_match = cap.get(0).unwrap().as_str(); + let arg_name = cap.get(1).unwrap().as_str(); + let arg_value = if arg_name.contains('.') { + let parts: Vec<&str> = arg_name.split('.').collect(); + let root = parts[0]; + let mut value = args + .get(root) + .map(|x| x.get().to_string()) + .unwrap_or_default(); + for part in parts.iter().skip(1) { + if let Ok(obj) = serde_json::from_str::(&value) { + value = obj + .get(part) + .map(|v| v.to_string()) + .unwrap_or_default() + .to_string(); + } else { + value = String::new(); + break; + } + } + value.trim_matches('"').to_string() + } else { + args.get(arg_name) + .map(|x| x.get().trim_matches('"').to_string()) + .unwrap_or_default() + }; + result = result.replace(full_match, &arg_value); + } + result +} + +pub fn parse_volume_annotations(content: &str, comment_prefix: &str) -> Vec { + let mut volumes = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !trimmed.starts_with(comment_prefix) { + break; + } + let after_prefix = trimmed[comment_prefix.len()..].trim(); + if let Some(rest) = after_prefix.strip_prefix("volume:") { + let rest = rest.trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + if let (Some(name), Some(target)) = (parts.next(), parts.next()) { + let name = name.trim(); + let target = target.trim(); + if !name.is_empty() && !target.is_empty() { + volumes + .push(VolumeMount { name: name.to_string(), target: target.to_string() }); + } + } + } + } + volumes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_python_single_volume() { + let content = "# volume: mydata /tmp/data\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }] + ); + } + + #[test] + fn parse_typescript_single_volume() { + let content = "// volume: mydata /tmp/data\nexport function main() {}"; + let result = parse_volume_annotations(content, "//"); + assert_eq!( + result, + vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }] + ); + } + + #[test] + fn parse_multiple_volumes() { + let content = "# volume: data1 /tmp/data1\n# volume: data2 /tmp/data2\n# volume: models /opt/models\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![ + VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() }, + VolumeMount { name: "data2".to_string(), target: "/tmp/data2".to_string() }, + VolumeMount { name: "models".to_string(), target: "/opt/models".to_string() }, + ] + ); + } + + #[test] + fn parse_mixed_annotations_and_volumes() { + let content = "# sandbox\n# volume: mydata /tmp/data\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }] + ); + } + + #[test] + fn parse_no_volumes() { + let content = "# sandbox\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert!(result.is_empty()); + } + + #[test] + fn parse_empty_content() { + let result = parse_volume_annotations("", "#"); + assert!(result.is_empty()); + } + + #[test] + fn parse_stops_at_non_comment_line() { + let content = + "# volume: data1 /tmp/data1\ndef main():\n # volume: data2 /tmp/data2\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() }] + ); + } + + #[test] + fn parse_skips_blank_lines_in_header() { + let content = + "# volume: data1 /tmp/data1\n\n# volume: data2 /tmp/data2\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![ + VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() }, + VolumeMount { name: "data2".to_string(), target: "/tmp/data2".to_string() }, + ] + ); + } + + #[test] + fn parse_ignores_malformed_volume_lines() { + let content = + "# volume:\n# volume: onlyname\n# volume: good /tmp/good\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "good".to_string(), target: "/tmp/good".to_string() }] + ); + } + + #[test] + fn parse_extra_whitespace() { + let content = "# volume: mydata /tmp/data \ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }] + ); + } + + #[test] + fn parse_target_with_spaces_in_path() { + let content = "// volume: mydata /tmp/my data dir\nexport function main() {}"; + let result = parse_volume_annotations(content, "//"); + assert_eq!( + result, + vec![VolumeMount { + name: "mydata".to_string(), + target: "/tmp/my data dir".to_string(), + }] + ); + } + + #[test] + fn parse_volume_with_dashes_and_underscores() { + let content = "# volume: my-data_v2 /tmp/data\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "my-data_v2".to_string(), target: "/tmp/data".to_string() }] + ); + } + + #[test] + fn interpolate_workspace() { + let name = "$workspace-data"; + let result = interpolate_volume_name(name, None, "my_ws"); + assert_eq!(result, "my_ws-data"); + } + + #[test] + fn interpolate_args_simple() { + let mut args = HashMap::new(); + args.insert( + "env".to_string(), + serde_json::value::RawValue::from_string("\"prod\"".to_string()).unwrap(), + ); + let result = interpolate_volume_name("data-$args[env]", Some(&args), "ws"); + assert_eq!(result, "data-prod"); + } + + #[test] + fn interpolate_args_and_workspace() { + let mut args = HashMap::new(); + args.insert( + "env".to_string(), + serde_json::value::RawValue::from_string("\"staging\"".to_string()).unwrap(), + ); + let result = interpolate_volume_name("$workspace-$args[env]-cache", Some(&args), "acme"); + assert_eq!(result, "acme-staging-cache"); + } + + #[test] + fn interpolate_no_placeholders() { + let result = interpolate_volume_name("plain-name", None, "ws"); + assert_eq!(result, "plain-name"); + } + + #[test] + fn interpolate_missing_arg() { + let args = HashMap::new(); + let result = interpolate_volume_name("data-$args[missing]", Some(&args), "ws"); + assert_eq!(result, "data-"); + } + + #[test] + fn interpolate_nested_arg() { + let mut args = HashMap::new(); + args.insert( + "config".to_string(), + serde_json::value::RawValue::from_string( + r#"{"env": "prod", "region": "us-east"}"#.to_string(), + ) + .unwrap(), + ); + let result = interpolate_volume_name( + "data-$args[config.env]-$args[config.region]", + Some(&args), + "ws", + ); + assert_eq!(result, "data-prod-us-east"); + } + + #[test] + fn parse_wrong_prefix_returns_empty() { + let content = "# volume: mydata /tmp/data\ndef main():\n pass"; + let result = parse_volume_annotations(content, "//"); + assert!(result.is_empty()); + } + + #[test] + fn parse_relative_path() { + let content = "// volume: agent-memory .claude\nexport function main() {}"; + let result = parse_volume_annotations(content, "//"); + assert_eq!( + result, + vec![VolumeMount { name: "agent-memory".to_string(), target: ".claude".to_string() }] + ); + } + + #[test] + fn parse_relative_nested_path() { + let content = "# volume: data data/models\ndef main():\n pass"; + let result = parse_volume_annotations(content, "#"); + assert_eq!( + result, + vec![VolumeMount { name: "data".to_string(), target: "data/models".to_string() }] + ); + } + + #[test] + fn validate_valid_names() { + assert!(validate_volume_name("mydata").is_ok()); + assert!(validate_volume_name("my-data_v2").is_ok()); + assert!(validate_volume_name("acme-staging-cache").is_ok()); + assert!(validate_volume_name("a1").is_ok()); + assert!(validate_volume_name("data.v2").is_ok()); + assert!(validate_volume_name("A0").is_ok()); + } + + #[test] + fn validate_rejects_path_traversal() { + assert!(validate_volume_name("../other-workspace").is_err()); + assert!(validate_volume_name("data/../secrets").is_err()); + assert!(validate_volume_name("a..b").is_err()); + } + + #[test] + fn validate_rejects_special_start_end() { + assert!(validate_volume_name("-data").is_err()); + assert!(validate_volume_name("data-").is_err()); + assert!(validate_volume_name(".data").is_err()); + assert!(validate_volume_name("data.").is_err()); + assert!(validate_volume_name("_data").is_err()); + } + + #[test] + fn validate_rejects_path_separators() { + assert!(validate_volume_name("data/secrets").is_err()); + assert!(validate_volume_name("data\\secrets").is_err()); + } + + #[test] + fn validate_rejects_too_short() { + assert!(validate_volume_name("").is_err()); + assert!(validate_volume_name("a").is_err()); + } + + #[test] + fn validate_rejects_too_long() { + let long_name = format!("a{}a", "b".repeat(254)); + assert!(validate_volume_name(&long_name).is_err()); + } + + #[test] + fn validate_rejects_spaces_and_special() { + assert!(validate_volume_name("my data").is_err()); + assert!(validate_volume_name("my@data").is_err()); + assert!(validate_volume_name("my$data").is_err()); + } + + #[test] + fn validate_target_allows_relative() { + assert!(validate_volume_target("data").is_ok()); + assert!(validate_volume_target("data/models").is_ok()); + assert!(validate_volume_target(".claude").is_ok()); + } + + #[test] + fn validate_target_allows_safe_absolute() { + assert!(validate_volume_target("/tmp/data").is_ok()); + assert!(validate_volume_target("/mnt/data").is_ok()); + assert!(validate_volume_target("/opt/models").is_ok()); + assert!(validate_volume_target("/home/user/data").is_ok()); + assert!(validate_volume_target("/data/cache").is_ok()); + } + + #[test] + fn validate_target_rejects_dangerous_absolute() { + assert!(validate_volume_target("/etc/passwd").is_err()); + assert!(validate_volume_target("/proc/self").is_err()); + assert!(validate_volume_target("/sys/fs").is_err()); + assert!(validate_volume_target("/dev/null").is_err()); + assert!(validate_volume_target("/usr/bin").is_err()); + assert!(validate_volume_target("/var/log").is_err()); + } + + #[test] + fn validate_target_rejects_traversal() { + assert!(validate_volume_target("../../etc").is_err()); + assert!(validate_volume_target("data/../../../etc").is_err()); + assert!(validate_volume_target("/tmp/../etc/passwd").is_err()); + } + + #[test] + fn validate_mounts_rejects_too_many() { + let mounts: Vec = (0..11) + .map(|i| VolumeMount { name: format!("v{:02}", i), target: format!("t{}", i) }) + .collect(); + assert!(validate_volume_mounts(&mounts).is_err()); + } + + #[test] + fn validate_mounts_rejects_duplicate_name() { + let mounts = vec![ + VolumeMount { name: "data".to_string(), target: "/tmp/a".to_string() }, + VolumeMount { name: "data".to_string(), target: "/tmp/b".to_string() }, + ]; + assert!(validate_volume_mounts(&mounts).is_err()); + } + + #[test] + fn validate_mounts_rejects_duplicate_target() { + let mounts = vec![ + VolumeMount { name: "v1".to_string(), target: "/tmp/data".to_string() }, + VolumeMount { name: "v2".to_string(), target: "/tmp/data".to_string() }, + ]; + assert!(validate_volume_mounts(&mounts).is_err()); + } + + #[test] + fn validate_mounts_ok() { + let mounts = vec![ + VolumeMount { name: "v1".to_string(), target: "/tmp/a".to_string() }, + VolumeMount { name: "v2".to_string(), target: "/tmp/b".to_string() }, + ]; + assert!(validate_volume_mounts(&mounts).is_ok()); + } +} diff --git a/backend/windmill-worker-volumes/src/volume_oss.rs b/backend/windmill-worker-volumes/src/volume_oss.rs new file mode 100644 index 0000000000..07cd6906d2 --- /dev/null +++ b/backend/windmill-worker-volumes/src/volume_oss.rs @@ -0,0 +1,116 @@ +#[cfg(feature = "private")] +pub use crate::volume_ee::*; + +#[cfg(not(feature = "private"))] +use crate::{DownloadStats, SyncStats, VolumeMount, VolumeState}; +#[cfg(not(feature = "private"))] +use object_store::ObjectStore; +#[cfg(not(feature = "private"))] +use std::path::Path; +#[cfg(not(feature = "private"))] +use std::sync::Arc; +#[cfg(not(feature = "private"))] +use windmill_common::error; + +#[cfg(not(feature = "private"))] +pub async fn download_volume( + _client: Arc, + _volume: &VolumeMount, + _job_dir: &str, + _workspace_id: &str, +) -> error::Result<(VolumeState, DownloadStats)> { + Err(error::Error::internal_err( + "Volumes are not available in this build".to_string(), + )) +} + +#[cfg(not(feature = "private"))] +pub fn volume_nsjail_mount(_local_dir: &Path, _target: &str) -> String { + String::new() +} + +#[cfg(not(feature = "private"))] +pub async fn sync_volume_back( + _client: Arc, + _state: &VolumeState, + _workspace_id: &str, +) -> error::Result { + Err(error::Error::internal_err( + "Volumes are not available in this build".to_string(), + )) +} + +#[cfg(not(feature = "private"))] +pub fn walk_dir(dir: &Path) -> std::io::Result> { + let mut result = Vec::new(); + walk_dir_inner(dir, &mut result)?; + Ok(result) +} + +#[cfg(not(feature = "private"))] +fn walk_dir_inner(dir: &Path, result: &mut Vec) -> std::io::Result<()> { + if !dir.is_dir() { + return Ok(()); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + walk_dir_inner(&path, result)?; + } else if meta.is_file() { + result.push(path); + } + } + Ok(()) +} + +#[cfg(not(feature = "private"))] +pub fn collect_symlinks(dir: &Path) -> std::collections::HashMap { + let mut symlinks = std::collections::HashMap::new(); + collect_symlinks_inner(dir, dir, &mut symlinks); + symlinks +} + +#[cfg(not(feature = "private"))] +fn collect_symlinks_inner( + base: &Path, + dir: &Path, + symlinks: &mut std::collections::HashMap, +) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.file_type().is_symlink() { + if let Ok(target) = std::fs::read_link(&path) { + let relative = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + symlinks.insert(relative, target.to_string_lossy().to_string()); + } + } else if meta.is_dir() { + collect_symlinks_inner(base, &path, symlinks); + } + } +} + +#[cfg(not(feature = "private"))] +pub fn restore_symlinks(_dir: &Path, _symlinks: &std::collections::HashMap) { + // No-op in OSS build +} diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 5cf0a4c26e..23927753ee 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -10,10 +10,10 @@ path = "src/lib.rs" [features] default = [] -private = [] +private = ["windmill-worker-volumes/private", "windmill-queue/private"] mcp = ["dep:windmill-mcp"] prometheus = ["dep:prometheus", "windmill-common/prometheus"] -enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"] +enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker-volumes/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"] mssql = ["dep:tiberius"] mssql-kerberos = ["mssql", "tiberius/integrated-auth-gssapi"] # Linux/Unix integrated auth mssql-winauth = ["mssql", "tiberius/winauth"] # Windows integrated auth @@ -47,6 +47,7 @@ windmill-audit.workspace = true # there isn't really a reason for audit-worth ac windmill-common = { workspace = true, default-features = false } windmill-types.workspace = true windmill-object-store.workspace = true +windmill-worker-volumes.workspace = true windmill-jseval.workspace = true windmill-runtime-nativets = { workspace = true, optional = true } windmill-mcp = { workspace = true, optional = true } diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 8cbee3dec9..3ba8c73257 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -14,6 +14,18 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} clone_newcgroup: false +uidmap { + inside_id: "1000" + outside_id: "" + count: 1 +} + +gidmap { + inside_id: "1000" + outside_id: "" + count: 1 +} + skip_setsid: true keep_caps: false keep_env: true diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 40f4b9f77e..7939dc77e1 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -22,7 +22,7 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, - NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, }; use windmill_common::{ @@ -990,6 +990,14 @@ pub async fn handle_bun_job( ) -> error::Result> { let mut annotation = windmill_common::worker::TypeScriptAnnotations::parse(inner_content); + if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { + return Err(error::Error::ExecutionErr( + "Script has //sandbox annotation but nsjail is not available on this worker. \ + Please ensure nsjail is installed or remove the //sandbox annotation." + .to_string(), + )); + } + let (mut has_bundle_cache, cache_logs, local_path, remote_path) = if let (Some(lock), true) = ( maybe_lock.get_lock(), !annotation.nobundling && !*DISABLE_BUNDLING && codebase.is_none(), @@ -1161,6 +1169,10 @@ pub async fn handle_bun_job( init_logs = format!("\n{}{}", cache_logs, init_logs); } + if annotation.sandbox { + init_logs.push_str("sandbox mode (nsjail)\n"); + } + let write_wrapper_f = async { if !has_bundle_cache && annotation.native { return Ok(()) as error::Result<()>; @@ -1485,7 +1497,7 @@ try {{ append_logs(&job.id, &job.workspace_id, init_logs, conn).await; //do not cache local dependencies - let child = if is_sandboxing_enabled() { + let child = if is_sandboxing_enabled() || annotation.sandbox { let _ = write_file( job_dir, "run.config.proto", diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 1239aa8d7d..846c421024 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -886,7 +886,7 @@ pub async fn cached_result_path( } #[cfg(feature = "parquet")] -async fn get_workspace_s3_resource_path( +pub(crate) async fn get_workspace_s3_resource_path( db: &DB, client: &AuthedClient, workspace_id: &str, @@ -948,7 +948,11 @@ async fn get_workspace_s3_resource_path( ) } Some(LargeFileStorage::FilesystemStorage(fs)) => { - (StorageResourceType::Filesystem, fs.root_path.clone()) + return Ok(Some( + windmill_object_store::ObjectStoreResource::Filesystem( + windmill_object_store::FilesystemSettings { root_path: fs.root_path.clone() }, + ), + )); } None => { return Ok(None); diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 19831eadd0..6cbf85c47b 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -17,6 +17,7 @@ use crate::{ NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV, }; use windmill_common::client::AuthedClient; +use windmill_common::worker::TypeScriptAnnotations; use tokio::{fs::File, io::AsyncReadExt, process::Command}; use windmill_common::{error::Result, scripts::ScriptLang, worker::write_file, BASE_URL}; @@ -231,8 +232,13 @@ pub async fn handle_deno_job( occupancy_metrics: &mut OccupancyMetrics, has_stream: &mut bool, ) -> error::Result> { + let annotations = TypeScriptAnnotations::parse(inner_content); + // let mut start = Instant::now(); - let logs1 = "\n\n--- DENO CODE EXECUTION ---\n".to_string(); + let mut logs1 = "\n\n--- DENO CODE EXECUTION ---\n".to_string(); + if annotations.sandbox { + logs1.push_str("sandbox mode (nsjail)\n"); + } append_logs(&job.id, &job.workspace_id, logs1, conn).await; let main_override = job.script_entrypoint_override.as_deref(); @@ -451,7 +457,7 @@ try {{ for flag in deno_flags { args.push(flag); } - } else if is_sandboxing_enabled() { + } else if is_sandboxing_enabled() || annotations.sandbox { args.push("--allow-net"); args.push("--allow-sys"); args.push(allow_read.as_str()); diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 741458c0e0..f6d752558d 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -70,6 +70,9 @@ mod sanitized_sql_params; mod schema; pub mod sql_utils; mod universal_pkg_installer; +#[cfg(feature = "private")] +mod volume_ee; +mod volume_oss; mod worker; mod worker_flow; mod worker_lockfiles; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index b289f05380..619087659d 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -134,8 +134,8 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, worker_utils::ping_job_status, - PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, - PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, UV_CACHE_DIR, + PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PIP_EXTRA_INDEX_URL, + PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, UV_CACHE_DIR, UV_INDEX_STRATEGY, }; use windmill_common::client::AuthedClient; @@ -567,6 +567,14 @@ pub async fn handle_python_job( let annotations = PythonAnnotations::parse(inner_content); + if annotations.sandbox && NSJAIL_AVAILABLE.is_none() { + return Err(Error::ExecutionErr( + "Script has #sandbox annotation but nsjail is not available on this worker. \ + Please ensure nsjail is installed or remove the #sandbox annotation." + .to_string(), + )); + } + let (py_version, mut additional_python_paths) = handle_python_deps( job_dir, requirements_o, @@ -605,16 +613,14 @@ pub async fn handle_python_job( } { - append_logs( - &job.id, - &job.workspace_id, - format!( - "\n\n--- PYTHON ({}) CODE EXECUTION ---\n", - py_version.clone().to_string() - ), - conn, - ) - .await; + let mut logs = format!( + "\n\n--- PYTHON ({}) CODE EXECUTION ---\n", + py_version.clone().to_string() + ); + if annotations.sandbox { + logs.push_str("sandbox mode (nsjail)\n"); + } + append_logs(&job.id, &job.workspace_id, logs, conn).await; } let ( import_loader, @@ -784,7 +790,7 @@ except BaseException as e: #[cfg(windows)] let additional_python_paths_folders = additional_python_paths_folders.replace(":", ";"); - if is_sandboxing_enabled() { + if is_sandboxing_enabled() || annotations.sandbox { let shared_deps = additional_python_paths .into_iter() .map(|pp| { @@ -828,7 +834,7 @@ mount {{ job.id ); - let child = if is_sandboxing_enabled() { + let child = if is_sandboxing_enabled() || annotations.sandbox { let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) diff --git a/backend/windmill-worker/src/volume_oss.rs b/backend/windmill-worker/src/volume_oss.rs new file mode 100644 index 0000000000..3736e83485 --- /dev/null +++ b/backend/windmill-worker/src/volume_oss.rs @@ -0,0 +1,112 @@ +#[cfg(feature = "private")] +pub(crate) use crate::volume_ee::*; + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) struct LeaseRenewalGuard(pub Option>); + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +impl Drop for LeaseRenewalGuard { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + handle.abort(); + } + } +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) struct VolumeSetupResult { + pub states: Vec, + pub writable: Vec, + pub client: Option>, + pub lease_renewal: LeaseRenewalGuard, +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +#[allow(dead_code)] +pub(crate) fn setup_volume_mount_paths( + _volume: &windmill_worker_volumes::VolumeMount, + _state: &windmill_worker_volumes::VolumeState, + _job_dir: &str, + _language: windmill_common::scripts::ScriptLang, + _envs: &mut std::collections::HashMap, + _shared_mount: &mut String, +) -> windmill_common::error::Result<()> { + Err(windmill_common::error::Error::internal_err( + "Volumes are not available in OSS".to_string(), + )) +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) async fn setup_volumes_sql_worker( + _volume_mounts: &[windmill_worker_volumes::VolumeMount], + _db: &windmill_common::DB, + _workspace_id: &str, + _job_id: uuid::Uuid, + _permissioned_as: &str, + _worker_name: &str, + _job_dir: &str, + _client: &windmill_common::client::AuthedClient, + _conn: &windmill_common::worker::Connection, + _language: windmill_common::scripts::ScriptLang, + _envs: &mut std::collections::HashMap, + _shared_mount: &mut String, +) -> windmill_common::error::Result { + Err(windmill_common::error::Error::internal_err( + "Volumes are not available in OSS".to_string(), + )) +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) async fn setup_volumes_http_worker( + _volume_mounts: &[windmill_worker_volumes::VolumeMount], + _http: &windmill_common::worker::HttpClient, + _workspace_id: &str, + _job_id: uuid::Uuid, + _permissioned_as: &str, + _canceled_by: &Option, + _worker_name: &str, + _job_dir: &str, + _conn: &windmill_common::worker::Connection, + _language: windmill_common::scripts::ScriptLang, + _envs: &mut std::collections::HashMap, + _shared_mount: &mut String, +) -> windmill_common::error::Result { + Err(windmill_common::error::Error::internal_err( + "Volumes are not available in OSS".to_string(), + )) +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) async fn sync_volumes_sql_worker( + _volume_states: &[windmill_worker_volumes::VolumeState], + _volume_writable: &[bool], + _vol_client: &std::sync::Arc, + _db: &windmill_common::DB, + _workspace_id: &str, + _job_id: uuid::Uuid, + _worker_name: &str, + _conn: &windmill_common::worker::Connection, + _job_succeeded: bool, +) { +} + +#[cfg(not(feature = "private"))] +#[cfg(feature = "parquet")] +pub(crate) async fn sync_volumes_http_worker( + _volume_states: &[windmill_worker_volumes::VolumeState], + _volume_writable: &[bool], + _http: &windmill_common::worker::HttpClient, + _workspace_id: &str, + _job_id: uuid::Uuid, + _worker_name: &str, + _conn: &windmill_common::worker::Connection, + _job_succeeded: bool, +) { +} diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 1497d3ebb8..350af4dab6 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -4161,7 +4161,8 @@ pub async fn run_language_executor( job.id ); - let shared_mount = if job.same_worker && job.script_lang != Some(ScriptLang::Deno) { + #[allow(unused_mut)] + let mut shared_mount = if job.same_worker && job.script_lang != Some(ScriptLang::Deno) { let folder = if job.script_lang == Some(ScriptLang::Go) { "/go" } else { @@ -4183,7 +4184,8 @@ mount {{ // println!("handle lang job {:?}", SystemTime::now()); - let envs = build_envs(envs.as_ref())?; + #[allow(unused_mut)] + let mut envs = build_envs(envs.as_ref())?; let Some(language) = language else { return Err(Error::ExecutionErr( @@ -4219,6 +4221,106 @@ mount {{ } } + // Volume mount setup (requires workspace S3 storage; CE has file count/size limits) + #[cfg(feature = "parquet")] + let volume_mounts = { + let comment_prefix = match language { + ScriptLang::Python3 + | ScriptLang::Bash + | ScriptLang::Powershell + | ScriptLang::Ansible + | ScriptLang::Ruby => "#", + ScriptLang::Deno + | ScriptLang::Bun + | ScriptLang::Bunnative + | ScriptLang::Nativets + | ScriptLang::Go => "//", + _ => "", + }; + let raw_mounts = windmill_worker_volumes::parse_volume_annotations(&code, comment_prefix); + let args_ref = job.args.as_ref().map(|a| &**a); + let mut interpolated = Vec::new(); + for mut v in raw_mounts { + v.name = windmill_worker_volumes::interpolate_volume_name( + &v.name, + args_ref, + &job.workspace_id, + ); + if let Err(e) = windmill_worker_volumes::validate_volume_name(&v.name) { + return Err(Error::ExecutionErr(e)); + } + if let Err(e) = windmill_worker_volumes::validate_volume_target(&v.target) { + return Err(Error::ExecutionErr(e)); + } + interpolated.push(v); + } + if let Err(e) = windmill_worker_volumes::validate_volume_mounts(&interpolated) { + return Err(Error::ExecutionErr(e)); + } + interpolated + }; + + #[cfg(feature = "parquet")] + let mut volume_setup = crate::volume_oss::VolumeSetupResult { + states: Vec::new(), + writable: Vec::new(), + client: None, + lease_renewal: crate::volume_oss::LeaseRenewalGuard(None), + }; + + #[cfg(feature = "parquet")] + if !volume_mounts.is_empty() { + let vol_summary: Vec = volume_mounts + .iter() + .map(|v| format!("'{}' -> {}", v.name, v.target)) + .collect(); + append_logs( + &job.id, + &job.workspace_id, + format!( + "\n--- VOLUME MOUNTS ---\nPulling {} volume(s): {}\n", + volume_mounts.len(), + vol_summary.join(", "), + ), + conn, + ) + .await; + + if let Connection::Sql(db) = conn { + volume_setup = crate::volume_oss::setup_volumes_sql_worker( + &volume_mounts, + db, + &job.workspace_id, + job.id, + &job.permissioned_as, + worker_name, + job_dir, + client, + conn, + language, + &mut envs, + &mut shared_mount, + ) + .await?; + } else if let Connection::Http(http) = conn { + volume_setup = crate::volume_oss::setup_volumes_http_worker( + &volume_mounts, + http, + &job.workspace_id, + job.id, + &job.permissioned_as, + &job.canceled_by, + worker_name, + job_dir, + conn, + language, + &mut envs, + &mut shared_mount, + ) + .await?; + } + } + // Box::pin all language handlers to prevent large match enum on stack let result: error::Result> = match language { ScriptLang::Python3 => { @@ -4630,6 +4732,61 @@ mount {{ // for related places search: ADD_NEW_LANG _ => panic!("unreachable, language is not supported: {language:#?}"), }; + // Volume sync-back and lease release + #[cfg(feature = "parquet")] + if !volume_setup.states.is_empty() { + // Stop lease renewal before sync-back + volume_setup.lease_renewal.0.take().map(|h| h.abort()); + + if let Some(ref vol_client) = volume_setup.client { + if let Connection::Sql(db) = conn { + crate::volume_oss::sync_volumes_sql_worker( + &volume_setup.states, + &volume_setup.writable, + vol_client, + db, + &job.workspace_id, + job.id, + worker_name, + conn, + result.is_ok(), + ) + .await; + } + } + + if let Connection::Http(http) = conn { + crate::volume_oss::sync_volumes_http_worker( + &volume_setup.states, + &volume_setup.writable, + http, + &job.workspace_id, + job.id, + worker_name, + conn, + result.is_ok(), + ) + .await; + } + + // Clean up absolute-path symlinks created by setup_volume_mount_paths + if !is_sandboxing_enabled() { + for state in &volume_setup.states { + #[cfg(unix)] + if state.mount.target.starts_with('/') { + let target_path = std::path::Path::new(&state.mount.target); + if target_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + std::fs::remove_file(target_path).ok(); + } + } + } + } + } + tracing::info!( workspace_id = %job.workspace_id, is_ok = result.is_ok(), diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 450dd68399..279c79b780 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -61,6 +61,11 @@ RUN ln -s /usr/bin/bun /usr/bin/node \ && bun install -g windmill-cli \ && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill +# Install Claude Code CLI (used by claude sandbox scripts) +# Copy to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root) +RUN curl -fsSL https://claude.ai/install.sh | bash \ + && cp /root/.local/share/claude/versions/* /usr/bin/claude + # add the docker client to call docker from a worker if enabled COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 7cc4dafa05..88c16aaac0 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -61,6 +61,11 @@ RUN ln -s /usr/bin/bun /usr/bin/node \ && bun install -g windmill-cli \ && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill +# Install Claude Code CLI (used by claude sandbox scripts) +# Copy to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root) +RUN curl -fsSL https://claude.ai/install.sh | bash \ + && cp /root/.local/share/claude/versions/* /usr/bin/claude + # add the docker client to call docker from a worker if enabled COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index d8429a5ebf..8a79bd7109 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -15,6 +15,7 @@ import GitHubAppIntegration from './GitHubAppIntegration.svelte' import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte' import { isCloudHosted } from '$lib/cloud' + import ResourceGen from './copilot/ResourceGen.svelte' interface Props { resourceType: string @@ -149,6 +150,12 @@ }} class="as-json-toggle" /> + {#if resourceType == 'postgresql'} - {#if asset.kind === 's3object'} + {#if asset.kind === 's3object' || asset.kind === 'volume'} Explore {:else if asset.kind === 'resource' || asset.kind === 'ducklake' || asset.kind === 'datatable'} Manage diff --git a/frontend/src/lib/components/FilesetEditor.svelte b/frontend/src/lib/components/FilesetEditor.svelte index 41559d0d8a..39a67f4ad6 100644 --- a/frontend/src/lib/components/FilesetEditor.svelte +++ b/frontend/src/lib/components/FilesetEditor.svelte @@ -46,6 +46,9 @@ } } + // Track the last args we wrote so we can detect external changes. + let lastWrittenArgs: Record = $state(args ?? {}) + // Sync files → args, overlaying current editContent for the active file. // This avoids spreading a new files object on every keystroke. $effect(() => { @@ -58,9 +61,33 @@ newArgs[argKey] = key === currentKey ? currentContent : value } } + lastWrittenArgs = newArgs args = newArgs }) + // Sync args → files when args changes externally (e.g. from AI generation). + $effect(() => { + const currentArgs = args + if (currentArgs === lastWrittenArgs) return + // Check if the args object is actually different + const currentKeys = Object.keys(currentArgs ?? {}).sort().join('\0') + const lastKeys = Object.keys(lastWrittenArgs ?? {}).sort().join('\0') + if (currentKeys === lastKeys) { + const allSame = Object.entries(currentArgs ?? {}).every( + ([k, v]) => lastWrittenArgs[k] === v + ) + if (allSame) return + } + const newFiles = Object.fromEntries( + Object.entries(currentArgs ?? {}).map(([k, v]) => ['/' + k, String(v ?? '')]) + ) + files = newFiles + lastWrittenArgs = currentArgs + const firstFile = Object.keys(newFiles).find((k) => !k.endsWith('/')) + selectedPath = firstFile ?? '/' + editContent = firstFile ? (newFiles[firstFile] ?? '') : '' + }) + function inferLang(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase() if (!ext) return 'plaintext' diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 5a5943390e..0ffed949d1 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -24,6 +24,7 @@ import GitHubAppIntegration from './GitHubAppIntegration.svelte' import Button from './common/button/Button.svelte' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' + import ResourceGen from './copilot/ResourceGen.svelte' interface Props { canSave?: boolean @@ -270,6 +271,13 @@ right: 'As JSON' }} /> + {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} {:else} @@ -296,9 +304,17 @@ {#if loadingSchema} {:else if !viewJsonSchema && resourceTypeInfo?.is_fileset} - - Fileset - + + Fileset + + {:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties} {#if resourceTypeInfo?.format_extension} diff --git a/frontend/src/lib/components/S3FilePicker.svelte b/frontend/src/lib/components/S3FilePicker.svelte index a07ea85d25..55bb25c5ef 100644 --- a/frontend/src/lib/components/S3FilePicker.svelte +++ b/frontend/src/lib/components/S3FilePicker.svelte @@ -13,6 +13,7 @@ interface Props { fromWorkspaceSettings?: boolean readOnlyMode: boolean + allowDelete?: boolean initialFileKey?: { s3: string; storage?: string } | undefined selectedFileKey?: { s3: string; storage?: string } | undefined folderOnly?: boolean @@ -24,6 +25,7 @@ let { fromWorkspaceSettings = false, readOnlyMode, + allowDelete = false, initialFileKey = $bindable(undefined), selectedFileKey = $bindable(undefined), folderOnly = false, @@ -94,6 +96,7 @@ }} {fromWorkspaceSettings} {readOnlyMode} + {allowDelete} bind:initialFileKey bind:selectedFileKey bind:workspaceSettingsInitialized diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index 380e3d9a35..74b85bd327 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -81,6 +81,7 @@ count: number } > + allowDelete?: boolean replaceUnauthorizedWarning?: Snippet listStoredFilesRequest?: (d: ListStoredFilesData) => CancelablePromise loadFilePreviewRequest?: (d: LoadFilePreviewData) => CancelablePromise @@ -102,11 +103,12 @@ folderOnly = false, regexFilter = undefined, hideS3SpecificDetails = false, - rootPath = '', + rootPath: initialRootPath = '', workspaceSettingsInitialized = $bindable(true), storage = $bindable(undefined), uploadModalOpen = $bindable(false), allFilesByKey = $bindable({}), + allowDelete = false, replaceUnauthorizedWarning, listStoredFilesRequest = HelpersService.listStoredFiles, loadFilePreviewRequest = HelpersService.loadFilePreview, @@ -116,6 +118,7 @@ testConnectionRequest = HelpersService.datasetStorageTestConnection }: Props = $props() + let rootPath = $state(initialRootPath) let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1)) let csvSeparatorChar: string = $state(',') @@ -263,7 +266,7 @@ } } } - displayedFileKeys = displayedFileKeys.sort() + displayedFileKeys = [...new Set(displayedFileKeys)].sort() fileListLoading = false fileInfoLoading = false } @@ -381,7 +384,7 @@ } } } - displayedFileKeys = displayedFileKeys.sort() + displayedFileKeys = [...new Set(displayedFileKeys)].sort() } async function clearAndLoadFiles({ keepFilter }: { keepFilter?: boolean } = {}) { @@ -424,9 +427,16 @@ export async function open(_preSelectedFileKey: S3Object | undefined = undefined) { const preSelectedFileKey = _preSelectedFileKey && parseS3Object(_preSelectedFileKey) storage = preSelectedFileKey?.storage - if (preSelectedFileKey !== undefined) { + if (preSelectedFileKey !== undefined && preSelectedFileKey.s3.endsWith('/')) { + rootPath = preSelectedFileKey.s3 + filter = '' + selectedFileKey = undefined + } else if (preSelectedFileKey !== undefined) { + rootPath = '' initialFileKey = { ...preSelectedFileKey } selectedFileKey = { ...preSelectedFileKey } + } else { + rootPath = '' } reloadContent() } @@ -461,7 +471,7 @@ if (selectedFileKey !== undefined) { if (allFilesByKey[selectedFileKey.s3] === undefined) { selectedFileKey = { s3: '', storage } - } else { + } else if (allFilesByKey[selectedFileKey.s3].type !== 'folder') { loadFileMetadataPlusPreviewAsync(selectedFileKey.s3) } } @@ -518,7 +528,7 @@ } } } - displayedFileKeys = displayedFileKeys.sort() + displayedFileKeys = [...new Set(displayedFileKeys)].sort() } else { selectedFileKey = { s3: item_key, @@ -719,8 +729,10 @@ startIcon={{ icon: MoveRight }} iconOnly={true} /> + {/if} + {#if !readOnlyMode || allowDelete} { deletionModalOpen = true diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 6fecf016f7..3de7ad3895 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -382,7 +382,7 @@ async function initContent( language: SupportedLanguage, kind: Script['kind'] | undefined, - template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' + template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' ) { scriptEditor?.disableCollaboration() const templateScript = await isTemplateScript() @@ -1159,9 +1159,10 @@ {#each langs as [label, lang] (lang)} {@const isPicked = - (lang == script.language && template == 'script') || + (lang == script.language && template != 'bunnative' && template != 'docker' && template != 'claudesandbox') || (template == 'bunnative' && lang == 'bunnative') || - (template == 'docker' && lang == 'docker')} + (template == 'docker' && lang == 'docker') || + (template == 'claudesandbox' && lang == 'bun')} @@ -1194,6 +1195,25 @@ {/if} + + Template + { + template = 'claudesandbox' + script.language = 'bun' + initContent('bun', script.kind, template) + }} + > + Claude Sandbox + + {#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true} {#snippet header()} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 5fe03b03fd..0f1e1d8a6e 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -100,7 +100,7 @@ path: string | undefined lang: Preview['language'] kind?: string | undefined - template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' + template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' tag: string | undefined initialArgs?: Record fixedOverflowWidgets?: boolean diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index e5ad54f88b..c8c69106ed 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -35,6 +35,7 @@ | 'postgres_trigger' | 'gcp_trigger' | 'email_trigger' + | 'volume' let kind: Kind let path: string = $state('') @@ -53,13 +54,17 @@ let drawer: Drawer | undefined = $state() let own = $state(false) - export async function openDrawer(newPath: string, kind_l: Kind) { + export async function openDrawer(newPath: string, kind_l: Kind, isOwnerOverride?: boolean) { path = newPath kind = kind_l loadAcls() loadGroups() loadUsernames() - loadOwner() + if (isOwnerOverride !== undefined) { + own = isOwnerOverride + } else { + loadOwner() + } drawer?.openDrawer() } diff --git a/frontend/src/lib/components/assets/JobAssetsViewer.svelte b/frontend/src/lib/components/assets/JobAssetsViewer.svelte index 86b9c7eca7..10e2f011f9 100644 --- a/frontend/src/lib/components/assets/JobAssetsViewer.svelte +++ b/frontend/src/lib/components/assets/JobAssetsViewer.svelte @@ -1,5 +1,5 @@ + + (open = false)}> + (open = false)}> + {#if loading} + + + + {:else if volume} + + + + Files + {volume.file_count} + + + Size + {displaySize(volume.size_bytes) ?? '0 B'} + + + Created at + {displayDate(volume.created_at)} + + + Created by + {volume.created_by} + + {#if volume.last_used_at} + + Last used + {displayDate(volume.last_used_at)} + + {/if} + + + + {#if s3FilePicker} + + Explore files + + {/if} + {#if $userStore?.is_admin} + + Delete + + {/if} + + + {:else} + + Volume '{volumeName}' not found. + + {/if} + + diff --git a/frontend/src/lib/components/assets/VolumesDrawer.svelte b/frontend/src/lib/components/assets/VolumesDrawer.svelte new file mode 100644 index 0000000000..3d6d861b15 --- /dev/null +++ b/frontend/src/lib/components/assets/VolumesDrawer.svelte @@ -0,0 +1,193 @@ + + + (open = false)}> + (open = false)}> + {#snippet actions()} + + {#snippet trigger()} + New volume + {/snippet} + {#snippet content({ close })} + + { + if (e.key === 'Enter' && newVolumeName.trim()) { + createVolume(newVolumeName.trim(), close) + } + } + }} + bind:value={newVolumeName} + /> + createVolume(newVolumeName.trim(), close)} + > + Create + + + {/snippet} + + {/snippet} + {#if volumes.loading} + + + + {:else if !volumes.current?.length} + + No volumes yet. Create one above or they are auto-created when a job declares a volume annotation. + + {:else} + + {#each volumes.current as vol (vol.name)} + {@const readable = canReadVolume(vol.created_by, vol.extra_perms)} + {@const writable = canWriteVolume(vol.created_by, vol.extra_perms)} + + + + + {vol.name} + } + canWrite={writable} + /> + + + {vol.file_count} {vol.file_count === 1 ? 'file' : 'files'} + · {displaySize(vol.size_bytes) ?? '0 B'} + · owner: {vol.created_by.replace(/^u\//, '')} + + + {#if vol.last_used_at} + + Used {displayDate(vol.last_used_at)} + + {/if} + {#if writable} + + shareModal?.openDrawer(vol.name, 'volume', true)} + /> + {/if} + {#if onExplore && readable} + onExplore(vol.name)} + > + Explore + + {/if} + {#if writable} + deleteVolume(vol.name)} + /> + {/if} + + {/each} + + {/if} + + + + refreshKey++} /> diff --git a/frontend/src/lib/components/assets/lib.ts b/frontend/src/lib/components/assets/lib.ts index 2dacedbae3..4dae4c20b1 100644 --- a/frontend/src/lib/components/assets/lib.ts +++ b/frontend/src/lib/components/assets/lib.ts @@ -26,6 +26,8 @@ export function formatAsset(asset: Asset): string { return `ducklake://${asset.path}` case 'datatable': return `datatable://${asset.path}` + case 'volume': + return `volume://${asset.path}` } return 'unknown' } @@ -89,6 +91,8 @@ export function formatAssetKind(asset: { return 'Ducklake' case 'datatable': return 'Data table' + case 'volume': + return 'Volume' } } diff --git a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte index a380a5d753..6c7a052301 100644 --- a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte +++ b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte @@ -24,6 +24,7 @@ import JavaIcon from '$lib/components/icons/JavaIcon.svelte' import DuckDbIcon from '$lib/components/icons/DuckDbIcon.svelte' import RubyIcon from '$lib/components/icons/RubyIcon.svelte' + import ClaudeIcon from '$lib/components/icons/ClaudeIcon.svelte' interface Props { lang: @@ -36,6 +37,7 @@ | 'docker' | 'powershell' | 'bunnative' + | 'claudesandbox' width?: number height?: number scale?: number @@ -45,7 +47,7 @@ let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props() - const languageLabel: Record + + + {#snippet trigger()} + abortController?.abort() : () => {}} + /> + {/snippet} + {#snippet content({ close })} + + {#if $copilotInfo.enabled} + + { + if (e.key === 'Enter' && !e.shiftKey && instructions.length > 0) { + e.preventDefault() + close() + generateResource() + } + }} + /> + { + close() + generateResource() + }} + disabled={instructions.length == 0} + startIcon={{ icon: Wand2 }} + > + Generate + + + {:else} + + Enable Windmill AI in the workspace settings + + {/if} + + {/snippet} + diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index 2e10660b3c..297cbf2ba5 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -288,6 +288,24 @@ {/each} + {#if !failureModule && !preprocessorModule} + AI Sandbox + + { + dispatch('new', { + language: 'bun', + kind, + subkind: 'claudesandbox', + summary + }) + }} + /> + + {/if} + Use pre-made {kind == 'script' ? 'action' : kind} {/if} + {#if selectedKind === 'script' && preFilter === 'all' && !selected} + AI Sandbox + { + dispatch('new', { + kind: selectedKind, + inlineScript: { + language: 'bun', + kind: selectedKind, + subkind: 'claudesandbox', + summary + } + }) + }} + /> + {/if} {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} {#if !selected && preFilter !== 'hub'} diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 1d35270601..0d692d171d 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -78,7 +78,7 @@ export async function pickFlow( export async function createInlineScriptModule( language: RawScript['language'], kind: Script['kind'], - subkind: 'pgsql' | 'flow' | undefined, + subkind: 'pgsql' | 'flow' | 'claudesandbox' | undefined, id: string, summary?: string ): Promise<[FlowModule, FlowModuleState]> { diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 71d87b5a35..3bc8d0c82a 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -31,7 +31,7 @@ }: Props = $props() let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi') - let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' = + let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' | 'aisandbox' = $state(kind) let preFilter: 'all' | 'workspace' | 'hub' = $state('all') let loading = $state(false) @@ -181,27 +181,53 @@ }} /> {/if} + { + selectedKind = 'aisandbox' + }} + /> {/if} {/if} - { - dispatch('close') - }} - on:new - on:pickScript - on:pickFlow - {preFilter} - {displayPath} - refreshCount={refreshCount.val} - /> + {#if selectedKind === 'aisandbox'} + + { + dispatch('close') + dispatch('new', { + kind: 'script', + inlineScript: { + language: 'bun', + kind: 'script', + subkind: 'claudesandbox', + } + }) + }} + /> + + {:else} + { + dispatch('close') + }} + on:new + on:pickScript + on:pickFlow + {preFilter} + {displayPath} + refreshCount={refreshCount.val} + /> + {/if} diff --git a/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte b/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte index ba8332aa72..573db39435 100644 --- a/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte +++ b/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte @@ -8,7 +8,7 @@ interface Props { disabled?: boolean label: string - lang?: SupportedLanguage | 'docker' | 'javascript' | undefined + lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined id?: string | undefined } @@ -32,6 +32,9 @@ {/if} {label} + {#if lang === 'claudesandbox'} + (new) + {/if} {#snippet text()} diff --git a/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte b/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte index 70bdd7a831..b4c90a61db 100644 --- a/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte +++ b/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte @@ -6,7 +6,7 @@ import { createEventDispatcher } from 'svelte' export let label: string - export let lang: SupportedLanguage | 'docker' | 'javascript' | undefined = undefined + export let lang: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined = undefined export let selected = false export let eeRestricted: boolean export let enterpriseLangs: string[] = [] @@ -46,6 +46,9 @@ {/if} {label}{#if eeRestricted} (EE){/if} + {#if lang === 'claudesandbox'} + (new) + {/if} {#if selected} ↵ diff --git a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte index bf600eb839..1e6e4600ca 100644 --- a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte +++ b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte @@ -41,6 +41,8 @@ 'Branch to one': { icon: GitBranch }, 'Branch to all': { icon: GitBranch }, 'AI Agent': { icon: BotIcon, iconClass: 'text-ai' }, + 'AI Sandbox': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' }, + 'Claude Code': { icon: BotIcon, iconClass: 'text-ai' }, MCP: { icon: Plug, showChevron: true }, 'Web Search': { icon: Globe } } diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 9b578030e2..469396a2de 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -27,7 +27,7 @@ export type InsertKind = export type InlineScript = { language: RawScript['language'] kind: Script['kind'] - subkind: 'pgsql' | 'flow' + subkind: 'pgsql' | 'flow' | 'claudesandbox' summary?: string instructions?: string } diff --git a/frontend/src/lib/components/icons/AssetGenericIcon.svelte b/frontend/src/lib/components/icons/AssetGenericIcon.svelte index 669201d919..1e10f38d8a 100644 --- a/frontend/src/lib/components/icons/AssetGenericIcon.svelte +++ b/frontend/src/lib/components/icons/AssetGenericIcon.svelte @@ -1,5 +1,5 @@ + + + + + + + diff --git a/frontend/src/lib/components/raw_apps/fileTreeUtils.ts b/frontend/src/lib/components/raw_apps/fileTreeUtils.ts index b7c277af6d..b8874ddf50 100644 --- a/frontend/src/lib/components/raw_apps/fileTreeUtils.ts +++ b/frontend/src/lib/components/raw_apps/fileTreeUtils.ts @@ -23,13 +23,11 @@ export function buildFileTree(filePaths: string[]): TreeNode[] { currentPath = currentPath ? `${currentPath}/${part}` : part // It's a folder if it's not the last part, or if the original path ended with / const isFolder = i < parts.length - 1 || (i === parts.length - 1 && pathEndsWithSlash) - const isLastPart = i === parts.length - 1 - // Check if this node already exists if (!nodeMap.has(currentPath)) { // Build the node path with trailing / for folders let nodePath = '/' + currentPath - if (isFolder && isLastPart && pathEndsWithSlash) { + if (isFolder) { nodePath = nodePath + '/' } @@ -49,7 +47,7 @@ export function buildFileTree(filePaths: string[]): TreeNode[] { existingNode.isFolder = true existingNode.children = [] // Update path to include trailing / - if (isLastPart && pathEndsWithSlash && !existingNode.path.endsWith('/')) { + if (!existingNode.path.endsWith('/')) { existingNode.path = existingNode.path + '/' } } diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index dd5ac52fad..f224c3bd6a 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -14,7 +14,7 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string - template?: 'docker' | 'bunnative' | 'script' + template?: 'docker' | 'bunnative' | 'claudesandbox' | 'script' initialArgs?: Record lockedLanguage?: boolean showMeta?: boolean diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index db17250222..3e19cb898a 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -102,6 +102,19 @@ let hasUnsavedChanges = $derived.by(() => { return !deepEqual(s3ResourceSettings, s3ResourceSavedSettings) }) + + let volumeStorageItems: { value: string; label: string }[] = $derived.by(() => { + const items: { value: string; label: string }[] = [{ value: '', label: 'Disabled' }] + if (!emptyString(s3ResourceSettings.resourcePath)) { + items.push({ value: 'primary', label: 'Primary storage' }) + } + for (const [name, s] of s3ResourceSettings.secondaryStorage ?? []) { + if (!emptyString(s.resourcePath)) { + items.push({ value: name, label: name }) + } + } + return items + }) @@ -160,6 +173,15 @@ + {#if tableRow[1].resourceType === 'filesystem'} + + {:else} + {/if} - + {#if tableRow[1].resourceType === 'filesystem'} + + {:else} + + {/if} @@ -295,6 +326,25 @@ + + + + s3ResourceSettings.volumeStorage ?? '', + (v) => { + s3ResourceSettings.volumeStorage = v || undefined + } + } + /> + + + } // e.g { id: "number", name: "text" } | { error: string; columns?: undefined } // error message if preparation failed +function parseVolumeAnnotations(code: string, commentPrefix: string): AssetWithAccessType[] { + const volumes: AssetWithAccessType[] = [] + for (const line of code.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + if (!trimmed.startsWith(commentPrefix)) break + const after = trimmed.slice(commentPrefix.length).trim() + const match = after.match(/^volume:\s*(\S+)/) + if (match) { + volumes.push({ kind: 'volume', path: match[1], access_type: 'rw' }) + } + } + return volumes +} + +function getCommentPrefix(language: SupportedLanguage | undefined): string | undefined { + switch (language) { + case 'python3': + case 'bash': + case 'powershell': + case 'ansible': + case 'ruby': + return '#' + case 'deno': + case 'bun': + case 'bunnative': + case 'nativets': + case 'go': + return '//' + default: + return undefined + } +} + export async function inferAssets( language: SupportedLanguage | undefined, code: string @@ -133,28 +167,39 @@ export async function inferAssets( return { status: 'ok', ...JSON.parse(raw_result) } } + let result: InferAssetsResult | undefined + try { if (language === 'duckdb') { await initWasmRegex() - return wrap(parse_assets_sql(code)) - } - if (language === 'deno' || language === 'nativets' || language === 'bun') { + result = wrap(parse_assets_sql(code)) + } else if (language === 'deno' || language === 'nativets' || language === 'bun') { await initWasmTs() - return wrap(parse_assets_ts(code)) - } - if (language === 'python3') { + result = wrap(parse_assets_ts(code)) + } else if (language === 'python3') { await initWasmPython() - return wrap(parse_assets_py(code)) - } - if (language === 'ansible') { + result = wrap(parse_assets_py(code)) + } else if (language === 'ansible') { await initWasmYaml() - return wrap(parse_assets_ansible(code)) + result = wrap(parse_assets_ansible(code)) } } catch (e) { return { status: 'error', error: (e as Error)?.message || JSON.stringify(e) } } - return { status: 'ok', assets: [] } + if (!result) { + result = { status: 'ok', assets: [] } + } + + const prefix = getCommentPrefix(language) + if (prefix && result.status === 'ok') { + const volumeAssets = parseVolumeAnnotations(code, prefix) + if (volumeAssets.length > 0) { + result = { ...result, assets: [...result.assets, ...volumeAssets] } + } + } + + return result } export async function inferAnsibleExecutionMode(code: string): Promise { diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index 6485970f27..de506fbfd2 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -2,6 +2,8 @@ import { type Script } from './gen' import type { SupportedLanguage } from './common' +import CLAUDE_SANDBOX_INIT_CODE from './templates/claude_sandbox.ts.template?raw' + const PYTHON_FAILURE_MODULE_CODE = `import os def main(message: str, name: str, step_id: str): @@ -1349,6 +1351,9 @@ export const INITIAL_CODE = { }, ruby: { script: RUBY_INIT_CODE + }, + claudesandbox: { + script: CLAUDE_SANDBOX_INIT_CODE } // for related places search: ADD_NEW_LANG } @@ -1376,6 +1381,7 @@ export function initialCode( | 'docker' | 'powershell' | 'bunnative' + | 'claudesandbox' | undefined, templateScript?: boolean ): string { @@ -1465,7 +1471,9 @@ export function initialCode( return INITIAL_CODE.ruby.script // for related places search: ADD_NEW_LANG } else if (language == 'bun' || language == 'bunnative') { - if (kind == 'trigger') { + if (subkind === 'claudesandbox') { + return INITIAL_CODE.claudesandbox.script + } else if (kind == 'trigger') { return INITIAL_CODE.bun.trigger } else if (language == 'bunnative' || subkind === 'bunnative') { return INITIAL_CODE.bunnative.script @@ -1505,6 +1513,7 @@ export function getResetCode( | 'docker' | 'powershell' | 'bunnative' + | 'claudesandbox' | undefined ) { if (language === 'deno') { diff --git a/frontend/src/lib/templates/claude_sandbox.ts.template b/frontend/src/lib/templates/claude_sandbox.ts.template new file mode 100644 index 0000000000..1d551bb294 --- /dev/null +++ b/frontend/src/lib/templates/claude_sandbox.ts.template @@ -0,0 +1,85 @@ +// sandbox +// volume: claude .claude + +import { query } from "@anthropic-ai/claude-agent-sdk"; +import * as fs from "fs"; +import * as path from "path"; + +// path -> content, fileset resource, that contains your CLAUDE.md, and skills +type AgentInstructions = Record + +export async function main(anthropic: RT.Anthropic, agent_instructions?: AgentInstructions) { + + const sessionFile = path.join(".claude/session-id.txt"); + + let sessionId = fs.existsSync(sessionFile) ? fs.readFileSync(sessionFile, "utf-8").trim() : undefined + + // Writing claude.md and skills to .claude/ + for (const [filePath, content] of Object.entries(agent_instructions ?? {})) {const fullPath = path.join(filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + + const isResume = !!sessionId; + + // you can hardcode the prompt or pass it as input. + // AgentInstructions can contains a CLAUDE.md where to put the bulk of the instructions as well. + const prompt = !isResume ? + "What is the fastest OSS workflow engine?" : + "What did I ask you before?" + + process.env.ANTHROPIC_API_KEY = anthropic.apiKey; + + let response = ""; + let newSessionId: string | undefined; + let tokenCount = 0 + const seenIds = new Set(); + + for await (const msg of query({ + prompt, + options: { + model: "opus", + pathToClaudeCodeExecutable: "/usr/bin/claude", + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + ...(isResume ? { resume: sessionId } : {}), + }, + })) { + + + if (msg.type === "system" && msg.subtype === "init") { + newSessionId = msg.session_id; + } + if (msg.type === "assistant") { + const msgId = msg.message.id; + if (!seenIds.has(msgId)) { + seenIds.add(msgId); + tokenCount += msg.message.usage.input_tokens + msg.message.usage.output_tokens; + console.log(`${tokenCount} tokens`) + } + + response += msg.message.content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join(""); + } + } + + if (newSessionId) { + fs.writeFileSync(sessionFile, newSessionId); + } + + // Delete all files created from agent_instructions + for (const filePath of Object.keys(agent_instructions ?? {})) { + fs.unlinkSync(path.join(filePath)); + } + + return { + is_resume: isResume, + previous_session_id: sessionId ?? null, + new_session_id: newSessionId, + prompt, + response, + }; +} diff --git a/frontend/src/lib/workspace_settings.ts b/frontend/src/lib/workspace_settings.ts index f3cb30694c..3e1ddc4447 100644 --- a/frontend/src/lib/workspace_settings.ts +++ b/frontend/src/lib/workspace_settings.ts @@ -3,7 +3,13 @@ import { emptyString } from './utils' // Extended type to include GCS support until backend types are regenerated -type S3type = 's3' | 'azure_blob' | 's3_aws_oidc' | 'azure_workload_identity' | 'gcloud_storage' +type S3type = + | 's3' + | 'azure_blob' + | 's3_aws_oidc' + | 'azure_workload_identity' + | 'gcloud_storage' + | 'filesystem' export type S3ResourceSettingsItem = { resourceType: S3type resourcePath: string | undefined @@ -15,6 +21,7 @@ export type S3ResourceSettingsItem = { } export type S3ResourceSettings = S3ResourceSettingsItem & { secondaryStorage: [string, S3ResourceSettingsItem][] | undefined + volumeStorage: string | undefined } export function convertBackendSettingsToFrontendSettings( large_file_storage: GetSettingsResponse['large_file_storage'], @@ -27,6 +34,7 @@ export function convertBackendSettingsToFrontendSettings( settings.secondaryStorage = Object.entries(large_file_storage?.secondary_storage ?? {}).map( ([key, value]) => [key, convertBackendSettingsToFrontendSettingsItem(value, isEnterprise)] ) + settings.volumeStorage = (large_file_storage as any)?.volume_storage ?? undefined return settings as S3ResourceSettings } @@ -79,6 +87,13 @@ export function convertBackendSettingsToFrontendSettingsItem( publicResource: large_file_storage?.public_resource, advancedPermissions } + } else if ((large_file_storage as any)?.type === 'FilesystemStorage') { + return { + resourceType: 'filesystem', + resourcePath: (large_file_storage as any)?.root_path, + publicResource: (large_file_storage as any)?.public_resource, + advancedPermissions + } } else { return { resourceType: 's3', @@ -99,6 +114,9 @@ export function convertFrontendToBackendSetting( .map(([key, value]) => [key, convertFrontendToBackendettingsItem(value)]) .filter(([, value]) => value !== undefined) ) + if (s3ResourceSettings.volumeStorage) { + ;(settings as any).volume_storage = s3ResourceSettings.volumeStorage + } } return settings } @@ -106,6 +124,21 @@ export function convertFrontendToBackendettingsItem( s3ResourceSettings: S3ResourceSettingsItem ): LargeFileStorage | undefined { if (!emptyString(s3ResourceSettings.resourcePath)) { + if (s3ResourceSettings.resourceType === 'filesystem') { + return { + type: 'FilesystemStorage', + root_path: s3ResourceSettings.resourcePath, + public_resource: s3ResourceSettings.publicResource, + ...(s3ResourceSettings.advancedPermissions + ? { + advanced_permissions: s3ResourceSettings.advancedPermissions.map((rule) => ({ + ...rule, + allow: rule.allow.join(',') + })) + } + : {}) + } as any + } let resourcePathWithPrefix = `$res:${s3ResourceSettings.resourcePath}` let params = { public_resource: s3ResourceSettings.publicResource, diff --git a/frontend/src/routes/(root)/(logged)/assets/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/+page.svelte index 032a60af66..d8e7339935 100644 --- a/frontend/src/routes/(root)/(logged)/assets/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/+page.svelte @@ -32,6 +32,9 @@ } from '$lib/components/FilterSearchbar.svelte' import { buildAssetsFilterSchema } from '$lib/components/assets/assetsFilter' import { untrack } from 'svelte' + import { VolumeService } from '$lib/gen' + import VolumesDrawer from '$lib/components/assets/VolumesDrawer.svelte' + import { HardDriveIcon } from 'lucide-svelte' interface AssetCursor { created_at?: string @@ -45,7 +48,8 @@ 'resource', 'variable', 'ducklake', - 'datatable' + 'datatable', + 'volume' ]) // FilterSearchbar setup @@ -127,6 +131,11 @@ d.map((d) => ({ label: d == 'main' ? 'Main data table' : d, value: d })) ) ) + let allVolumes = resource( + () => $workspaceStore, + () => VolumeService.listVolumes({ workspace: $workspaceStore! }) + ) + let volumesDrawer: VolumesDrawer | undefined = $state() function extractFavorites(kind: AssetKind) { return favoriteManager.current @@ -157,6 +166,7 @@ settingsHref: string docsHref: string favorites?: { table: string; schema?: string; assetName: string; path: string }[] + itemExtra?: import('svelte').Snippet<[{ label: string; value: string }]> })} @@ -188,11 +198,16 @@ {#each props.data.current ?? [] as item} {item.label} - + + {#if props.itemExtra} + {@render props.itemExtra(item)} + {/if} + + {/each} @@ -256,13 +271,27 @@ docsHref: 'https://www.windmill.dev/docs/core_concepts/persistent_storage/ducklake', favorites: extractFavorites('ducklake') })} + {#snippet volumesButton(item: { label: string; value: string })} + {#if item.value === '/'} + volumesDrawer?.openDrawer()} + > + {allVolumes.current?.length ?? 0} {(allVolumes.current?.length ?? 0) === 1 ? 'volume' : 'volumes'} + + {/if} + {/snippet} {@render card({ title: 'Object storage', data: allS3Storages, assetKind: 's3object', settingsHref: '/workspace_settings?tab=windmill_lfs', docsHref: - 'https://www.windmill.dev/docs/core_concepts/persistent_storage/large_data_files' + 'https://www.windmill.dev/docs/core_concepts/persistent_storage/large_data_files', + itemExtra: volumesButton })} @@ -293,7 +322,14 @@ {/if} - + + { + const storage = (await VolumeService.getVolumeStorage({ workspace: $workspaceStore! })) ?? undefined + s3FilePicker?.open({ s3: `volumes/${$workspaceStore}/${name}/`, storage }) + }} +/> {#snippet table()} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 2b4739a401..8857b170c5 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -170,13 +170,15 @@ resourceType: 's3', resourcePath: undefined, publicResource: undefined, - secondaryStorage: undefined + secondaryStorage: undefined, + volumeStorage: undefined }) let s3ResourceSavedSettings: S3ResourceSettings = $state({ resourceType: 's3', resourcePath: undefined, publicResource: undefined, - secondaryStorage: undefined + secondaryStorage: undefined, + volumeStorage: undefined }) let dataTableSettings: DataTableSettingsType = $state({ dataTables: [] }) diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index efb208b861..481915dd5c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -654,6 +654,7 @@ components: - resource - ducklake - datatable + - volume access_type: type: string nullable: true From 65082159d83cb1128d4e3532f8aa53f499161b0b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 5 Mar 2026 07:44:32 +0100 Subject: [PATCH 27/58] tighten volume limits (#8236) * feat: add volume limits info in CE volumes drawer Show an info alert in the volumes drawer when running in Community Edition, mentioning the 20 volumes per workspace and 50 MB per file limits. Update ee-repo-ref for companion EE changes. Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to a61366dd4d9e9b1f98a421aaa6d3f63194615275 This commit updates the EE repository reference after PR #438 was merged in windmill-ee-private. Previous ee-repo-ref: 05385738e36e81f5bc51d15c0ca60bba30457c21 New ee-repo-ref: a61366dd4d9e9b1f98a421aaa6d3f63194615275 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../src/lib/components/assets/VolumesDrawer.svelte | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 0e4b8a1791..605b2244b1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -151bc3edfe23c160f4f9b0cfaa708beb36c212f4 \ No newline at end of file +a61366dd4d9e9b1f98a421aaa6d3f63194615275 diff --git a/frontend/src/lib/components/assets/VolumesDrawer.svelte b/frontend/src/lib/components/assets/VolumesDrawer.svelte index 3d6d861b15..bd00dd0dba 100644 --- a/frontend/src/lib/components/assets/VolumesDrawer.svelte +++ b/frontend/src/lib/components/assets/VolumesDrawer.svelte @@ -1,8 +1,8 @@ @@ -47,7 +54,7 @@ @@ -68,7 +75,7 @@ {})}>Back {/if} {#if isGoogleSignin} - + workspace)) { + $workspaceStore = untrack(() => workspace) } onMount(async () => { diff --git a/frontend/src/lib/components/AppWrapper.svelte b/frontend/src/lib/components/AppWrapper.svelte index 37e5bdfc2e..9c6cd9756d 100644 --- a/frontend/src/lib/components/AppWrapper.svelte +++ b/frontend/src/lib/components/AppWrapper.svelte @@ -1,10 +1,11 @@ diff --git a/frontend/src/lib/components/AutheliaSetting.svelte b/frontend/src/lib/components/AutheliaSetting.svelte index bd32b632a9..a3aa2e3458 100644 --- a/frontend/src/lib/components/AutheliaSetting.svelte +++ b/frontend/src/lib/components/AutheliaSetting.svelte @@ -1,16 +1,20 @@ - + + import { run } from 'svelte/legacy'; + import IconedResourceType from './IconedResourceType.svelte' import Toggle from './Toggle.svelte' import SettingCard from './instanceSettings/SettingCard.svelte' - export let value: any + interface Props { + value: any; + } + + let { value = $bindable() }: Props = $props(); - $: enabled = value != undefined - // Initialize org from existing auth_url - $: org = value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? '' - $: changeOrg(org) function changeOrg(org) { if (value && org) { @@ -30,10 +32,16 @@ } } } + let enabled = $derived(value != undefined) + // Initialize org from existing auth_url + let org = $derived(value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? '') + run(() => { + changeOrg(org) + }); - + + import { run } from 'svelte/legacy'; + import { ExternalLink } from 'lucide-svelte' import OauthScopes from './OauthScopes.svelte' - export let connect_config: { + interface Props { + connect_config?: { scopes: string[] auth_url: string token_url: string req_body_auth: boolean extra_params: { tenant_id: string } extra_params_callback: Record - } = { + }; + } + + let { connect_config = $bindable({ scopes: ['offline_access'], auth_url: '', token_url: '', req_body_auth: true, extra_params: { tenant_id: '' }, extra_params_callback: {} - } + }) }: Props = $props(); - $: if (!connect_config) { - connect_config = { - scopes: ['offline_access'], - auth_url: '', - token_url: '', - req_body_auth: true, - extra_params: { tenant_id: '' }, - extra_params_callback: {} + run(() => { + if (!connect_config) { + connect_config = { + scopes: ['offline_access'], + auth_url: '', + token_url: '', + req_body_auth: true, + extra_params: { tenant_id: '' }, + extra_params_callback: {} + } } - } + }); - $: if (connect_config.extra_params.tenant_id) { - connect_config.auth_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/authorize` - connect_config.token_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/token` - } + run(() => { + if (connect_config.extra_params.tenant_id) { + connect_config.auth_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/authorize` + connect_config.token_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/token` + } + }); diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte index 11042b4f1e..8dcad8ef8e 100644 --- a/frontend/src/lib/components/Badge.svelte +++ b/frontend/src/lib/components/Badge.svelte @@ -1,12 +1,22 @@ - + {@render children?.()} {#if tooltip && tooltip != ''} {tooltip} {/if} diff --git a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte index 37e3da3d21..6fa87abf64 100644 --- a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte +++ b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte @@ -5,12 +5,21 @@ import Alert from './common/alert/Alert.svelte' import { createEventDispatcher } from 'svelte' - export let email: string - export let username: string - export let isConflict = false - export let noPadding = false + interface Props { + email: string; + username: string; + isConflict?: boolean; + noPadding?: boolean; + } - let loading = false + let { + email, + username = $bindable(), + isConflict = false, + noPadding = false + }: Props = $props(); + + let loading = $state(false) let usernameInfo: | { @@ -20,7 +29,7 @@ username: string }[] } - | undefined = undefined + | undefined = $state(undefined) function handleKeyUp(event: KeyboardEvent) { const key = event.key @@ -83,7 +92,7 @@ diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte index b859e08691..43277787ba 100644 --- a/frontend/src/lib/components/ContextualVariableEditor.svelte +++ b/frontend/src/lib/components/ContextualVariableEditor.svelte @@ -13,25 +13,25 @@ const dispatch = createEventDispatcher() - let edit: boolean = false - let name: string = '' - let value: string = '' + let edit: boolean = $state(false) + let name: string = $state('') + let value: string = $state('') export function initNew(): void { edit = false name = '' value = '' - drawer.openDrawer() + drawer?.openDrawer() } export function editVariable(editName: string, editValue: string): void { edit = true name = editName value = editValue - drawer.openDrawer() + drawer?.openDrawer() } - let drawer: Drawer + let drawer: Drawer | undefined = $state() async function updateVariable(): Promise { await WorkspaceService.setEnvironmentVariable({ @@ -48,7 +48,7 @@ ) dispatch('update') - drawer.closeDrawer() + drawer?.closeDrawer() setTimeout(() => { dispatch('update') }, 5000) @@ -58,7 +58,7 @@ {#if !edit} diff --git a/frontend/src/lib/components/CustomOauth.svelte b/frontend/src/lib/components/CustomOauth.svelte index 8678f46822..bbc020d794 100644 --- a/frontend/src/lib/components/CustomOauth.svelte +++ b/frontend/src/lib/components/CustomOauth.svelte @@ -1,28 +1,32 @@ @@ -42,12 +46,12 @@ bind:value={connect_config.token_url} /> - + Scopes - + Extra Query Args for Authorize Request - + Extra Query Args for Token request Not needed in most cases - + Payload placement) }) const popperOptions: PopperOptions<{}> = { - placement, + placement: untrack(() => placement), strategy: 'fixed', modifiers: [ { name: 'offset', options: { offset: [8, 8] } }, diff --git a/frontend/src/lib/components/CustomSso.svelte b/frontend/src/lib/components/CustomSso.svelte index e11a8d98d8..5c36e1c57e 100644 --- a/frontend/src/lib/components/CustomSso.svelte +++ b/frontend/src/lib/components/CustomSso.svelte @@ -1,10 +1,12 @@ @@ -51,12 +55,12 @@ bind:value={login_config.userinfo_url} /> - + Scopes - + Extra Query Args for Authorize Request - + Extra Query Args for Token request Not needed in most cases - + Payload + import { untrack } from 'svelte' function validate(values: TableEditorValues, dbSchema?: DBSchema) { const columnNamesErrs = values.columns.flatMap((column) => { const isUnique = values.columns.filter((c) => c.name === column.name).length === 1 @@ -92,7 +93,7 @@ computePreview }: Props = $props() - const columnTypes = DB_TYPES[dbType] + const columnTypes = DB_TYPES[untrack(() => dbType)] const defaultColumnType = ( { postgresql: 'BIGSERIAL', @@ -102,10 +103,10 @@ mysql: 'varchar', duckdb: 'string' } satisfies Record - )[dbType] + )[untrack(() => dbType)] const values: TableEditorValues = $state( - $state.snapshot(initialValues) ?? { + $state.snapshot(untrack(() => initialValues)) ?? { name: '', columns: [], foreignKeys: [] @@ -122,8 +123,8 @@ ...(primaryKey && { primaryKey }) }) } - if (!initialValues) { - addColumn({ name: 'id', primaryKey: features?.primaryKeys }) + if (!untrack(() => initialValues)) { + addColumn({ name: 'id', primaryKey: untrack(() => features)?.primaryKeys }) } const errors: ReturnType = $derived(validate(values, dbSchema)) diff --git a/frontend/src/lib/components/DateInput.svelte b/frontend/src/lib/components/DateInput.svelte index d634a149ae..2d1ee73150 100644 --- a/frontend/src/lib/components/DateInput.svelte +++ b/frontend/src/lib/components/DateInput.svelte @@ -69,6 +69,7 @@ let randomId = 'datetarget-' + Math.random().toString(36).substring(7) + + {#if $userStore?.is_admin || $userStore?.is_super_admin} - + diff --git a/frontend/src/lib/components/DefaultScriptsInner.svelte b/frontend/src/lib/components/DefaultScriptsInner.svelte index 6d4979aae1..1a451d8264 100644 --- a/frontend/src/lib/components/DefaultScriptsInner.svelte +++ b/frontend/src/lib/components/DefaultScriptsInner.svelte @@ -6,8 +6,11 @@ import { defaultScriptLanguages } from '$lib/scripts' import Alert from './common/alert/Alert.svelte' - export let small = false - $: langs = computeLangs($defaultScripts) + interface Props { + small?: boolean; + } + + let { small = false }: Props = $props(); function computeLangs(defaultScripts: WorkspaceDefaultScripts | undefined): Script['language'][] { const allLangs = Object.keys(defaultScriptLanguages) as Script['language'][] @@ -30,6 +33,7 @@ requestBody: $defaultScripts }) } + let langs = $derived(computeLangs($defaultScripts)) @@ -47,7 +51,7 @@ {#if i > 0} changePosition(i ?? 0, true)} + onclick={() => changePosition(i ?? 0, true)} class={small ? 'mr-2 text-secondary text-sm' : 'text-lg mr-2'} title="Move up" > @@ -56,7 +60,7 @@ {/if} {#if i < langs.length - 1} changePosition(i ?? 0, false)} + onclick={() => changePosition(i ?? 0, false)} class={small ? 'mr-2 text-secondary text-sm' : 'text-lg mr-2'} title="Move down">↓ diff --git a/frontend/src/lib/components/Description.svelte b/frontend/src/lib/components/Description.svelte index c3cd74c7d6..2078b74cd2 100644 --- a/frontend/src/lib/components/Description.svelte +++ b/frontend/src/lib/components/Description.svelte @@ -2,11 +2,19 @@ import { ExternalLink } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' - export let link: string | undefined = undefined + + interface Props { + link?: string | undefined; + class?: string; + children?: import('svelte').Snippet; + } + + let { link = undefined, class: className = '', children }: Props = $props(); + - - + + {@render children?.()} {#if link} Learn more initial) + if (untrackedInitial) { + if (untrackedInitial.type == 'script') { + replaceScript(untrackedInitial.script) + } else if (untrackedInitial.type == 'flow') { + replaceFlow(untrackedInitial.flow) } modeInitialized = true } @@ -596,9 +597,9 @@ } }) } - let token = $derived($page.url.searchParams.get('wm_token') ?? undefined) - let workspace = $derived($page.url.searchParams.get('workspace') ?? undefined) - let themeDarkRaw = $derived($page.url.searchParams.get('activeColorTheme')) + let token = $derived(page.url.searchParams.get('wm_token') ?? undefined) + let workspace = $derived(page.url.searchParams.get('workspace') ?? undefined) + let themeDarkRaw = $derived(page.url.searchParams.get('activeColorTheme')) let themeDark = $derived(themeDarkRaw == '2' || themeDarkRaw == '4') $effect.pre(() => { diff --git a/frontend/src/lib/components/DropdownSubmenuItem.svelte b/frontend/src/lib/components/DropdownSubmenuItem.svelte index 45346aaa3c..ed08fd7abd 100644 --- a/frontend/src/lib/components/DropdownSubmenuItem.svelte +++ b/frontend/src/lib/components/DropdownSubmenuItem.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index 902de661aa..c6243f3529 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -76,7 +76,7 @@ ids: { menu: dropdownId } } = createDropdownMenu({ positioning: { - placement + placement: untrack(() => placement) }, loop: true, onOpenChange: ({ next }) => { diff --git a/frontend/src/lib/components/DurationMs.svelte b/frontend/src/lib/components/DurationMs.svelte index 451925c0c6..60d2964ffe 100644 --- a/frontend/src/lib/components/DurationMs.svelte +++ b/frontend/src/lib/components/DurationMs.svelte @@ -4,9 +4,13 @@ import { Hourglass } from 'lucide-svelte' import WaitTimeWarning from './common/waitTimeWarning/WaitTimeWarning.svelte' - export let duration_ms: number - export let self_wait_time_ms: number | undefined = undefined - export let aggregate_wait_time_ms: number | undefined = undefined + interface Props { + duration_ms: number; + self_wait_time_ms?: number | undefined; + aggregate_wait_time_ms?: number | undefined; + } + + let { duration_ms, self_wait_time_ms = undefined, aggregate_wait_time_ms = undefined }: Props = $props(); diff --git a/frontend/src/lib/components/DynamicInput.svelte b/frontend/src/lib/components/DynamicInput.svelte index bd6cceaf99..692272d50e 100644 --- a/frontend/src/lib/components/DynamicInput.svelte +++ b/frontend/src/lib/components/DynamicInput.svelte @@ -113,10 +113,10 @@ } }) - let lastArgs = $state.snapshot(otherArgs) + let lastArgs = $state.snapshot(untrack(() => otherArgs)) let timeout: number | undefined = $state() - let nargs = $state($state.snapshot(otherArgs)) + let nargs = $state($state.snapshot(untrack(() => otherArgs))) $effect(() => { otherArgs untrack(() => clearTimeout(timeout)) diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 99fe00bdb0..989ce6a2fe 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -286,7 +286,7 @@ } } - let jsonView: boolean = $state(customUi?.jsonOnly == true) + let jsonView: boolean = $state(untrack(() => customUi)?.jsonOnly == true) let schemaString: string = $state(JSON.stringify(schema, null, '\t')) let error: string | undefined = $state(undefined) let editor: SimpleEditor | undefined = $state(undefined) @@ -296,8 +296,8 @@ editor?.setCode(schemaString) } - const editTabDefaultSize = noPreview ? 100 : 50 - editPanelSize = editTab ? (editPanelInitialSize ?? editTabDefaultSize) : 0 + const editTabDefaultSize = untrack(() => noPreview) ? 100 : 50 + editPanelSize = untrack(() => editTab) ? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize) : 0 let inputPanelSize = $state(100 - editPanelSize) let editPanelSizeSmooth = tweened(editPanelSize, { duration: 150 @@ -592,7 +592,7 @@ {argName} {#if !uiOnly} - + {#snippet trigger()} scriptLang))) - let filePath = $state(computePath(path)) + let filePath = $state(computePath(untrack(() => path))) - let initialPath: string | undefined = $state(path) + let initialPath: string | undefined = $state(untrack(() => path)) let websockets: WebSocket[] = [] let languageClients: MonacoLanguageClient[] = [] @@ -209,7 +209,7 @@ let destroyed = false const uri = computeUri( untrack(() => filePath), - scriptLang + untrack(() => scriptLang) ) console.log('uri', uri) diff --git a/frontend/src/lib/components/ExecutionDuration.svelte b/frontend/src/lib/components/ExecutionDuration.svelte index 9044adff36..0b96607bfe 100644 --- a/frontend/src/lib/components/ExecutionDuration.svelte +++ b/frontend/src/lib/components/ExecutionDuration.svelte @@ -1,32 +1,40 @@ diff --git a/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte b/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte index 3cbc020c9d..c7f068a83f 100644 --- a/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte +++ b/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte @@ -1,6 +1,7 @@ @@ -54,9 +72,11 @@ {#if !emptyString(simpleTooltip)} - - {simpleTooltip} - + {#snippet text()} + + {simpleTooltip} + + {/snippet} {/if} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index d3187c43aa..7118719957 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -228,7 +228,9 @@ } } - const primaryScheduleStore = writable(savedPrimarySchedule) // kept for legacy reasons + const primaryScheduleStore = writable( + untrack(() => savedPrimarySchedule) + ) // kept for legacy reasons const triggersCount = writable(undefined) const simplifiedPoll = writable(false) @@ -601,8 +603,8 @@ const selectionManager = new SelectionManager() const selectedIdStore = $derived(selectionManager.getSelectedId()) // Initialize with selected id if provided - if (selectedId) { - selectionManager.selectId(selectedId) + if (untrack(() => selectedId)) { + selectionManager.selectId(untrack(() => selectedId) ?? '') } else { selectionManager.selectId('settings-metadata') } @@ -611,11 +613,11 @@ return selectedIdStore } - const previewArgsStore = $state({ val: initialArgs }) + const previewArgsStore = $state({ val: untrack(() => initialArgs) }) const scriptEditorDrawer = writable(undefined) const flowEditorDrawer = writable(undefined) - const history = initHistory(flowStore.val) - const pathStore = writable(pathStoreInit ?? initialPath) + const history = initHistory(untrack(() => flowStore).val) + const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) const captureOn = writable(false) const showCaptureHint = writable(undefined) const flowInputEditorStateStore = writable({ @@ -642,15 +644,15 @@ scriptEditorDrawer, flowEditorDrawer, history, - flowStateStore, - flowStore, + flowStateStore: untrack(() => flowStateStore), + flowStore: untrack(() => flowStore), pathStore, stepsInputArgs, saveDraft, initialPathStore, fakeInitialPath, flowInputsStore: writable({}), - customUi, + customUi: untrack(() => customUi), insertButtonOpen, executionCount: writable(0), flowInputEditorState: flowInputEditorStateStore, @@ -661,10 +663,13 @@ }) // Set up NoteEditor context for note editing capabilities - const noteEditor = new NoteEditor(flowStore, () => { - // Enable notes display when a note is created - flowEditor?.enableNotes?.() - }) + const noteEditor = new NoteEditor( + untrack(() => flowStore), + () => { + // Enable notes display when a note is created + flowEditor?.enableNotes?.() + } + ) setNoteEditorContext(noteEditor) setContext( @@ -678,9 +683,9 @@ [ { type: 'webhook', path: '', isDraft: false }, { type: 'default_email', path: '', isDraft: false }, - ...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? []) + ...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? []) ], - selectedTriggerIndexFromUrl, + untrack(() => selectedTriggerIndexFromUrl), saveSessionDraft ) ) @@ -804,7 +809,7 @@ onClick: () => void }> = [] - if (customUi.topBar?.extraDeployOptions != false) { + if (untrack(() => customUi).topBar?.extraDeployOptions != false) { if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { dropdownItems.push({ label: 'Exit & see details', @@ -812,14 +817,14 @@ }) } - if (!newFlow) { + if (!untrack(() => newFlow)) { dropdownItems.push({ label: 'Fork', onClick: () => window.open(`/flows/add?template=${initialPath}`) }) } - if (!newFlow && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) { + if (!untrack(() => newFlow) && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) { dropdownItems.push({ label: 'Edit in workspace fork', onClick: () => window.open(buildForkEditUrl('flow', initialPath)) @@ -1036,10 +1041,10 @@ } let stepHistoryLoader = new StepHistoryLoader( - loadedFromHistoryFromUrl?.stepsState ?? {}, - loadedFromHistoryFromUrl?.flowJobInitial, + untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {}, + untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial, saveSessionDraft, - noInitial + untrack(() => noInitial) ) setStepHistoryLoaderContext(stepHistoryLoader) diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 35c21cb261..f4b3b1cc97 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -9,23 +9,38 @@ import { dfs } from './flows/dfs' import { workspaceStore } from '$lib/stores' - export let flow: { + + interface Props { + flow: { summary: string description?: string value: FlowValue schema?: any path?: string + }; + overflowAuto?: boolean; + noSide?: boolean; + download?: boolean; + noGraph?: boolean; + triggerNode?: boolean; + stepDetail?: FlowModule | string | undefined; + workspace?: string | undefined; + minHeight?: number; + noBorder?: boolean; } - export let overflowAuto = false - export let noSide = false - export let download = false - export let noGraph = false - export let triggerNode = false - export let stepDetail: FlowModule | string | undefined = undefined - export let workspace: string | undefined = $workspaceStore - export let minHeight = 400 - export let noBorder = false + let { + flow, + overflowAuto = false, + noSide = false, + download = false, + noGraph = false, + triggerNode = false, + stepDetail = $bindable(undefined), + workspace = $workspaceStore, + minHeight = 400, + noBorder = false + }: Props = $props(); const dispatch = createEventDispatcher() diff --git a/frontend/src/lib/components/FlowGraphViewerStep.svelte b/frontend/src/lib/components/FlowGraphViewerStep.svelte index 81d15843d8..8c560abef0 100644 --- a/frontend/src/lib/components/FlowGraphViewerStep.svelte +++ b/frontend/src/lib/components/FlowGraphViewerStep.svelte @@ -19,17 +19,20 @@ import HighlightTheme from './HighlightTheme.svelte' import LanguageIcon from './common/languageIcons/LanguageIcon.svelte' - export let schema: any | undefined = undefined + interface Props { + schema?: any | undefined + stepDetail?: FlowModule | string | undefined + jobScriptHash?: string | undefined + } - export let stepDetail: FlowModule | string | undefined = undefined - export let jobScriptHash: string | undefined = undefined - let codeViewer: Drawer + let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined }: Props = $props() + let codeViewer: Drawer | undefined = $state() - + {#if stepDetail && typeof stepDetail != 'string'} {#if stepDetail.value.type == 'script'} @@ -183,7 +186,7 @@ Expand @@ -221,7 +224,7 @@ Expand diff --git a/frontend/src/lib/components/FlowInputViewer.svelte b/frontend/src/lib/components/FlowInputViewer.svelte index 94c25ffcc4..62aef2dd37 100644 --- a/frontend/src/lib/components/FlowInputViewer.svelte +++ b/frontend/src/lib/components/FlowInputViewer.svelte @@ -3,7 +3,11 @@ import FieldHeader from './FieldHeader.svelte' - export let schema: Schema | { [key: string]: unknown } | undefined + interface Props { + schema: Schema | { [key: string]: unknown } | undefined; + } + + let { schema }: Props = $props(); diff --git a/frontend/src/lib/components/FlowLogViewerWrapper.svelte b/frontend/src/lib/components/FlowLogViewerWrapper.svelte index 4549fc8304..648dbc9f06 100644 --- a/frontend/src/lib/components/FlowLogViewerWrapper.svelte +++ b/frontend/src/lib/components/FlowLogViewerWrapper.svelte @@ -62,7 +62,7 @@ const timelineItems = $derived(timelineCompute?.items ?? undefined) const timelineNow = $derived(timelineCompute?.now ?? Date.now()) - let moduleTracker = new ChangeTracker($state.snapshot(job.raw_flow?.modules ?? [])) + let moduleTracker = new ChangeTracker($state.snapshot(untrack(() => job).raw_flow?.modules ?? [])) $effect(() => { readFieldsRecursively(job.raw_flow?.modules ?? []) untrack(() => moduleTracker.track($state.snapshot(job.raw_flow?.modules ?? []))) @@ -123,7 +123,7 @@ } let timelineAvailableWidths = $state>({}) - let lastJobId: string | undefined = $state(job.id) + let lastJobId: string | undefined = $state(untrack(() => job).id) const timelinelWidth = $derived.by(() => { const widths = Object.values(timelineAvailableWidths) diff --git a/frontend/src/lib/components/FlowPlugConnect.svelte b/frontend/src/lib/components/FlowPlugConnect.svelte index da70d882db..cd44f3d1bd 100644 --- a/frontend/src/lib/components/FlowPlugConnect.svelte +++ b/frontend/src/lib/components/FlowPlugConnect.svelte @@ -4,9 +4,13 @@ import AnimatedButton from './common/button/AnimatedButton.svelte' import { twMerge } from 'tailwind-merge' - export let connecting: boolean - export let id: undefined | string = undefined - export let wrapperClasses = '' + interface Props { + connecting: boolean; + id?: undefined | string; + wrapperClasses?: string; + } + + let { connecting, id = undefined, wrapperClasses = '' }: Props = $props(); diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index 5da8b31419..950eb7354f 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -62,7 +62,7 @@ showLogsWithResult = false }: Props = $props() - let lastJobId: string = jobId + let lastJobId: string = untrack(() => jobId) let retryStatus = $state({ val: {} }) let globalRefreshes: Record Promise)[]> = $state({}) @@ -71,11 +71,11 @@ flowState, suspendStatus, retryStatus, - hideDownloadInGraph, - hideNodeDefinition, - hideTimeline, - hideJobId, - hideDownloadLogs + hideDownloadInGraph: untrack(() => hideDownloadInGraph), + hideNodeDefinition: untrack(() => hideNodeDefinition), + hideTimeline: untrack(() => hideTimeline), + hideJobId: untrack(() => hideJobId), + hideDownloadLogs: untrack(() => hideDownloadLogs) }) function loadOwner(path: string) { diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 410ba822ab..25731f82c8 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -181,7 +181,7 @@ let resultStreams: Record = $state({}) - if (onResultStreamUpdate == undefined) { + if (untrack(() => onResultStreamUpdate) == undefined) { onResultStreamUpdate = ({ jobId, result_stream @@ -234,7 +234,7 @@ }) let jobResults: any[] = $state( - flowJobIds?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? [] + untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? [] ) let retry_selected = $state('') @@ -805,7 +805,7 @@ let destroyed = false - updateRecursiveRefresh(jobId) + updateRecursiveRefresh(untrack(() => jobId)) async function updateJobId() { if (jobId !== job?.id || innerModules == undefined) { diff --git a/frontend/src/lib/components/FlowViewer.svelte b/frontend/src/lib/components/FlowViewer.svelte index 8a78637519..2355ddf68d 100644 --- a/frontend/src/lib/components/FlowViewer.svelte +++ b/frontend/src/lib/components/FlowViewer.svelte @@ -1,4 +1,5 @@ {#if tabular} diff --git a/frontend/src/lib/components/GraphqlSchemaViewer.svelte b/frontend/src/lib/components/GraphqlSchemaViewer.svelte index 7929083d9a..1ab4c68f00 100644 --- a/frontend/src/lib/components/GraphqlSchemaViewer.svelte +++ b/frontend/src/lib/components/GraphqlSchemaViewer.svelte @@ -5,10 +5,17 @@ import { onDestroy, onMount } from 'svelte' - let divEl: HTMLDivElement | null = null + let divEl: HTMLDivElement | null = $state(null) let editor: meditor.IStandaloneCodeEditor - export let code: string = '' + + interface Props { + code?: string; + class?: string; + } + + let { code = '', class: className = '' }: Props = $props(); + async function loadMonaco() { editor = meditor.create(divEl as HTMLDivElement, { @@ -43,4 +50,4 @@ }) - + diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index a8391bd8a6..5a8c4cb411 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -159,12 +159,14 @@ {/if} {#if members} - - - user - - - + + {#snippet headerRow()} + + user + + + + {/snippet} {#snippet body()} {#each members ?? [] as { member_name, role }} @@ -301,10 +303,12 @@ {#if instance_group?.emails} Members from the instance group - - - user - + + {#snippet headerRow()} + + user + + {/snippet} {#snippet body()} {#each instance_group?.emails ?? [] as email} diff --git a/frontend/src/lib/components/IdEditorInput.svelte b/frontend/src/lib/components/IdEditorInput.svelte index 7b85d8e52b..611dbc2d3e 100644 --- a/frontend/src/lib/components/IdEditorInput.svelte +++ b/frontend/src/lib/components/IdEditorInput.svelte @@ -1,4 +1,5 @@ {#if entries.length} diff --git a/frontend/src/lib/components/InstanceGroupEditor.svelte b/frontend/src/lib/components/InstanceGroupEditor.svelte index cf2a3ded0a..f3d8df33de 100644 --- a/frontend/src/lib/components/InstanceGroupEditor.svelte +++ b/frontend/src/lib/components/InstanceGroupEditor.svelte @@ -1,4 +1,6 @@ @@ -85,17 +91,20 @@ {#if members} - - user - - - - {#each members as { member_email }} - {member_email} - - { + {#snippet headerRow()} + + user + + + {/snippet} + {#snippet body()} + + {#each members as { member_email }} + {member_email} + + { await GroupService.removeUserFromInstanceGroup({ name, requestBody: { email: member_email } @@ -104,10 +113,11 @@ sendUserToast('User removed') loadInstanceGroup() }}>remove - - {/each} - + > + + {/each} + + {/snippet} {:else} diff --git a/frontend/src/lib/components/InstanceNameEditor.svelte b/frontend/src/lib/components/InstanceNameEditor.svelte index 31fc3d738b..cb710cddce 100644 --- a/frontend/src/lib/components/InstanceNameEditor.svelte +++ b/frontend/src/lib/components/InstanceNameEditor.svelte @@ -1,4 +1,7 @@ - + {'REALM_URL/protocol/openid-connect/auth'} - + Custom Name diff --git a/frontend/src/lib/components/LogId.svelte b/frontend/src/lib/components/LogId.svelte index bb90a700cf..cadbe14e0c 100644 --- a/frontend/src/lib/components/LogId.svelte +++ b/frontend/src/lib/components/LogId.svelte @@ -1,5 +1,13 @@ diff --git a/frontend/src/lib/components/LogSnippetViewer.svelte b/frontend/src/lib/components/LogSnippetViewer.svelte index 1781351573..8aa1ad9dec 100644 --- a/frontend/src/lib/components/LogSnippetViewer.svelte +++ b/frontend/src/lib/components/LogSnippetViewer.svelte @@ -1,8 +1,14 @@ - + {@html html} diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 0118c32cb8..a62827bba2 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index 66da99e6e6..9547861bc3 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -1,4 +1,6 @@ diff --git a/frontend/src/lib/components/ModuleStatus.svelte b/frontend/src/lib/components/ModuleStatus.svelte index 36f7b54753..351b3e0d03 100644 --- a/frontend/src/lib/components/ModuleStatus.svelte +++ b/frontend/src/lib/components/ModuleStatus.svelte @@ -5,9 +5,13 @@ import { displayDate } from '$lib/utils' import { Hourglass } from 'lucide-svelte' - export let type: FlowStatusModule['type'] - export let scheduled_for: Date | undefined - export let skipped: boolean = false + interface Props { + type: FlowStatusModule['type']; + scheduled_for: Date | undefined; + skipped?: boolean; + } + + let { type, scheduled_for, skipped = false }: Props = $props(); {#if type == 'WaitingForEvents'} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 59c8f973ea..37cb96bea7 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -8,7 +8,7 @@ } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { getScriptByPath } from '$lib/scripts' - import { getContext } from 'svelte' + import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import JobLoader, { type Callbacks } from './JobLoader.svelte' import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte' @@ -167,8 +167,8 @@ testJob = modulesTestStates.states?.[mod.id]?.testJob }) - modulesTestStates.states[mod.id] = { - ...(modulesTestStates.states?.[mod.id] ?? { loading: false }), + modulesTestStates.states[untrack(() => mod).id] = { + ...(modulesTestStates.states?.[untrack(() => mod).id] ?? { loading: false }), loading: testIsLoading, testJob: testJob } diff --git a/frontend/src/lib/components/OauthExtraParams.svelte b/frontend/src/lib/components/OauthExtraParams.svelte index 826f0237af..eec9cd47b8 100644 --- a/frontend/src/lib/components/OauthExtraParams.svelte +++ b/frontend/src/lib/components/OauthExtraParams.svelte @@ -2,9 +2,13 @@ import { Button } from './common' import { X, Plus } from 'lucide-svelte' - export let extra_params: Record = {} + interface Props { + extra_params?: Record; + } - let extra_params_vec: [string, string][] = Object.entries(extra_params) + let { extra_params = $bindable({}) }: Props = $props(); + + let extra_params_vec: [string, string][] = $state(Object.entries(extra_params)) function sync() { extra_params = Object.fromEntries(extra_params_vec) @@ -13,8 +17,8 @@ {#each extra_params_vec as o} - - + + {#if scopes && Array.isArray(scopes)} - {#each scopes as v} + {#each scopes as v, i} - + e.detail && loadUsers()}> - - - - {#if selectedDisplayName} - {selectedDisplayName} + {#snippet trigger()} + + + + {#if selectedDisplayName} + {selectedDisplayName} + {/if} + + + {/snippet} + {#snippet content({ close: closePopover })} + + {label} + + {#if targetEmail} + onSelect('target')} + > + + {targetUsername} + {isDeployment ? '(target)' : '(current)'} + {/if} - - - - {label} - - {#if targetEmail} + + onSelect('me')} + > + + {$userStore?.username} + (me) + + onSelect('target')} + onclick={() => { + closePopover() + openModal() + }} > - - {targetUsername} - {isDeployment ? '(target)' : '(current)'} + {#if selected === 'custom' && customUsername} + + {customUsername} + (custom) + {:else} + + + Pick from workspace… + {/if} - {/if} - - onSelect('me')} - > - - {$userStore?.username} - (me) - - - { - closePopover() - openModal() - }} - > - {#if selected === 'custom' && customUsername} - - {customUsername} - (custom) - {:else} - - - Pick from workspace… - {/if} - - + + {/snippet} diff --git a/frontend/src/lib/components/PageHeader.svelte b/frontend/src/lib/components/PageHeader.svelte index 07b02227d4..556ac1b0cc 100644 --- a/frontend/src/lib/components/PageHeader.svelte +++ b/frontend/src/lib/components/PageHeader.svelte @@ -1,11 +1,23 @@ @@ -31,9 +43,9 @@ {/if} - {#if $$slots.default} + {#if children} - + {@render children?.()} {/if} diff --git a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte index 07a8c267a9..262be32363 100644 --- a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte +++ b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte @@ -1,4 +1,6 @@ @@ -182,7 +189,7 @@ Separator - mountGrid()}> + mountGrid()}> , ; \t diff --git a/frontend/src/lib/components/PermissionHistory.svelte b/frontend/src/lib/components/PermissionHistory.svelte index 82d646bd53..23f3ec151f 100644 --- a/frontend/src/lib/components/PermissionHistory.svelte +++ b/frontend/src/lib/components/PermissionHistory.svelte @@ -79,12 +79,14 @@ No permission changes recorded yet {:else} - - Changed By - Change Type - Affected - Date - + {#snippet headerRow()} + + Changed By + Change Type + Affected + Date + + {/snippet} {#snippet body()} {#each history as change} diff --git a/frontend/src/lib/components/PersistentScriptDrawer.svelte b/frontend/src/lib/components/PersistentScriptDrawer.svelte index aa5c869f31..23fa122b7d 100644 --- a/frontend/src/lib/components/PersistentScriptDrawer.svelte +++ b/frontend/src/lib/components/PersistentScriptDrawer.svelte @@ -10,19 +10,19 @@ import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte' let dispatch = createEventDispatcher() - let drawer: Drawer + let drawer: Drawer | undefined = $state() - let script: Script - let loadQueuedJobs = true - let queuedJobsLoading = false + let script: Script | undefined = $state() + let loadQueuedJobs = $state(true) + let queuedJobsLoading = $state(false) let queuedJobs: { status: 'running' | 'queued' jobId: string scheduledFor: string scriptHash: string - }[] = [] + }[] = $state([]) - let cancellingInProgress = false + let cancellingInProgress = $state(false) async function continuouslyLoadQueuedJobs() { while (loadQueuedJobs) { @@ -40,7 +40,7 @@ let qjs = await JobService.listQueue({ workspace: $workspaceStore ?? '', orderDesc: false, - scriptPathExact: script.path + scriptPathExact: script?.path }) let loadingQueuedJobs: { status: 'running' | 'queued' @@ -71,12 +71,12 @@ cancellingInProgress = true await JobService.cancelPersistentQueuedJobs({ workspace: $workspaceStore ?? '', - path: script.path, + path: script?.path ?? '', requestBody: { reason: undefined } }) - sendUserToast(`All jobs cancelled for ${script.path}`) + sendUserToast(`All jobs cancelled for ${script?.path}`) cancellingInProgress = false } @@ -88,12 +88,12 @@ script = persistentScript! loadQueuedJobs = true continuouslyLoadQueuedJobs() - drawer.openDrawer?.() + drawer?.openDrawer?.() } async function exit() { loadQueuedJobs = false - drawer.closeDrawer?.() + drawer?.closeDrawer?.() } onDestroy(() => { @@ -117,51 +117,57 @@ > - Queued jobs for {script.path} + Queued jobs for {script?.path} - - Script Hash - Job ID - Status - Scheduled For - - - {#each queuedJobs as { jobId, status, scriptHash, scheduledFor }} - - - - {scriptHash} - - - - {jobId.substring(24)} - - - {#if status === 'running'} - - - - {:else} - - - - {/if} - - {scheduledFor} - - {/each} - + {#snippet headerRow()} + + Script Hash + Job ID + Status + Scheduled For + + {/snippet} + {#snippet body()} + + {#each queuedJobs as { jobId, status, scriptHash, scheduledFor }} + + + + {scriptHash} + + + + {jobId.substring(24)} + + + {#if status === 'running'} + + + + {:else} + + + + {/if} + + {scheduledFor} + + {/each} + + {/snippet} {#snippet actions()} diff --git a/frontend/src/lib/components/Popover.svelte b/frontend/src/lib/components/Popover.svelte index 1fa0a0d88d..07447b2e4a 100644 --- a/frontend/src/lib/components/Popover.svelte +++ b/frontend/src/lib/components/Popover.svelte @@ -43,10 +43,10 @@ onClick }: Props = $props() - const [popperRef, popperContent] = createPopperActions({ placement }) + const [popperRef, popperContent] = createPopperActions({ placement: untrack(() => placement) }) const popperOptions: PopperOptions<{}> = { - placement, + placement: untrack(() => placement), strategy: 'fixed', modifiers: [ { name: 'offset', options: { offset: [8, 8] } }, diff --git a/frontend/src/lib/components/PrefixedInput.svelte b/frontend/src/lib/components/PrefixedInput.svelte index c548418855..2a1dccf386 100644 --- a/frontend/src/lib/components/PrefixedInput.svelte +++ b/frontend/src/lib/components/PrefixedInput.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/RadioButton.svelte b/frontend/src/lib/components/RadioButton.svelte index efa9a62b40..c7e5dc8dce 100644 --- a/frontend/src/lib/components/RadioButton.svelte +++ b/frontend/src/lib/components/RadioButton.svelte @@ -1,13 +1,24 @@ @@ -28,7 +39,7 @@ class="sr-only" bind:group={value} aria-labelledby="memory-option-0-label" - on:click={() => dispatch('change', val)} + onclick={() => dispatch('change', val)} /> {#if typeof label !== 'string'} diff --git a/frontend/src/lib/components/Range.svelte b/frontend/src/lib/components/Range.svelte index 0e92ac72de..72129aada2 100644 --- a/frontend/src/lib/components/Range.svelte +++ b/frontend/src/lib/components/Range.svelte @@ -1,29 +1,47 @@ - + {#if max <= min} Impossible to display range: {`max (${max}) <= min (${min})`} + import { untrack } from 'svelte' import { GitSyncService } from '$lib/gen' import Select from './select/Select.svelte' @@ -32,7 +33,7 @@ }: Props = $props() // Track all loaded repositories across pages - let loadedRepositories = $state(initialRepositories) + let loadedRepositories = $state(untrack(() => initialRepositories)) let currentPage = $state(1) let isLoadingMore = $state(false) diff --git a/frontend/src/lib/components/Required.svelte b/frontend/src/lib/components/Required.svelte index 2163efa976..3ee1deec1a 100644 --- a/frontend/src/lib/components/Required.svelte +++ b/frontend/src/lib/components/Required.svelte @@ -1,12 +1,19 @@ {#if required} - * + * {:else if detail || detail != ''} - ({detail != '' ? `${detail}` : ''}) {/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 0ffed949d1..70aaf6a2ee 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -30,7 +30,6 @@ canSave?: boolean resource_type?: string | undefined path?: string - newResource?: boolean hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined @@ -40,7 +39,6 @@ canSave = $bindable(true), resource_type = $bindable(undefined), path = $bindable(''), - newResource = false, hidePath = false, onChange, defaultValues = undefined @@ -63,6 +61,7 @@ let resourceTypeInfo: ResourceType | undefined = $state(undefined) let editDescription = $state(false) let viewJsonSchema = $state(false) + let newResource = $derived(!path) const dispatch = createEventDispatcher() @@ -82,7 +81,7 @@ .map(([k, _]) => k) } - if (!newResource) { + if (!untrack(() => newResource)) { initEdit() } else if (resource_type) { loadResourceType() diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 50db916f13..dae6943868 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -5,50 +5,44 @@ import { Loader2, Save } from 'lucide-svelte' - let drawer: Drawer - let canSave = true - let resource_type: string | undefined = undefined - let defaultValues: Record | undefined = undefined + let drawer: Drawer | undefined = $state() + let canSave = $state(true) + let resource_type: string | undefined = $state(undefined) + let defaultValues: Record | undefined = $state(undefined) let resourceEditor: { editResource: () => void; createResource: () => void } | undefined = - undefined + $state(undefined) - let path: string | undefined = undefined + let path: string | undefined = $state(undefined) - let newResource = false export async function initEdit(p: string): Promise { resource_type = undefined - newResource = false path = p - drawer.openDrawer?.() + drawer?.openDrawer?.() } export async function initNew( resourceType: string, nDefaultValues?: Record ): Promise { - newResource = true path = undefined resource_type = resourceType defaultValues = nDefaultValues - drawer.openDrawer?.() + drawer?.openDrawer?.() } - let mode: 'edit' | 'new' = newResource ? 'new' : 'edit' - - $: path ? (mode = 'edit') : (mode = 'new') + let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') {#await import('./ResourceEditor.svelte')} {:then Module} diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 6806e60d3c..fddf9205f5 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -152,7 +152,7 @@ loading = false } - let previousResourceType = resourceType + let previousResourceType = untrack(() => resourceType) $effect(() => { $workspaceStore && resourceType diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 969576a06b..da0c87a15f 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -15,7 +15,7 @@ import Popover from './meltComponents/Popover.svelte' import { Calendar, Check, CornerDownLeft } from 'lucide-svelte' import RunFormAdvancedPopup from './RunFormAdvancedPopup.svelte' - import { page } from '$app/stores' + import { page } from '$app/state' import { replaceState } from '$app/navigation' import JsonInputs from '$lib/components/JsonInputs.svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' @@ -108,7 +108,7 @@ nurl.hash = computeSharableHash(args) try { - replaceState(nurl.toString(), $page.state) + replaceState(nurl.toString(), page.state) } catch (e) { console.error(e) } diff --git a/frontend/src/lib/components/RunFormAdvancedPopup.svelte b/frontend/src/lib/components/RunFormAdvancedPopup.svelte index 7d6ef01bb2..399e55f772 100644 --- a/frontend/src/lib/components/RunFormAdvancedPopup.svelte +++ b/frontend/src/lib/components/RunFormAdvancedPopup.svelte @@ -8,7 +8,9 @@ import { WorkerService } from '$lib/gen' import DateTimeInput from './DateTimeInput.svelte' - export let runnable: + + interface Props { + runnable: | { summary?: string description?: string @@ -21,11 +23,18 @@ created_by?: string extra_perms?: Record } - | undefined + | undefined; + scheduledForStr: string | undefined; + invisible_to_owner: boolean | undefined; + overrideTag: string | undefined; + } - export let scheduledForStr: string | undefined - export let invisible_to_owner: boolean | undefined - export let overrideTag: string | undefined + let { + runnable, + scheduledForStr = $bindable(), + invisible_to_owner = $bindable(), + overrideTag = $bindable() + }: Props = $props(); loadWorkerGroups() async function loadWorkerGroups() { diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index d03495c052..5aee359a4a 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -96,8 +96,8 @@ let batchRerunOptionsIsOpen = $state(false) // Initialize path filter from route param if provided and not already set via query params - if (initialPath && !filters.val.path) { - filters.val.path = initialPath + if (untrack(() => initialPath) && !filters.val.path) { + filters.val.path = untrack(() => initialPath) } // Apply persistent toggle values from local storage if URL doesn't specify them diff --git a/frontend/src/lib/components/S3ObjectPicker.svelte b/frontend/src/lib/components/S3ObjectPicker.svelte index c70983ee83..1c461708cd 100644 --- a/frontend/src/lib/components/S3ObjectPicker.svelte +++ b/frontend/src/lib/components/S3ObjectPicker.svelte @@ -11,15 +11,19 @@ import S3FilePicker from './S3FilePicker.svelte' import FileUpload from './common/fileUpload/FileUpload.svelte' - export let value: any - export let editor: SimpleEditor | undefined = undefined + interface Props { + value: any + editor?: SimpleEditor | undefined + } + + let { value = $bindable(), editor = $bindable(undefined) }: Props = $props() const dispatch = createEventDispatcher() - let s3FilePicker: S3FilePicker - let s3FileUploadRawMode: false + let s3FilePicker: S3FilePicker | undefined = $state() + let s3FileUploadRawMode: boolean | undefined = $state() let el: HTMLTextAreaElement | undefined = undefined - let rawValue: string | undefined = undefined + let rawValue: string | undefined = $state(undefined) function evalValueToRaw() { rawValue = JSON.stringify(value, null, 2) diff --git a/frontend/src/lib/components/SaveInputsButton.svelte b/frontend/src/lib/components/SaveInputsButton.svelte index 414e5ff205..4bddde54ca 100644 --- a/frontend/src/lib/components/SaveInputsButton.svelte +++ b/frontend/src/lib/components/SaveInputsButton.svelte @@ -10,14 +10,25 @@ const dispatch = createEventDispatcher() - export let runnableId: string | undefined - export let runnableType: RunnableType | undefined - export let args: object - export let disabled: boolean = false - export let small: boolean | undefined = undefined - export let showTooltip: boolean | undefined = undefined + interface Props { + runnableId: string | undefined; + runnableType: RunnableType | undefined; + args: object; + disabled?: boolean; + small?: boolean | undefined; + showTooltip?: boolean | undefined; + } - let savingInputs = false + let { + runnableId, + runnableType, + args, + disabled = false, + small = undefined, + showTooltip = undefined + }: Props = $props(); + + let savingInputs = $state(false) async function saveInput(args: object) { savingInputs = true diff --git a/frontend/src/lib/components/SavedInputsPickerViewer.svelte b/frontend/src/lib/components/SavedInputsPickerViewer.svelte index 150e2b9b97..f033bbf4d9 100644 --- a/frontend/src/lib/components/SavedInputsPickerViewer.svelte +++ b/frontend/src/lib/components/SavedInputsPickerViewer.svelte @@ -1,20 +1,34 @@ - + {#snippet trigger({ isOpen })} - - + {/snippet} + {#snippet content()} { + onclick={() => { copyToClipboard(JSON.stringify(payloadData)) }} - on:keydown + onkeydown={bubble('keydown')} > {#if !objectViewerLoaded && payloadTooBigForPreview} @@ -158,5 +174,5 @@ {/if} - + {/snippet} diff --git a/frontend/src/lib/components/SchemaEditorProperty.svelte b/frontend/src/lib/components/SchemaEditorProperty.svelte index 611e178981..fa12138c0d 100644 --- a/frontend/src/lib/components/SchemaEditorProperty.svelte +++ b/frontend/src/lib/components/SchemaEditorProperty.svelte @@ -2,7 +2,11 @@ import type { SchemaProperty } from '$lib/common' import Badge from './common/badge/Badge.svelte' - export let property: SchemaProperty + interface Props { + property: SchemaProperty; + } + + let { property }: Props = $props(); diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 3de7ad3895..e4e166791b 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -157,7 +157,7 @@ let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning let open: boolean = $state(false) // Is confirmation modal open - let args: Record = $state(initialArgs) // Test args input + let args: Record = $state(untrack(() => initialArgs)) // Test args input let selectedInputTab: 'main' | 'preprocessor' = $state('main') let hasPreprocessor = $state(false) let preserveOnBehalfOf = $state(false) @@ -170,12 +170,12 @@ let customOnBehalfOfEmail: string = $state('') let metadataOpen = $state( - !neverShowMeta && - (showMeta || - searchParams.get('metadata_open') == 'true' || + !untrack(() => neverShowMeta) && + (untrack(() => showMeta) || + untrack(() => searchParams).get('metadata_open') == 'true' || (initialPath == '' && - searchParams.get('state') == undefined && - searchParams.get('collab') == undefined)) + untrack(() => searchParams).get('state') == undefined && + untrack(() => searchParams).get('collab') == undefined)) ) let editor: Editor | undefined = $state(undefined) @@ -193,10 +193,15 @@ confirmDeploymentCallback(selectedTriggers) } - const primaryScheduleStore = writable(savedPrimarySchedule) // keep for legacy + const primaryScheduleStore = writable( + untrack(() => savedPrimarySchedule) + ) // keep for legacy const triggersCount = writable( - savedPrimarySchedule - ? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } } + untrack(() => savedPrimarySchedule) + ? { + schedule_count: 1, + primary_schedule: { schedule: untrack(() => savedPrimarySchedule)!.cron } + } : undefined ) const simplifiedPoll = writable(false) @@ -859,7 +864,7 @@ })() ) - setContext('disableTooltips', customUi?.disableTooltips === true) + setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true) function langToLanguage(lang: SupportedLanguage | 'docker' | 'bunnative'): SupportedLanguage { if (lang == 'docker') { @@ -1672,8 +1677,10 @@ /> {:else if script.on_behalf_of_email && !canPreserve} - Currently: {originalOnBehalfOfEmail ?? script.on_behalf_of_email}. - Will be set to {$userStore?.email} on deploy (requires admin or wm_deployers group to override) + Currently: {originalOnBehalfOfEmail ?? script.on_behalf_of_email}. Will be set to {$userStore?.email} on + deploy (requires admin or wm_deployers group to override) {/if} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 0f1e1d8a6e..f310010c18 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -123,7 +123,7 @@ lastDeployedCode?: string | undefined disableAi?: boolean assets?: AssetWithAltAccessType[] - editor_bar_right?: import('svelte').Snippet + editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean } @@ -155,7 +155,7 @@ lastDeployedCode = undefined, disableAi = false, assets = $bindable(), - editor_bar_right, + editorBarRight, enablePreprocessorSnippet = false }: Props = $props() @@ -883,7 +883,7 @@ } } - setContext('disableTooltips', customUi?.disableTooltips === true) + setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true) let codePanelSize = $state(70) let testPanelSize = $state(30) @@ -1042,7 +1042,7 @@ bind:showHistoryDrawer > {#snippet right()} - {@render editor_bar_right?.()} + {@render editorBarRight?.()} {/snippet} {/if} diff --git a/frontend/src/lib/components/ScriptPicker.svelte b/frontend/src/lib/components/ScriptPicker.svelte index 5fa0618edd..803684f49e 100644 --- a/frontend/src/lib/components/ScriptPicker.svelte +++ b/frontend/src/lib/components/ScriptPicker.svelte @@ -52,7 +52,7 @@ let lang: SupportedLanguage | undefined = $state() let options: [[string, any, any, string | undefined]] = [['Script', 'script', Code2, undefined]] - allowFlow && options.push(['Flow', 'flow', FlowIcon, '#14b8a6']) + untrack(() => allowFlow) && options.push(['Flow', 'flow', FlowIcon, '#14b8a6']) const dispatch = createEventDispatcher() async function loadItems(): Promise { diff --git a/frontend/src/lib/components/ScriptWrapper.svelte b/frontend/src/lib/components/ScriptWrapper.svelte index 5e53dc09f3..eec781ce77 100644 --- a/frontend/src/lib/components/ScriptWrapper.svelte +++ b/frontend/src/lib/components/ScriptWrapper.svelte @@ -1,11 +1,12 @@ diff --git a/frontend/src/lib/components/Scrollable.svelte b/frontend/src/lib/components/Scrollable.svelte index 37e3ae2933..7c34b3cbe1 100644 --- a/frontend/src/lib/components/Scrollable.svelte +++ b/frontend/src/lib/components/Scrollable.svelte @@ -2,14 +2,19 @@ import { onMount, onDestroy } from 'svelte' import { twMerge } from 'tailwind-merge' - let isAtBottom: boolean = false - let isScrollable = false + let isAtBottom: boolean = $state(false) + let isScrollable = $state(false) - export let id: string | null | undefined = undefined - export let scrollableClass: string = '' - export let shiftedShadow: boolean = false + interface Props { + id?: string | null | undefined + scrollableClass?: string + shiftedShadow?: boolean + children?: import('svelte').Snippet + } + + let { id = undefined, scrollableClass = '', shiftedShadow = false, children }: Props = $props() let mutationObserver: MutationObserver - let el: HTMLDivElement + let el: HTMLDivElement | undefined = $state() function handleScroll(event) { const scrollableElement = event.target @@ -20,7 +25,8 @@ } function checkIfScrollable(el) { - return el.scrollHeight > el.clientHeight + if (!el) return false + return el?.scrollHeight > el?.clientHeight } function observeScrollability(el) { @@ -33,7 +39,7 @@ } export function scrollIntoView(top: number) { - el.scrollTo({ top, behavior: 'smooth' }) + el?.scrollTo({ top, behavior: 'smooth' }) } onMount(() => { observeScrollability(el) @@ -45,8 +51,8 @@ - - + + {@render children?.()} {#if !isAtBottom && isScrollable} opts)) function filterItems() { let trimmed = filter.trim() diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte index 0f4f923cd6..abcffa32ef 100644 --- a/frontend/src/lib/components/ServiceLogsInner.svelte +++ b/frontend/src/lib/components/ServiceLogsInner.svelte @@ -22,7 +22,7 @@ import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte' import Select from './select/Select.svelte' import { goto } from '$lib/navigation' - import { page } from '$app/stores' + import { page } from '$app/state' import { watch } from 'runed' interface Props { @@ -169,13 +169,13 @@ type Selected = { mode: string; workerGroup: string; hostname: string } let initialSelected = - $page.url.searchParams.get('mode') && - $page.url.searchParams.get('workerGroup') && - $page.url.searchParams.get('hostname') + page.url.searchParams.get('mode') && + page.url.searchParams.get('workerGroup') && + page.url.searchParams.get('hostname') ? { - mode: $page.url.searchParams.get('mode')!, - workerGroup: $page.url.searchParams.get('workerGroup')!, - hostname: $page.url.searchParams.get('hostname')! + mode: page.url.searchParams.get('mode')!, + workerGroup: page.url.searchParams.get('workerGroup')!, + hostname: page.url.searchParams.get('hostname')! } : undefined let selected: Selected | undefined = $state(initialSelected) @@ -663,7 +663,7 @@ { + onClick={() => { let logLineNumber = document.line_number[0] let logFile = document.file_name[0] let host = document.host[0] diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index c8c69106ed..10cf5f09e0 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -159,12 +159,14 @@ {/if} {#if acls?.length > 0} - - -
+ This recording does not contain valid job data. It may have been recorded incorrectly. +
+ No schema available in this recording +
- Upload a recording JSON file to replay a flow execution offline. + Upload a recording JSON file to replay a flow or script execution offline.
{selectionManager.selectedIds.length} nodes selected
flow_env.VARIABLE_NAME
flow_env["VARIABLE_NAME"]
Enable Windmill AI in the workspace settings
{@html html} diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 0118c32cb8..a62827bba2 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index 66da99e6e6..9547861bc3 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -1,4 +1,6 @@ diff --git a/frontend/src/lib/components/ModuleStatus.svelte b/frontend/src/lib/components/ModuleStatus.svelte index 36f7b54753..351b3e0d03 100644 --- a/frontend/src/lib/components/ModuleStatus.svelte +++ b/frontend/src/lib/components/ModuleStatus.svelte @@ -5,9 +5,13 @@ import { displayDate } from '$lib/utils' import { Hourglass } from 'lucide-svelte' - export let type: FlowStatusModule['type'] - export let scheduled_for: Date | undefined - export let skipped: boolean = false + interface Props { + type: FlowStatusModule['type']; + scheduled_for: Date | undefined; + skipped?: boolean; + } + + let { type, scheduled_for, skipped = false }: Props = $props(); {#if type == 'WaitingForEvents'} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 59c8f973ea..37cb96bea7 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -8,7 +8,7 @@ } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { getScriptByPath } from '$lib/scripts' - import { getContext } from 'svelte' + import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import JobLoader, { type Callbacks } from './JobLoader.svelte' import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte' @@ -167,8 +167,8 @@ testJob = modulesTestStates.states?.[mod.id]?.testJob }) - modulesTestStates.states[mod.id] = { - ...(modulesTestStates.states?.[mod.id] ?? { loading: false }), + modulesTestStates.states[untrack(() => mod).id] = { + ...(modulesTestStates.states?.[untrack(() => mod).id] ?? { loading: false }), loading: testIsLoading, testJob: testJob } diff --git a/frontend/src/lib/components/OauthExtraParams.svelte b/frontend/src/lib/components/OauthExtraParams.svelte index 826f0237af..eec9cd47b8 100644 --- a/frontend/src/lib/components/OauthExtraParams.svelte +++ b/frontend/src/lib/components/OauthExtraParams.svelte @@ -2,9 +2,13 @@ import { Button } from './common' import { X, Plus } from 'lucide-svelte' - export let extra_params: Record = {} + interface Props { + extra_params?: Record; + } - let extra_params_vec: [string, string][] = Object.entries(extra_params) + let { extra_params = $bindable({}) }: Props = $props(); + + let extra_params_vec: [string, string][] = $state(Object.entries(extra_params)) function sync() { extra_params = Object.fromEntries(extra_params_vec) @@ -13,8 +17,8 @@ {#each extra_params_vec as o} - - + + {#if scopes && Array.isArray(scopes)} - {#each scopes as v} + {#each scopes as v, i} - + e.detail && loadUsers()}> - - - - {#if selectedDisplayName} - {selectedDisplayName} + {#snippet trigger()} + + + + {#if selectedDisplayName} + {selectedDisplayName} + {/if} + + + {/snippet} + {#snippet content({ close: closePopover })} + + {label} + + {#if targetEmail} + onSelect('target')} + > + + {targetUsername} + {isDeployment ? '(target)' : '(current)'} + {/if} - - - - {label} - - {#if targetEmail} + + onSelect('me')} + > + + {$userStore?.username} + (me) + + onSelect('target')} + onclick={() => { + closePopover() + openModal() + }} > - - {targetUsername} - {isDeployment ? '(target)' : '(current)'} + {#if selected === 'custom' && customUsername} + + {customUsername} + (custom) + {:else} + + + Pick from workspace… + {/if} - {/if} - - onSelect('me')} - > - - {$userStore?.username} - (me) - - - { - closePopover() - openModal() - }} - > - {#if selected === 'custom' && customUsername} - - {customUsername} - (custom) - {:else} - - - Pick from workspace… - {/if} - - + + {/snippet} diff --git a/frontend/src/lib/components/PageHeader.svelte b/frontend/src/lib/components/PageHeader.svelte index 07b02227d4..556ac1b0cc 100644 --- a/frontend/src/lib/components/PageHeader.svelte +++ b/frontend/src/lib/components/PageHeader.svelte @@ -1,11 +1,23 @@ @@ -31,9 +43,9 @@ {/if} - {#if $$slots.default} + {#if children} - + {@render children?.()} {/if} diff --git a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte index 07a8c267a9..262be32363 100644 --- a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte +++ b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte @@ -1,4 +1,6 @@ @@ -182,7 +189,7 @@ Separator - mountGrid()}> + mountGrid()}> , ; \t diff --git a/frontend/src/lib/components/PermissionHistory.svelte b/frontend/src/lib/components/PermissionHistory.svelte index 82d646bd53..23f3ec151f 100644 --- a/frontend/src/lib/components/PermissionHistory.svelte +++ b/frontend/src/lib/components/PermissionHistory.svelte @@ -79,12 +79,14 @@ No permission changes recorded yet {:else} - - Changed By - Change Type - Affected - Date - + {#snippet headerRow()} + + Changed By + Change Type + Affected + Date + + {/snippet} {#snippet body()} {#each history as change} diff --git a/frontend/src/lib/components/PersistentScriptDrawer.svelte b/frontend/src/lib/components/PersistentScriptDrawer.svelte index aa5c869f31..23fa122b7d 100644 --- a/frontend/src/lib/components/PersistentScriptDrawer.svelte +++ b/frontend/src/lib/components/PersistentScriptDrawer.svelte @@ -10,19 +10,19 @@ import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte' let dispatch = createEventDispatcher() - let drawer: Drawer + let drawer: Drawer | undefined = $state() - let script: Script - let loadQueuedJobs = true - let queuedJobsLoading = false + let script: Script | undefined = $state() + let loadQueuedJobs = $state(true) + let queuedJobsLoading = $state(false) let queuedJobs: { status: 'running' | 'queued' jobId: string scheduledFor: string scriptHash: string - }[] = [] + }[] = $state([]) - let cancellingInProgress = false + let cancellingInProgress = $state(false) async function continuouslyLoadQueuedJobs() { while (loadQueuedJobs) { @@ -40,7 +40,7 @@ let qjs = await JobService.listQueue({ workspace: $workspaceStore ?? '', orderDesc: false, - scriptPathExact: script.path + scriptPathExact: script?.path }) let loadingQueuedJobs: { status: 'running' | 'queued' @@ -71,12 +71,12 @@ cancellingInProgress = true await JobService.cancelPersistentQueuedJobs({ workspace: $workspaceStore ?? '', - path: script.path, + path: script?.path ?? '', requestBody: { reason: undefined } }) - sendUserToast(`All jobs cancelled for ${script.path}`) + sendUserToast(`All jobs cancelled for ${script?.path}`) cancellingInProgress = false } @@ -88,12 +88,12 @@ script = persistentScript! loadQueuedJobs = true continuouslyLoadQueuedJobs() - drawer.openDrawer?.() + drawer?.openDrawer?.() } async function exit() { loadQueuedJobs = false - drawer.closeDrawer?.() + drawer?.closeDrawer?.() } onDestroy(() => { @@ -117,51 +117,57 @@ > - Queued jobs for {script.path} + Queued jobs for {script?.path} - - Script Hash - Job ID - Status - Scheduled For - - - {#each queuedJobs as { jobId, status, scriptHash, scheduledFor }} - - - - {scriptHash} - - - - {jobId.substring(24)} - - - {#if status === 'running'} - - - - {:else} - - - - {/if} - - {scheduledFor} - - {/each} - + {#snippet headerRow()} + + Script Hash + Job ID + Status + Scheduled For + + {/snippet} + {#snippet body()} + + {#each queuedJobs as { jobId, status, scriptHash, scheduledFor }} + + + + {scriptHash} + + + + {jobId.substring(24)} + + + {#if status === 'running'} + + + + {:else} + + + + {/if} + + {scheduledFor} + + {/each} + + {/snippet} {#snippet actions()} diff --git a/frontend/src/lib/components/Popover.svelte b/frontend/src/lib/components/Popover.svelte index 1fa0a0d88d..07447b2e4a 100644 --- a/frontend/src/lib/components/Popover.svelte +++ b/frontend/src/lib/components/Popover.svelte @@ -43,10 +43,10 @@ onClick }: Props = $props() - const [popperRef, popperContent] = createPopperActions({ placement }) + const [popperRef, popperContent] = createPopperActions({ placement: untrack(() => placement) }) const popperOptions: PopperOptions<{}> = { - placement, + placement: untrack(() => placement), strategy: 'fixed', modifiers: [ { name: 'offset', options: { offset: [8, 8] } }, diff --git a/frontend/src/lib/components/PrefixedInput.svelte b/frontend/src/lib/components/PrefixedInput.svelte index c548418855..2a1dccf386 100644 --- a/frontend/src/lib/components/PrefixedInput.svelte +++ b/frontend/src/lib/components/PrefixedInput.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/RadioButton.svelte b/frontend/src/lib/components/RadioButton.svelte index efa9a62b40..c7e5dc8dce 100644 --- a/frontend/src/lib/components/RadioButton.svelte +++ b/frontend/src/lib/components/RadioButton.svelte @@ -1,13 +1,24 @@ @@ -28,7 +39,7 @@ class="sr-only" bind:group={value} aria-labelledby="memory-option-0-label" - on:click={() => dispatch('change', val)} + onclick={() => dispatch('change', val)} /> {#if typeof label !== 'string'} diff --git a/frontend/src/lib/components/Range.svelte b/frontend/src/lib/components/Range.svelte index 0e92ac72de..72129aada2 100644 --- a/frontend/src/lib/components/Range.svelte +++ b/frontend/src/lib/components/Range.svelte @@ -1,29 +1,47 @@ - + {#if max <= min} Impossible to display range: {`max (${max}) <= min (${min})`} + import { untrack } from 'svelte' import { GitSyncService } from '$lib/gen' import Select from './select/Select.svelte' @@ -32,7 +33,7 @@ }: Props = $props() // Track all loaded repositories across pages - let loadedRepositories = $state(initialRepositories) + let loadedRepositories = $state(untrack(() => initialRepositories)) let currentPage = $state(1) let isLoadingMore = $state(false) diff --git a/frontend/src/lib/components/Required.svelte b/frontend/src/lib/components/Required.svelte index 2163efa976..3ee1deec1a 100644 --- a/frontend/src/lib/components/Required.svelte +++ b/frontend/src/lib/components/Required.svelte @@ -1,12 +1,19 @@ {#if required} - * + * {:else if detail || detail != ''} - ({detail != '' ? `${detail}` : ''}) {/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 0ffed949d1..70aaf6a2ee 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -30,7 +30,6 @@ canSave?: boolean resource_type?: string | undefined path?: string - newResource?: boolean hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined @@ -40,7 +39,6 @@ canSave = $bindable(true), resource_type = $bindable(undefined), path = $bindable(''), - newResource = false, hidePath = false, onChange, defaultValues = undefined @@ -63,6 +61,7 @@ let resourceTypeInfo: ResourceType | undefined = $state(undefined) let editDescription = $state(false) let viewJsonSchema = $state(false) + let newResource = $derived(!path) const dispatch = createEventDispatcher() @@ -82,7 +81,7 @@ .map(([k, _]) => k) } - if (!newResource) { + if (!untrack(() => newResource)) { initEdit() } else if (resource_type) { loadResourceType() diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 50db916f13..dae6943868 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -5,50 +5,44 @@ import { Loader2, Save } from 'lucide-svelte' - let drawer: Drawer - let canSave = true - let resource_type: string | undefined = undefined - let defaultValues: Record | undefined = undefined + let drawer: Drawer | undefined = $state() + let canSave = $state(true) + let resource_type: string | undefined = $state(undefined) + let defaultValues: Record | undefined = $state(undefined) let resourceEditor: { editResource: () => void; createResource: () => void } | undefined = - undefined + $state(undefined) - let path: string | undefined = undefined + let path: string | undefined = $state(undefined) - let newResource = false export async function initEdit(p: string): Promise { resource_type = undefined - newResource = false path = p - drawer.openDrawer?.() + drawer?.openDrawer?.() } export async function initNew( resourceType: string, nDefaultValues?: Record ): Promise { - newResource = true path = undefined resource_type = resourceType defaultValues = nDefaultValues - drawer.openDrawer?.() + drawer?.openDrawer?.() } - let mode: 'edit' | 'new' = newResource ? 'new' : 'edit' - - $: path ? (mode = 'edit') : (mode = 'new') + let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') {#await import('./ResourceEditor.svelte')} {:then Module} diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 6806e60d3c..fddf9205f5 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -152,7 +152,7 @@ loading = false } - let previousResourceType = resourceType + let previousResourceType = untrack(() => resourceType) $effect(() => { $workspaceStore && resourceType diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 969576a06b..da0c87a15f 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -15,7 +15,7 @@ import Popover from './meltComponents/Popover.svelte' import { Calendar, Check, CornerDownLeft } from 'lucide-svelte' import RunFormAdvancedPopup from './RunFormAdvancedPopup.svelte' - import { page } from '$app/stores' + import { page } from '$app/state' import { replaceState } from '$app/navigation' import JsonInputs from '$lib/components/JsonInputs.svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' @@ -108,7 +108,7 @@ nurl.hash = computeSharableHash(args) try { - replaceState(nurl.toString(), $page.state) + replaceState(nurl.toString(), page.state) } catch (e) { console.error(e) } diff --git a/frontend/src/lib/components/RunFormAdvancedPopup.svelte b/frontend/src/lib/components/RunFormAdvancedPopup.svelte index 7d6ef01bb2..399e55f772 100644 --- a/frontend/src/lib/components/RunFormAdvancedPopup.svelte +++ b/frontend/src/lib/components/RunFormAdvancedPopup.svelte @@ -8,7 +8,9 @@ import { WorkerService } from '$lib/gen' import DateTimeInput from './DateTimeInput.svelte' - export let runnable: + + interface Props { + runnable: | { summary?: string description?: string @@ -21,11 +23,18 @@ created_by?: string extra_perms?: Record } - | undefined + | undefined; + scheduledForStr: string | undefined; + invisible_to_owner: boolean | undefined; + overrideTag: string | undefined; + } - export let scheduledForStr: string | undefined - export let invisible_to_owner: boolean | undefined - export let overrideTag: string | undefined + let { + runnable, + scheduledForStr = $bindable(), + invisible_to_owner = $bindable(), + overrideTag = $bindable() + }: Props = $props(); loadWorkerGroups() async function loadWorkerGroups() { diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index d03495c052..5aee359a4a 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -96,8 +96,8 @@ let batchRerunOptionsIsOpen = $state(false) // Initialize path filter from route param if provided and not already set via query params - if (initialPath && !filters.val.path) { - filters.val.path = initialPath + if (untrack(() => initialPath) && !filters.val.path) { + filters.val.path = untrack(() => initialPath) } // Apply persistent toggle values from local storage if URL doesn't specify them diff --git a/frontend/src/lib/components/S3ObjectPicker.svelte b/frontend/src/lib/components/S3ObjectPicker.svelte index c70983ee83..1c461708cd 100644 --- a/frontend/src/lib/components/S3ObjectPicker.svelte +++ b/frontend/src/lib/components/S3ObjectPicker.svelte @@ -11,15 +11,19 @@ import S3FilePicker from './S3FilePicker.svelte' import FileUpload from './common/fileUpload/FileUpload.svelte' - export let value: any - export let editor: SimpleEditor | undefined = undefined + interface Props { + value: any + editor?: SimpleEditor | undefined + } + + let { value = $bindable(), editor = $bindable(undefined) }: Props = $props() const dispatch = createEventDispatcher() - let s3FilePicker: S3FilePicker - let s3FileUploadRawMode: false + let s3FilePicker: S3FilePicker | undefined = $state() + let s3FileUploadRawMode: boolean | undefined = $state() let el: HTMLTextAreaElement | undefined = undefined - let rawValue: string | undefined = undefined + let rawValue: string | undefined = $state(undefined) function evalValueToRaw() { rawValue = JSON.stringify(value, null, 2) diff --git a/frontend/src/lib/components/SaveInputsButton.svelte b/frontend/src/lib/components/SaveInputsButton.svelte index 414e5ff205..4bddde54ca 100644 --- a/frontend/src/lib/components/SaveInputsButton.svelte +++ b/frontend/src/lib/components/SaveInputsButton.svelte @@ -10,14 +10,25 @@ const dispatch = createEventDispatcher() - export let runnableId: string | undefined - export let runnableType: RunnableType | undefined - export let args: object - export let disabled: boolean = false - export let small: boolean | undefined = undefined - export let showTooltip: boolean | undefined = undefined + interface Props { + runnableId: string | undefined; + runnableType: RunnableType | undefined; + args: object; + disabled?: boolean; + small?: boolean | undefined; + showTooltip?: boolean | undefined; + } - let savingInputs = false + let { + runnableId, + runnableType, + args, + disabled = false, + small = undefined, + showTooltip = undefined + }: Props = $props(); + + let savingInputs = $state(false) async function saveInput(args: object) { savingInputs = true diff --git a/frontend/src/lib/components/SavedInputsPickerViewer.svelte b/frontend/src/lib/components/SavedInputsPickerViewer.svelte index 150e2b9b97..f033bbf4d9 100644 --- a/frontend/src/lib/components/SavedInputsPickerViewer.svelte +++ b/frontend/src/lib/components/SavedInputsPickerViewer.svelte @@ -1,20 +1,34 @@ - + {#snippet trigger({ isOpen })} - - + {/snippet} + {#snippet content()} { + onclick={() => { copyToClipboard(JSON.stringify(payloadData)) }} - on:keydown + onkeydown={bubble('keydown')} > {#if !objectViewerLoaded && payloadTooBigForPreview} @@ -158,5 +174,5 @@ {/if} - + {/snippet} diff --git a/frontend/src/lib/components/SchemaEditorProperty.svelte b/frontend/src/lib/components/SchemaEditorProperty.svelte index 611e178981..fa12138c0d 100644 --- a/frontend/src/lib/components/SchemaEditorProperty.svelte +++ b/frontend/src/lib/components/SchemaEditorProperty.svelte @@ -2,7 +2,11 @@ import type { SchemaProperty } from '$lib/common' import Badge from './common/badge/Badge.svelte' - export let property: SchemaProperty + interface Props { + property: SchemaProperty; + } + + let { property }: Props = $props(); diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 3de7ad3895..e4e166791b 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -157,7 +157,7 @@ let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning let open: boolean = $state(false) // Is confirmation modal open - let args: Record = $state(initialArgs) // Test args input + let args: Record = $state(untrack(() => initialArgs)) // Test args input let selectedInputTab: 'main' | 'preprocessor' = $state('main') let hasPreprocessor = $state(false) let preserveOnBehalfOf = $state(false) @@ -170,12 +170,12 @@ let customOnBehalfOfEmail: string = $state('') let metadataOpen = $state( - !neverShowMeta && - (showMeta || - searchParams.get('metadata_open') == 'true' || + !untrack(() => neverShowMeta) && + (untrack(() => showMeta) || + untrack(() => searchParams).get('metadata_open') == 'true' || (initialPath == '' && - searchParams.get('state') == undefined && - searchParams.get('collab') == undefined)) + untrack(() => searchParams).get('state') == undefined && + untrack(() => searchParams).get('collab') == undefined)) ) let editor: Editor | undefined = $state(undefined) @@ -193,10 +193,15 @@ confirmDeploymentCallback(selectedTriggers) } - const primaryScheduleStore = writable(savedPrimarySchedule) // keep for legacy + const primaryScheduleStore = writable( + untrack(() => savedPrimarySchedule) + ) // keep for legacy const triggersCount = writable( - savedPrimarySchedule - ? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } } + untrack(() => savedPrimarySchedule) + ? { + schedule_count: 1, + primary_schedule: { schedule: untrack(() => savedPrimarySchedule)!.cron } + } : undefined ) const simplifiedPoll = writable(false) @@ -859,7 +864,7 @@ })() ) - setContext('disableTooltips', customUi?.disableTooltips === true) + setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true) function langToLanguage(lang: SupportedLanguage | 'docker' | 'bunnative'): SupportedLanguage { if (lang == 'docker') { @@ -1672,8 +1677,10 @@ /> {:else if script.on_behalf_of_email && !canPreserve} - Currently: {originalOnBehalfOfEmail ?? script.on_behalf_of_email}. - Will be set to {$userStore?.email} on deploy (requires admin or wm_deployers group to override) + Currently: {originalOnBehalfOfEmail ?? script.on_behalf_of_email}. Will be set to {$userStore?.email} on + deploy (requires admin or wm_deployers group to override) {/if} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 0f1e1d8a6e..f310010c18 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -123,7 +123,7 @@ lastDeployedCode?: string | undefined disableAi?: boolean assets?: AssetWithAltAccessType[] - editor_bar_right?: import('svelte').Snippet + editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean } @@ -155,7 +155,7 @@ lastDeployedCode = undefined, disableAi = false, assets = $bindable(), - editor_bar_right, + editorBarRight, enablePreprocessorSnippet = false }: Props = $props() @@ -883,7 +883,7 @@ } } - setContext('disableTooltips', customUi?.disableTooltips === true) + setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true) let codePanelSize = $state(70) let testPanelSize = $state(30) @@ -1042,7 +1042,7 @@ bind:showHistoryDrawer > {#snippet right()} - {@render editor_bar_right?.()} + {@render editorBarRight?.()} {/snippet} {/if} diff --git a/frontend/src/lib/components/ScriptPicker.svelte b/frontend/src/lib/components/ScriptPicker.svelte index 5fa0618edd..803684f49e 100644 --- a/frontend/src/lib/components/ScriptPicker.svelte +++ b/frontend/src/lib/components/ScriptPicker.svelte @@ -52,7 +52,7 @@ let lang: SupportedLanguage | undefined = $state() let options: [[string, any, any, string | undefined]] = [['Script', 'script', Code2, undefined]] - allowFlow && options.push(['Flow', 'flow', FlowIcon, '#14b8a6']) + untrack(() => allowFlow) && options.push(['Flow', 'flow', FlowIcon, '#14b8a6']) const dispatch = createEventDispatcher() async function loadItems(): Promise { diff --git a/frontend/src/lib/components/ScriptWrapper.svelte b/frontend/src/lib/components/ScriptWrapper.svelte index 5e53dc09f3..eec781ce77 100644 --- a/frontend/src/lib/components/ScriptWrapper.svelte +++ b/frontend/src/lib/components/ScriptWrapper.svelte @@ -1,11 +1,12 @@ diff --git a/frontend/src/lib/components/Scrollable.svelte b/frontend/src/lib/components/Scrollable.svelte index 37e3ae2933..7c34b3cbe1 100644 --- a/frontend/src/lib/components/Scrollable.svelte +++ b/frontend/src/lib/components/Scrollable.svelte @@ -2,14 +2,19 @@ import { onMount, onDestroy } from 'svelte' import { twMerge } from 'tailwind-merge' - let isAtBottom: boolean = false - let isScrollable = false + let isAtBottom: boolean = $state(false) + let isScrollable = $state(false) - export let id: string | null | undefined = undefined - export let scrollableClass: string = '' - export let shiftedShadow: boolean = false + interface Props { + id?: string | null | undefined + scrollableClass?: string + shiftedShadow?: boolean + children?: import('svelte').Snippet + } + + let { id = undefined, scrollableClass = '', shiftedShadow = false, children }: Props = $props() let mutationObserver: MutationObserver - let el: HTMLDivElement + let el: HTMLDivElement | undefined = $state() function handleScroll(event) { const scrollableElement = event.target @@ -20,7 +25,8 @@ } function checkIfScrollable(el) { - return el.scrollHeight > el.clientHeight + if (!el) return false + return el?.scrollHeight > el?.clientHeight } function observeScrollability(el) { @@ -33,7 +39,7 @@ } export function scrollIntoView(top: number) { - el.scrollTo({ top, behavior: 'smooth' }) + el?.scrollTo({ top, behavior: 'smooth' }) } onMount(() => { observeScrollability(el) @@ -45,8 +51,8 @@ - - + + {@render children?.()} {#if !isAtBottom && isScrollable} opts)) function filterItems() { let trimmed = filter.trim() diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte index 0f4f923cd6..abcffa32ef 100644 --- a/frontend/src/lib/components/ServiceLogsInner.svelte +++ b/frontend/src/lib/components/ServiceLogsInner.svelte @@ -22,7 +22,7 @@ import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte' import Select from './select/Select.svelte' import { goto } from '$lib/navigation' - import { page } from '$app/stores' + import { page } from '$app/state' import { watch } from 'runed' interface Props { @@ -169,13 +169,13 @@ type Selected = { mode: string; workerGroup: string; hostname: string } let initialSelected = - $page.url.searchParams.get('mode') && - $page.url.searchParams.get('workerGroup') && - $page.url.searchParams.get('hostname') + page.url.searchParams.get('mode') && + page.url.searchParams.get('workerGroup') && + page.url.searchParams.get('hostname') ? { - mode: $page.url.searchParams.get('mode')!, - workerGroup: $page.url.searchParams.get('workerGroup')!, - hostname: $page.url.searchParams.get('hostname')! + mode: page.url.searchParams.get('mode')!, + workerGroup: page.url.searchParams.get('workerGroup')!, + hostname: page.url.searchParams.get('hostname')! } : undefined let selected: Selected | undefined = $state(initialSelected) @@ -663,7 +663,7 @@ { + onClick={() => { let logLineNumber = document.line_number[0] let logFile = document.file_name[0] let host = document.host[0] diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index c8c69106ed..10cf5f09e0 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -159,12 +159,14 @@ {/if} {#if acls?.length > 0} - - -
No permission changes recorded yet
{#if typeof label !== 'string'} diff --git a/frontend/src/lib/components/Range.svelte b/frontend/src/lib/components/Range.svelte index 0e92ac72de..72129aada2 100644 --- a/frontend/src/lib/components/Range.svelte +++ b/frontend/src/lib/components/Range.svelte @@ -1,29 +1,47 @@