From 4b06881918b76c5a411cc70b318e46efcc1393a7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 30 May 2026 11:33:27 +0200 Subject: [PATCH 01/37] fix(ai): validate token_url for SSRF in OAuth credentials flow (#9385) get_token_using_oauth resolved the AI OAuth resource's token_url and POSTed to it without any SSRF validation, while base_url is validated in get_base_url. A workspace member with resources:write could point token_url at an internal/metadata address (e.g. 169.254.169.254), turning the server into an authenticated blind SSRF probe. Validate the resolved token_url with validate_url_for_ssrf before the request, gated behind the same ALLOW_PRIVATE_AI_BASE_URLS opt-in as base_url so private AI deployments keep working consistently for both URL fields. ALLOW_PRIVATE_AI_BASE_URLS is now pub so windmill-api can reuse it instead of re-parsing the env var. --- backend/windmill-ai/src/ai_providers.rs | 2 +- backend/windmill-api/src/ai.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index fb29454ca5..52d04910de 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -20,7 +20,7 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 3196c9df0c..0e096eb798 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -305,6 +305,22 @@ async fn get_token_using_oauth( resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + // Validate the resolved token_url against SSRF rules before issuing the request, + // mirroring the protection applied to base_url in `get_base_url` (same + // ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could + // point token_url at an internal/metadata address. + if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; + windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url) + .await + .map_err(|e| match e { + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( + "{e}. If you need to use private/internal AI endpoints, \ + set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" + )), + e => Error::from(e), + })?; + } let mut params = HashMap::new(); params.insert("grant_type", "client_credentials"); params.insert("scope", "https://cognitiveservices.azure.com/.default"); From b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Sat, 30 May 2026 12:02:11 +0200 Subject: [PATCH 02/37] fix(cli): preserve user drafts on sync push and permissioned-as (#9381) CLI deploys (sync push, set-permissioned-as) went through the same create/update endpoints as a UI "deploy from draft", which delete the draft at that path. That silently wiped teammates' in-progress drafts on every push. Add a transient skip_draft_deletion deploy flag (mirroring deployment_message) that the CLI sets; the backend then skips the DELETE FROM draft for scripts, flows, and apps. UI deploys are unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- backend/tests/dependency_map.rs | 2 + backend/windmill-api-flows/src/flows.rs | 36 +++++++++------- backend/windmill-api-scripts/src/scripts.rs | 24 +++++++---- backend/windmill-api/openapi.yaml | 21 ++++++++++ backend/windmill-api/src/apps.rs | 46 ++++++++++++++------- backend/windmill-common/src/scripts.rs | 1 + backend/windmill-types/src/flows.rs | 5 +++ backend/windmill-types/src/scripts.rs | 10 +++++ cli/src/commands/app/app.ts | 6 +++ cli/src/commands/app/raw_apps.ts | 4 ++ cli/src/commands/flow/flow.ts | 6 +++ cli/src/commands/script/script.ts | 5 +++ 12 files changed, 130 insertions(+), 36 deletions(-) diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index d6d49c8d85..48a50ef9b9 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -451,6 +451,7 @@ def main(): preserve_on_behalf_of: None, ws_error_handler_muted: None, labels: None, + skip_draft_deletion: None, }) .send() .await @@ -513,6 +514,7 @@ def main(): custom_path: None, preserve_on_behalf_of: None, labels: None, + skip_draft_deletion: None, }) .send() .await diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 8a42bce88e..1861fde83e 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -558,13 +558,17 @@ async fn create_flow( w_id ).execute(&mut *tx).await?; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -1157,13 +1161,17 @@ async fn update_flow( })?; } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - flow_path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + flow_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 19fbaa2148..0dc86bfda8 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -737,6 +737,9 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // caller-intent flag (preserve user drafts on CLI/git-sync deploys); + // transient, never persisted, does not change what the script *is* + skip_draft_deletion: _, } = ns; if path != &parent.path { @@ -925,6 +928,9 @@ async fn create_script_internal<'c>( } } let script_path = ns.path.clone(); + // Caller-intent: CLI / git-sync deploys ask us to preserve any existing + // user draft at this path instead of wiping it as part of the deploy. + let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false); let hash = ScriptHash(hash_script(&ns)); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; @@ -1357,13 +1363,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - p_path, - &w_id - ) - .execute(&mut *tx) - .await?; + if !skip_draft_deletion { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", @@ -1442,7 +1450,7 @@ async fn create_script_internal<'c>( tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } - } else { + } else if !skip_draft_deletion { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", ns.path, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 04117d640d..182b7e3a56 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9751,6 +9751,9 @@ paths: type: boolean deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "201": description: flow created @@ -9792,6 +9795,9 @@ paths: properties: deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "200": @@ -10290,6 +10296,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10342,6 +10351,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10660,6 +10672,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." responses: "200": description: app updated @@ -10706,6 +10721,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." js: type: string css: @@ -21883,6 +21901,9 @@ components: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." required: - path diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index c377203f38..de55cb87ff 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -306,6 +306,11 @@ pub struct CreateApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, Deserialize)] @@ -319,6 +324,11 @@ pub struct EditApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, FromRow)] @@ -1338,13 +1348,17 @@ async fn create_app_internal<'a>( )); } } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - &app.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !app.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + &app.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } let id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) @@ -1943,13 +1957,17 @@ async fn update_app_internal<'a>( ))); } }; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !ns.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, &authed, diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index dd4c0bb42e..38cd444f29 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -393,6 +393,7 @@ pub async fn clone_script<'c>( modules: s.modules, auto_parent: None, labels: s.labels, + skip_draft_deletion: None, }; let new_hash = hash_script(&ns); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 8b60eb0d44..7af3091898 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -110,6 +110,11 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this flow must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } impl NewFlow { diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index e9e9337e6d..c26947d8d6 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -540,9 +540,19 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this script must NOT delete an existing user draft at the same path. + /// Transient — never persisted. Deliberately excluded from `impl Hash` + /// below (it must not affect the version hash) and from the no-op + /// comparison in the deploy handler (it isn't part of what the script + /// *is*). See `is_noop_deploy_against_parent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript +// (exception: caller-intent flags like `skip_draft_deletion` are intentionally +// omitted — they must not influence the computed version hash) impl Hash for NewScript { fn hash(&self, state: &mut H) { self.path.hash(state); diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index a6f85fde1c..6f0263c5cf 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -192,6 +192,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -205,6 +207,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -480,6 +484,8 @@ const command = new Command() on_behalf_of_email: email, } as any, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`)); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 5b9e880adc..fcfc68915e 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -466,6 +466,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), @@ -486,6 +488,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 4717711ae5..c0e94c478d 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -224,6 +224,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -237,6 +239,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } catch (e) { @@ -1159,6 +1163,8 @@ const command = new Command() path: flowPath, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, } as any, }); log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`)); diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 808456f151..0299744056 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -758,6 +758,9 @@ async function createScript( workspace: Workspace ): Promise { const start = performance.now(); + // Preserve any user draft at this path: a CLI / git-sync deploy must not wipe + // an in-progress draft the way a UI "deploy from draft" intentionally does. + body = { ...body, skip_draft_deletion: true }; // skip_if_noop asks the backend to treat deploys identical to the parent // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. @@ -1796,6 +1799,8 @@ async function setPermissionedAs( parent_hash: remote.hash, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`)); From f300a716a901125af06e7671aa6555a6a6e928b1 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Sat, 30 May 2026 12:02:36 +0200 Subject: [PATCH 03/37] test: add global chat resource variable schedule evals (#9379) --- ai_evals/README.md | 4 +- .../core/shared/providerConfig.test.ts | 10 +- ai_evals/cases/global.yaml | 188 ++++++++++++++++++ ai_evals/core/models.test.ts | 12 +- ai_evals/core/models.ts | 18 -- .../global/initial/report_digest_script.json | 23 +++ 6 files changed, 220 insertions(+), 35 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/report_digest_script.json diff --git a/ai_evals/README.md b/ai_evals/README.md index 464d235a07..d5fe1661a2 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script @@ -89,8 +89,6 @@ Today: - `opus` - `4o` - `gpt-5.5` -- `gemini-flash` -- `gemini-pro` - `gemini-3-flash-preview` - `gemini-3.1-pro-preview` - `deepseek-v4-flash` diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index 819300bd62..01a55e048e 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -21,9 +21,9 @@ describe("proxy helpers", () => { describe("resolveEvalModelProvider", () => { it("infers googleai from Gemini model ids", () => { - expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({ + expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({ provider: "googleai", - model: "gemini-2.5-flash", + model: "gemini-3-flash-preview", }); }); @@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => { }); it("preserves an explicit provider", () => { - expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({ + expect( + resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"), + ).toEqual({ provider: "googleai", - model: "gemini-2.5-pro", + model: "gemini-3.1-pro-preview", }); }); }); diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 36238e8ccd..732ca7f2e9 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -430,3 +430,191 @@ judgeChecklist: - asks which script to update when the user refers to "this script" without selected or active editor context - does not guess a path or create a new script draft + +- id: global-test15-human-postgres-resource + prompt: |- + I'm wiring the eval reporting database into this workspace. + Can you stage a Postgres connection for it in the shared evals/global folder? + Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`. + Keep the credentials safe. + This is just draft work for now. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + - password + valueIncludes: + - "true" + - report + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + valueIncludes: + - postgres + - reports-db.internal + - "5432" + - evals_reporting + - report_reader + - "$var:" + valueExcludes: + - pg-redacted-reporting-password + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - pg-redacted-reporting-password + skipJudge: true + judgeChecklist: + - creates a Postgres resource draft for the eval reporting database + - creates a secret variable draft for the database password + - puts the drafts in sensible eval/global reporting-related paths + - uses the requested host, port, database, and user + - references the secret variable from the resource instead of embedding the password + - leaves the work as a draft only + +- id: global-test16-human-visible-variable + prompt: |- + We keep reusing a 30 day trial cutoff in eval notification jobs. + Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls? + It is not a secret. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - trial + valueIncludes: + - "30" + - "false" + - trial + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates exactly one non-secret variable draft for the trial cutoff + - stores the value 30 + - chooses a sensible eval/global path related to trials or notifications + - includes a useful description of what the value controls + - does not create resources, schedules, triggers, or deployed workspace changes + +- id: global-test17-human-schedule-existing-helper + prompt: |- + The workspace already has a report digest helper. + Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on? + I only want the schedule draft for review. + initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: schedule + pathIncludes: + - digest + valueIncludes: + - f/evals/global/send_report_digest + - UTC + - dry_run + - "true" + toolExpect: + requiredToolsUsed: + - list_workspace_items + - write_schedule + forbiddenToolsUsed: + - write_script + - write_flow + - write_resource + - write_variable + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds the existing report digest helper rather than creating a new script or flow + - creates one schedule draft for that helper + - schedules it for weekdays around 08:30 UTC + - passes dry_run as true + - leaves only the schedule draft for review + +- id: global-test18-human-slack-resource-with-secret + prompt: |- + I'm preparing Slack notifications for eval failures. + Can you stage a Slack connection in the shared evals/global folder? + The bot token is `xoxb-redacted-test-token`; keep it safe. + Don't deploy anything yet. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + - token + valueIncludes: + - "true" + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + valueIncludes: + - slack + - "$var:" + valueExcludes: + - xoxb-redacted-test-token + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a secret variable draft for the Slack bot token placeholder + - creates a Slack resource draft that references the secret variable instead of embedding the token + - keeps both drafts under a sensible eval/global Slack-related path + - does not create schedules, triggers, or deployed workspace changes diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index 986d9cd7cd..ba53c24592 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -18,14 +18,6 @@ describe("resolveEvalModel", () => { }); it("supports Gemini aliases for frontend evals", () => { - expect(resolveEvalModel("flow", "gemini").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-flash", - }); - expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-pro", - }); expect( resolveEvalModel("script", "gemini-3-flash-preview").frontend, ).toEqual({ @@ -52,8 +44,8 @@ describe("resolveEvalModel", () => { }); it("rejects Gemini aliases for cli evals", () => { - expect(() => resolveEvalModel("cli", "gemini")).toThrow( - "Model gemini-flash is not supported for cli mode", + expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow( + "Model gemini-3-flash-preview is not supported for cli mode", ); }); }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 0e18037f82..295cd36135 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -96,24 +96,6 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-5.5", }, }, - { - id: "gemini-flash", - label: "Gemini 2.5 Flash", - aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"], - frontend: { - provider: "googleai", - model: "gemini-2.5-flash", - }, - }, - { - id: "gemini-pro", - label: "Gemini 2.5 Pro", - aliases: ["gemini-pro", "gemini-2.5-pro"], - frontend: { - provider: "googleai", - model: "gemini-2.5-pro", - }, - }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview", diff --git a/ai_evals/fixtures/frontend/global/initial/report_digest_script.json b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json new file mode 100644 index 0000000000..832b06712f --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/send_report_digest", + "summary": "Build and send the eval report digest", + "description": "Returns a dry-run summary for eval report digest notifications.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "required": ["dry_run"] + }, + "content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n" + } + ] + } +} From 2c0c2c467f163cd24c14c7be2db07af9cf2ce020 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Sat, 30 May 2026 12:05:19 +0200 Subject: [PATCH 04/37] fix(apps): make public apps opt into cross-origin isolation via wm_coep (GIT-884) (#9374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(apps): make public apps opt into cross-origin isolation via wm_coep Public app pages served at /public/* and custom paths /a/* were not getting the COEP/COOP/CORP headers, so they were blocked when embedded as an iframe inside a cross-origin-isolated page (e.g. another raw app, which sets Cross-Origin-Embedder-Policy: require-corp). A nested document loaded into a require-corp context must itself set COEP for the iframe to load. Rather than applying the isolation headers to all public pages (which would also force COEP on classic apps and break subresources without CORP, e.g. external image URLs or embeds), public apps now opt in via a `wm_coep` query param on the embed URL: ` + } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ workspace: $workspaceStore!, @@ -253,12 +270,34 @@ {#if appPath == ''} {:else if secretUrlHref} - +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ {:else} {/if}
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside + another Windmill app or any cross-origin-isolated page. Without it the browser blocks + the iframe. lets it load inside a cross-origin-isolated page. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this url directly, or switch to Embed to get an iframe snippet. + {/if}
@@ -305,7 +344,10 @@
Custom public URL
- +
{dirtyCustomPath ? customPathError : ''} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 48c3d5578d..fef6ba8fa3 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -145,7 +145,7 @@ name: $userStore?.name, groups: $userStore?.groups, username: $userStore?.username, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={effectiveWorkspace} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 495a4ebcc1..35878e789b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -837,6 +837,7 @@ {appPath} {onLatest} {savedApp} + rawApp bind:summary bind:customPath bind:deploymentMsg diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8fb6475806..2e6302fb6c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1186,9 +1186,22 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -export function urlParamsToObject(params: URLSearchParams): Record { +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers. +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { const result: Record = {} params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } result[key] = value }) return result diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index bd900b38f0..737ebb2e33 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -56,7 +56,7 @@ name: $userStore?.name, username: $userStore?.username, groups: $userStore?.groups, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={$workspaceStore ?? ''} From fddbe4a51c06707b067bfc0dfc27bfb861b5642d Mon Sep 17 00:00:00 2001 From: Stefan Stefanov <35034980+s-stefanov@users.noreply.github.com> Date: Sat, 30 May 2026 13:21:56 +0300 Subject: [PATCH 05/37] docs(skills): fix //native marker + import rules for bunnative, remove legacy nativets skill (#9382) * docs(skills): document mandatory //native marker for bunnative and nativets * docs(skills): clarify windmill-client is the only allowed library in native mode * docs(skills): remove legacy nativets skill in favor of bunnative Co-Authored-By: Claude Opus 4.8 (1M context) * docs(skills): fix bunnative import rule - any bundleable lib, not just windmill-client Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 4.8 (1M context) --- cli/src/guidance/skills.gen.ts | 693 +----------------- system_prompts/auto-generated/prompts.ts | 91 +-- system_prompts/auto-generated/script.md | 91 +-- .../skills/write-script-bunnative/SKILL.md | 14 +- .../skills/write-script-nativets/SKILL.md | 675 ----------------- system_prompts/languages/bunnative.md | 12 +- system_prompts/languages/nativets.md | 77 -- system_prompts/utils.py | 16 +- 8 files changed, 57 insertions(+), 1612 deletions(-) delete mode 100644 system_prompts/auto-generated/skills/write-script-nativets/SKILL.md delete mode 100644 system_prompts/languages/nativets.md diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 714fdec222..5754c22487 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -10,7 +10,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, { name: "write-script-bigquery", description: "MUST use when writing BigQuery queries.", languageKey: "bigquery" }, { name: "write-script-bun", description: "MUST use when writing Bun/TypeScript scripts.", languageKey: "bun" }, - { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, + { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.", languageKey: "bunnative" }, { name: "write-script-csharp", description: "MUST use when writing C# scripts.", languageKey: "csharp" }, { name: "write-script-deno", description: "MUST use when writing Deno/TypeScript scripts.", languageKey: "deno" }, { name: "write-script-duckdb", description: "MUST use when writing DuckDB queries.", languageKey: "duckdb" }, @@ -19,7 +19,6 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, - { name: "write-script-nativets", description: "MUST use when writing Native TypeScript scripts.", languageKey: "nativets" }, { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, @@ -926,7 +925,7 @@ ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-bunnative": `--- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -966,13 +965,14 @@ Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -988,6 +988,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -999,9 +1000,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -1010,13 +1012,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -1049,6 +1052,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -1060,6 +1064,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -2976,682 +2981,6 @@ All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storag omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or \`csv\`). Use this for large result sets — rows stream directly to S3 instead of being buffered as the script return value. -`, - "write-script-nativets": `--- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. -- \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. -- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use \`script preview\`. Do NOT push the script to then \`script run\` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use \`script run\` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use \`sync push\` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. - -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - * - * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * \`\`\`typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * \`\`\` - * - * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) - * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-php": `--- name: write-script-php diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9ce0d56e01..c76b47d5a0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3524,13 +3524,14 @@ const result: wmill.S3Object = await wmill.writeS3File( export const LANG_BUNNATIVE = `# TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -3546,6 +3547,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -3557,9 +3559,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -3568,13 +3571,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -3607,6 +3611,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -3618,6 +3623,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -4108,85 +4114,6 @@ omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, being buffered as the script return value. `; -export const LANG_NATIVETS = `# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` -`; - export const LANG_PHP = `# PHP ## Structure diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index b7b97326a2..7515b2bec4 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -239,13 +239,14 @@ const result: wmill.S3Object = await wmill.writeS3File( # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -261,6 +262,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -272,9 +274,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -283,13 +286,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -322,6 +326,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -333,6 +338,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -823,85 +829,6 @@ omit to use the workspace default), `format` (`json` (default), `parquet`, or being buffered as the script return value. -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` - - # PHP ## Structure diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 89b1bc6f05..3b790959f2 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -1,6 +1,6 @@ --- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -40,13 +40,14 @@ Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -62,6 +63,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -73,9 +75,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -84,13 +87,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -123,6 +127,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -134,6 +139,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md deleted file mode 100644 index 42ad9448a8..0000000000 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ /dev/null @@ -1,675 +0,0 @@ ---- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- `wmill script preview ` — **default when iterating on a local script.** Runs the local file without deploying. -- `wmill script run ` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- `wmill generate-metadata` — generate `.script.yaml` and `.lock` files for the script you modified. -- `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use `script preview`. Do NOT push the script to then `script run` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use `script run` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use `sync push` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run `wmill script preview` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute `wmill script preview -d ''` directly — pick plausible args from the script's declared parameters. The shape varies by language: `main(...)` for code languages, the SQL dialect's own placeholder syntax (`$1` for PostgreSQL, `?` for MySQL/Snowflake, `@P1` for MSSQL, `@name` for BigQuery, etc.), positional `$1`, `$2`, … for Bash, `param(...)` for PowerShell. - -`wmill script preview` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). `wmill sync push` and `wmill generate-metadata` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than `script preview`'s run-and-print-result), use the `preview` skill. - -Use `wmill resource-type list --schema` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format `$res:path` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to `getStatePath()`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to `getStatePath()`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * ``` - * - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * ``` - * - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * ``` - * - * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * ```typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * ``` - * - * @param s3object - S3 object identifying the file to delete (must have `s3` set) - * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails. - * - * **Usage Example:** - * ```typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * ``` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails. - * - * **Usage Example:** - * ```typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * ``` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use `step()` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling `fn(item)`, which should be a task(). - * Items are dispatched in batches of `concurrency` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql` - * SELECT * FROM friends - * WHERE name = ${name} AND age = ${age}::int - * `.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql` - * SELECT * FROM friends - * WHERE name = ${name} AND age = ${age} - * `.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index 977c974737..9daa8a9a46 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -1,12 +1,13 @@ # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -22,6 +23,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -33,9 +35,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -44,13 +47,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -83,6 +87,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -94,6 +99,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 diff --git a/system_prompts/languages/nativets.md b/system_prompts/languages/nativets.md deleted file mode 100644 index 5df6ad2279..0000000000 --- a/system_prompts/languages/nativets.md +++ /dev/null @@ -1,77 +0,0 @@ -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` diff --git a/system_prompts/utils.py b/system_prompts/utils.py index 152df2e1d9..45b7a365ba 100644 --- a/system_prompts/utils.py +++ b/system_prompts/utils.py @@ -94,14 +94,14 @@ LANGUAGE_METADATA = { 'description': 'MUST use when writing Deno/TypeScript scripts.', 'use_cases': 'TypeScript with Deno stdlib, secure sandboxed execution' }, - 'nativets': { - 'name': 'Native TypeScript', - 'description': 'MUST use when writing Native TypeScript scripts.', - 'use_cases': 'simple API calls, lightweight TypeScript, no dependencies' - }, + # 'nativets' is intentionally omitted: it is a legacy duplicate of + # 'bunnative' (a Bun script with a leading //native marker). No + # write-script-nativets skill is generated so agents always author native + # TypeScript as 'bunnative'. It remains in TS_SDK_LANGUAGES below so the + # TypeScript SDK still attaches when editing existing nativets scripts. 'bunnative': { 'name': 'Bun Native', - 'description': 'MUST use when writing Bun Native scripts.', + 'description': 'MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.', 'use_cases': 'simple Bun scripts, lightweight, no dependencies' }, 'python3': { @@ -186,7 +186,9 @@ LANGUAGE_METADATA = { }, } -# Languages that use TypeScript SDK +# Languages that use TypeScript SDK. 'nativets' is kept here (despite having no +# write-script skill — see LANGUAGE_METADATA) so the TS SDK still attaches when +# editing pre-existing legacy nativets scripts. TS_SDK_LANGUAGES = ['bun', 'deno', 'nativets', 'bunnative'] # Languages that use Python SDK From def01b8ff6f331cc36ce02b947adc31c766042c4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 30 May 2026 12:40:56 +0200 Subject: [PATCH 06/37] fix(frontend): sanitize user markdown to prevent stored XSS (#9386) Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/package-lock.json | 30 +++++++++++++++++++ frontend/package.json | 3 +- .../src/lib/components/GfmMarkdown.svelte | 11 ++----- .../components/display/AppMarkdown.svelte | 11 ++----- .../src/lib/components/markdownPlugins.ts | 30 +++++++++++++++++++ 5 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/components/markdownPlugins.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c59d3e5b35..9e6e7374e5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -66,6 +66,7 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", @@ -6352,6 +6353,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", @@ -10888,6 +10904,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4f0c9ada75..2babd6f064 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -126,7 +126,6 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "unist-util-visit": "^5.0.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -141,12 +140,14 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", "tailwind-merge": "^1.13.2", + "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 83cce90fbe..377951b97b 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,19 +1,12 @@
diff --git a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte index e8ae0f8335..ff9c4263a8 100644 --- a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte @@ -5,10 +5,8 @@ import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types' import { initCss } from '../../utils' import RunnableWrapper from '../helpers/RunnableWrapper.svelte' - import { Markdown, type Plugin } from 'svelte-exmarkdown' - import { gfmPlugin } from 'svelte-exmarkdown/gfm' - import rehypeRaw from 'rehype-raw' - import { rehypeGithubAlerts } from 'rehype-github-alerts' + import { Markdown } from 'svelte-exmarkdown' + import { markdownPlugins as plugins } from '$lib/components/markdownPlugins' import { classNames } from '$lib/utils' import { components } from '../../editor/component' import ResolveConfig from '../helpers/ResolveConfig.svelte' @@ -31,11 +29,6 @@ configuration }: Props = $props() - const plugins: Plugin[] = [ - gfmPlugin(), - { rehypePlugin: [rehypeRaw] }, - { rehypePlugin: [rehypeGithubAlerts] } - ] const { app, worldStore, mode } = getContext('AppViewerContext') const resolvedConfig = $state( diff --git a/frontend/src/lib/components/markdownPlugins.ts b/frontend/src/lib/components/markdownPlugins.ts new file mode 100644 index 0000000000..4495619868 --- /dev/null +++ b/frontend/src/lib/components/markdownPlugins.ts @@ -0,0 +1,30 @@ +import type { Plugin } from 'svelte-exmarkdown' +import { gfmPlugin } from 'svelte-exmarkdown/gfm' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import { rehypeGithubAlerts } from 'rehype-github-alerts' + +/** + * Shared plugin chain for rendering user-supplied Markdown (script/flow/resource + * descriptions, flow-graph notes, the App "Markdown" component, ...). + * + * Order matters and is security-sensitive: + * 1. `gfmPlugin` — GitHub-flavored Markdown. + * 2. `rehypeRaw` — re-parses embedded raw HTML into live hast nodes. + * 3. `rehypeSanitize` — strips dangerous nodes (` -
+
@@ -30,6 +45,7 @@ defaultLang="yaml" defaultOriginal={beforeYaml} defaultModified={afterYaml} + {inlineDiff} readOnly /> {/await} @@ -37,7 +53,7 @@ {#await import('$lib/components/FlowGraphDiffViewer.svelte')} {:then Module} - + {/await} {/if}
diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index 3639f0955b..caab746502 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -2,12 +2,12 @@ import type { OpenFlow } from '$lib/gen' import YAML from 'yaml' import FlowGraphV2 from './graph/FlowGraphV2.svelte' - import { Alert, Button } from './common' + import { Alert } from './common' import { computeFlowModuleDiff } from './flows/flowDiff' import { Pane, Splitpanes } from 'svelte-splitpanes' + import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import type { Viewport } from '@xyflow/svelte' const SIDE_BY_SIDE_MIN_WIDTH = 700 @@ -15,13 +15,54 @@ interface Props { beforeYaml: string afterYaml: string + /** When true, render an empty surface placeholder for the "before" + * pane in side-by-side mode (use for added items where there's no + * prior flow to show). */ + beforeMissing?: boolean + /** Same as `beforeMissing` but for the "after" pane (use for removed + * items). */ + afterMissing?: boolean + /** Render the unified single-pane diff when true, side-by-side + * otherwise. When undefined, the component renders its own + * Unified / Side-by-side toggle in the corner (legacy behavior for + * the standalone comparison page). A narrow viewer still falls back + * to unified automatically. */ + inlineDiff?: boolean | undefined } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + beforeMissing = false, + afterMissing = false, + inlineDiff = undefined + }: Props = $props() + + // Local toggle state, used only when no inlineDiff prop is supplied. + let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside') + const showLocalToggle = $derived(inlineDiff === undefined) + const effectiveInlineDiff = $derived( + inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified' + ) let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH) let beforePaneSize = $state(50) - let viewMode = $state<'sidebyside' | 'unified'>('sidebyside') + // Track the content area's rendered height so unified-mode graphs can + // grow to fill the diff box (otherwise FlowGraphV2 sits at its + // content-fit height + small floor, leaving empty space below). + let contentAreaHeight = $state(0) + + // Each FlowGraphV2 sizes itself to its own content (clamped to minHeight). + // In side-by-side mode we want both graphs to share the same height, so + // we track each side's reported height and feed back the max as minHeight + // to both. The width-graph then stays at its computed size; the shorter + // graph grows to match. + let beforeContentHeight = $state(0) + let afterContentHeight = $state(0) + const SHARED_MIN_HEIGHT = 400 + const sharedMinHeight = $derived( + Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight) + ) // Shared viewport for synchronizing both graphs in side-by-side mode let sharedViewport = $state({ x: 0, y: 0, zoom: 1 }) @@ -29,7 +70,10 @@ let beforeGraph: FlowGraphV2 | undefined = $state(undefined) let afterGraph: FlowGraphV2 | undefined = $state(undefined) - function parseFlow(yaml: string, label: 'before' | 'after'): { + function parseFlow( + yaml: string, + label: 'before' | 'after' + ): { flow: OpenFlow | undefined error: string | undefined } { @@ -49,14 +93,27 @@ } } - let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before')) - let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after')) + // For added/removed items, the caller passes empty YAML and sets the + // corresponding *Missing flag. We swap in an empty OpenFlow stub on + // that side so the unified diff path still has something to compare + // against (every module on the present side becomes added / removed). + // The side-by-side rendering uses the flag directly to draw a + // placeholder pane instead. + const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } } + + let beforeParsed = $derived.by(() => + beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before') + ) + let afterParsed = $derived.by(() => + afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after') + ) let parseError = $derived(beforeParsed.error ?? afterParsed.error) let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow) let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow) - // Determine if we should render side-by-side or unified (user controlled via toggle) - let isSideBySide = $derived(viewMode === 'sidebyside') + // Side-by-side unless the caller asked for unified, OR the viewer pane + // is too narrow to comfortably split (fallback to unified for legibility). + const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH) // Build timeline using history-based approach // In side-by-side view, mark removed modules as 'shadowed' in the After graph @@ -72,14 +129,6 @@ sharedViewport = viewport } } - - $effect(() => { - if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) { - viewMode = 'unified' - } else { - viewMode = 'sidebyside' - } - }) {#if parseError} @@ -88,10 +137,12 @@ {:else if beforeFlow && afterFlow}
- -
-
- + {#if showLocalToggle} + +
+ {#snippet children({ item })}
- + {/if} + +
{#if isSideBySide} - -
- +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 710ce9bd5d..9dc36e3503 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -81,7 +81,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import LabelsInput from './LabelsInput.svelte' @@ -134,7 +134,9 @@ onSaveDraftError, onSaveDraft, onNavigate, - disableAi + disableAi, + initialTestPanelCollapsed = false, + initialPathChosen = false }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -626,17 +628,23 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if ( + // "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the + // editor in place rather than navigating to the deployed item. + const stayHere = stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language)) - ) { + if (stayHere) { + // Re-pin parent_hash so the next deploy's conflict check is against + // the version we just wrote. script.parent_hash = newHash - sendUserToast('Deployed') - } else { - onDeploy?.({ path: script.path, hash: newHash }) } + // Always notify on a successful deploy; the consumer decides whether to + // navigate (route) or stay + sync the preview (session). Previously the + // stay/lib branch skipped onDeploy, so session previews didn't sync after + // a "Deploy & Stay here" or lib-script deploy. + onDeploy?.({ path: script.path, hash: newHash, stay: stayHere }) } catch (error) { onDeployError?.({ path: script.path, error }) sendUserToast(`Error while saving the script: ${error.body || error.message}`, true) @@ -793,6 +801,12 @@ loadingDraft = false } + // Inside an AI session pane (which injects an aiChatManager via context) the + // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace + // fork, Exit & See details, Export — don't make sense: the session always + // stays put and is already scoped to a fork. Only "Show diff" is kept. + const inSessionPane = !!getContext('aiChatManager') + function computeDropdownItems( initialPath: string, savedScript: NewScriptWithDraftAndDraftTriggers | undefined, @@ -801,26 +815,30 @@ let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -852,7 +870,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -862,7 +883,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -875,7 +896,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -901,7 +926,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // Seed "path is already chosen" so the summary→path auto-slug (which only + // runs for new scripts with initialPath == '') doesn't clobber a path the + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -2091,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6b2a7818d..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 02ce70f64f..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -139,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -264,10 +264,10 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref}
diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 07ccd1efb1..c6366113c9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -3,30 +3,61 @@ import { untrack } from 'svelte' import { type ScriptLang } from '$lib/gen' import { dbSchemas, userStore, workspaceStore } from '$lib/stores' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + let { + hideHeader = false, + hideModeSelector = false, + forceDisabled = false, + forceDisabledMessage = '', + wideLayout = false, + emptyHint, + inputPreface + }: { + hideHeader?: boolean + hideModeSelector?: boolean + // External "you can't type here" override. Used by sessions when + // the session's committed workspace was deleted/archived so the + // chat is effectively read-only until the user moves or discards + // the session. Wins over the internal disabled derivation. + forceDisabled?: boolean + forceDisabledMessage?: string + // Forwarded to AIChatDisplay. When true, the messages / input + // columns are centered in a max-w-3xl px-8 box. Sessions opt + // in; the narrow global-chat panel leaves it off. + wideLayout?: boolean + emptyHint?: import('svelte').Snippet + inputPreface?: import('svelte').Snippet + } = $props() + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const hasCopilot = $derived($copilotInfo.enabled) const disabled = $derived( - !hasCopilot || + forceDisabled || + !hasCopilot || (aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) const disabledMessage = $derived( - !hasCopilot - ? isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + forceDisabled + ? forceDisabledMessage + : !hasCopilot + ? isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ @@ -53,6 +84,10 @@ aiChatManager.sendRequest(options) } + export function focusInput() { + aiChatDisplay?.focusInput() + } + const historyManager = aiChatManager.historyManager let aiChatDisplay: AIChatDisplay | undefined = $state(undefined) @@ -129,4 +164,9 @@ {disabled} {disabledMessage} {suggestions} + {hideHeader} + {hideModeSelector} + {wideLayout} + {emptyHint} + {inputPreface} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..5b0e549dd0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +208,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +508,11 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {} } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +810,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +840,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { aiChatManager } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7249f0ae05..8121ca8b70 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -66,6 +66,9 @@ } | undefined diffDrawer?: DiffDrawer | undefined + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + /** Fired after a successful deploy; the session preview reloads on it. */ + onDeploy?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -75,6 +78,14 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Initial value for the "Split with Preview" tab-bar toggle. Defaults + * to `true` (split mode, preview always pinned to the right). Set + * `false` when the editor mounts inside a context that wants single- + * view by default with the Preview tab selected — e.g. session + * previews, where the editor pane is already narrow. The user can + * still toggle the mode after mount; this prop only seeds the + * initial state. */ + defaultSplitWithPreview?: boolean } let { @@ -88,9 +99,12 @@ newPath = undefined, savedApp = $bindable(undefined), diffDrawer = undefined, + onNavigate, + onDeploy = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', - liveEditorDraftStoragePath = undefined + liveEditorDraftStoragePath = undefined, + defaultSplitWithPreview = true }: Props = $props() export const version: number | undefined = undefined @@ -225,7 +239,9 @@ } let tabs: TabItem[] = $state([previewTab]) let activeTabId: string = $state(PREVIEW_TAB_ID) - let splitWithPreview: boolean = $state(true) + // Seed from the prop, then own the state locally so the user's toggle + // after mount sticks even if the prop reference changes. + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -255,11 +271,23 @@ const showRunnable = $derived(activeTabKind === 'runnable') // Mount the UI Builder iframe the first time a file is shown (paneA has // width then; mounting it at 0-width breaks the VS Code workbench), and - // keep it mounted so tab switches don't reload it. + // keep it mounted so tab switches don't reload it. Mount it as soon as + // either pane needs it: `showSource` for the source-editor view, OR the + // preview tab is active — the Preview iframe is fed by `preview` + // postMessages bundled by the UI Builder iframe, so it needs to be + // mounted even when the user opens the editor straight on Preview (e.g. + // session previews seeded with `defaultSplitWithPreview=false`). let iframeShouldMount = $state(false) $effect(() => { - if (showSource) iframeShouldMount = true + if (showSource || activeTabKind === 'preview') iframeShouldMount = true }) + // Width of the editor area (both inner panes). The UI Builder iframe is + // pre-mounted while it's the inactive tab so the editor is ready instantly; + // but the VS Code workbench inside crashes if it boots at 0 size. So while + // inactive we keep the iframe at this real width and hide it with + // `visibility` instead of collapsing it — Monaco boots correctly and + // revealing a file is just an unhide (no reload, no relayout, no latency). + let editorAreaWidth = $state(0) // Inner pane sizes are a pure function of mode + active tab → derived. // `paneARatio` is the user's last manual split drag (set by rememberPaneDrag). @@ -994,7 +1022,11 @@ ensureFileTab(selectedDocument) // Don't auto-activate — the user's tab choice wins. // But if no file tab is currently active, fall in line. - if (activeTabKind === 'preview' && tabs.length === 2) { + // Skip this auto-activation in single-view-with-preview + // mode (the caller seeded `defaultSplitWithPreview=false` + // because Preview is the intended starting tab); the + // iframe's first setActiveDocument shouldn't fight that. + if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) { activateTab(id) } } @@ -1158,9 +1190,14 @@ }) }) - // Open a default file on mount (boots the iframe; avoids a blank preview). - // Layout isn't persisted — each open starts fresh in split mode. + // Open a default file on mount (boots the iframe in split mode and gives + // the user something to edit on the left). When the caller seeded + // `defaultSplitWithPreview=false` we instead want the Preview tab as the + // only-visible / active surface, so skip the file-tab activation — the + // iframe still boots via `populateFiles`/`setFilesInIframe` even without + // a selected document. onMount(() => { + if (!splitWithPreview) return if (tabs.length === 1) { const def = pickDefaultFile(files) if (def) activateTab(ensureFileTab(def)) @@ -1332,6 +1369,8 @@ {data} {runnables} {getBundle} + {onNavigate} + {onDeploy} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1415,6 +1454,7 @@ Preview previously hid every tab. -->
-
+ +
{#if iframeShouldMount}