diff --git a/AGENTS.md b/AGENTS.md index 6a3bcf0463..c2aae7159d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. - **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `` / `` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details. +- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise). - **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). - **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server. diff --git a/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json index 501e149fc4..20689e1f0a 100644 --- a/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json +++ b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json @@ -53,7 +53,7 @@ false, false, false, - false, + true, false, false, true, diff --git a/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json index ee946012b2..5791e1ca31 100644 --- a/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json +++ b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json @@ -76,7 +76,7 @@ false, false, false, - false, + true, false, false, false, diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e5146df23b..3b07694533 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b5294d38db082bdd104d5358a47695390694c05d +b3fd7d49f2ba58f5080fabc3a5686c34a0b2684c diff --git a/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql new file mode 100644 index 0000000000..6e9587579b --- /dev/null +++ b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql @@ -0,0 +1,18 @@ +-- A server without these scopes ignores them, which would widen every token carrying one: +-- a narrowed `mcp:all` to full access, a workspace list to all of the owner's workspaces. +DELETE FROM token WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_refresh_token WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_server_code WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_server_code WHERE workspace_id IS NULL; +DELETE FROM mcp_oauth_refresh_token WHERE workspace_id IS NULL; +ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id SET NOT NULL; +ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id SET NOT NULL; diff --git a/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql new file mode 100644 index 0000000000..efd4bdc283 --- /dev/null +++ b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql @@ -0,0 +1,4 @@ +-- An MCP OAuth grant can span several workspaces (listed in its scopes) or all of the +-- user's, in which case the token it mints is bound to none. +ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id DROP NOT NULL; +ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id DROP NOT NULL; diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index b37a3616f1..f68791179c 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1280,6 +1280,7 @@ pub async fn resolve_opt_job_authed( // could approve an OAuth flow that mints a new non-read-only // token. let is_mcp_transport = path == "/api/mcp/gateway" + || path == "/api/mcp" || (path.starts_with("/api/mcp/w/") && (path.ends_with("/mcp") || path.ends_with("/sse") diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 6e5b43b68b..e6dcfe8155 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1252,7 +1252,7 @@ async fn validate_dbt_relation( /// The schema the editor would have derived from `content`, for a deploy that sends /// none (MCP, a bare API call). `None` for a language whose parser this build lacks, /// or code that does not parse. -fn infer_main_schema( +pub fn infer_main_schema( language: &ScriptLang, kind: Option<&ScriptKind>, content: &str, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 3bf21463eb..a4fa57ba3f 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -576,14 +576,14 @@ pub async fn run_server( // Setup MCP server #[allow(unused_variables)] - let (mcp_router, gateway_mcp_router, mcp_cancellation_token) = { + let (mcp_router, gateway_mcp_router, gateway_session_router, mcp_cancellation_token) = { #[cfg(feature = "mcp")] if server_mode || mcp_mode { use mcp::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_workspace_from_token, reject_token_query_param, }; - let (mcp_router, mcp_cancellation_token) = setup_mcp_server( + let (mcp_router, session_router, mcp_cancellation_token) = setup_mcp_server( db.clone(), user_db, _base_internal_url.clone(), @@ -605,17 +605,32 @@ pub async fn run_server( .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, )); + // The workspace server. Its tools name their workspace; the token bounds which. + let gateway_session_router = session_router + .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(extract_workspace_from_token)) + .layer(axum::middleware::from_fn(reject_token_query_param)) + .layer(axum::middleware::from_fn( + add_www_authenticate_header_gateway, + )) + .layer(Extension(mcp::McpEndpoint("/api/mcp"))); ( workspaced_mcp_router, gateway_mcp_router, + gateway_session_router, Some(mcp_cancellation_token), ) } else { - (Router::new(), Router::new(), None) + (Router::new(), Router::new(), Router::new(), None) } #[cfg(not(feature = "mcp"))] - (Router::new(), Router::new(), Option::<()>::None) + ( + Router::new(), + Router::new(), + Router::new(), + Option::<()>::None, + ) }; // Workers block on this before pulling their first job, so it is released ahead of @@ -628,15 +643,15 @@ pub async fn run_server( } tokio::task::yield_now().await; - let mcp_list_tools_service = { + let (mcp_list_tools_service, mcp_toolset_service) = { #[cfg(feature = "mcp")] { - mcp::list_tools_service() + (mcp::list_tools_service(), mcp::toolset_service()) } #[cfg(not(feature = "mcp"))] { - Router::new() + (Router::new(), Router::new()) } }; @@ -854,6 +869,7 @@ pub async fn run_server( .nest("/docs", docs::global_service()) .nest("/indexer", indexer_oss::management_service()) .nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service) + .nest("/mcp/toolset", mcp_toolset_service) .nest("/db_health", db_health::global_service()) .nest("/health/detailed", health::detailed_service()) .nest( @@ -949,6 +965,7 @@ pub async fn run_server( mcp_router.clone().layer(mcp_cors.clone()), ) .nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone())) + .merge(gateway_session_router.layer(mcp_cors.clone())) .nest("/agent_workers", { #[cfg(feature = "agent_worker_server")] { @@ -1186,6 +1203,16 @@ pub async fn run_server( get(|| async { axum::http::StatusCode::NOT_FOUND }) } }) + .route("/.well-known/oauth-protected-resource/api/mcp", { + #[cfg(feature = "mcp")] + { + get(mcp::oauth_server::gateway_session_protected_resource_metadata) + } + #[cfg(not(feature = "mcp"))] + { + get(|| async { axum::http::StatusCode::NOT_FOUND }) + } + }) // JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix) .route("/.well-known/jwks.json", { #[cfg(all(feature = "private", feature = "enterprise", feature = "openidconnect"))] @@ -1259,6 +1286,9 @@ pub async fn run_server( if let Some(name) = name.as_ref() { tracing::info!("server starting for name={name}"); } + #[cfg(feature = "mcp")] + mcp::register_router(&_base_internal_url, Some(app.clone())); + let listener = listener.tap_io(move |tcp_stream| { let _ = tcp_stream.set_nodelay(!server_mode); }); @@ -1296,7 +1326,10 @@ pub async fn run_server( } }); - server.await?; + let served = server.await; + #[cfg(feature = "mcp")] + mcp::register_router(&_base_internal_url, None); + served?; let forks = &windmill_api_workspaces::workspaces::BACKGROUND_FORKS; forks.close(); if !forks.is_empty() { diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index a8eb420ac4..d31234b585 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -13,7 +13,8 @@ use windmill_mcp::common::types::{ FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo, }; use windmill_mcp::server::{ - BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter, + BackendResult, CallToolResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter, + SessionAccess, Tool, }; use crate::auth::AuthCache; @@ -23,6 +24,7 @@ use crate::jobs::{ }; use super::auto_generated_endpoints::all_tools; +use super::session; use super::utils::{ build_query_string, build_request_body, create_http_request, get_hub_script_schema, get_item_paths, get_item_schema, get_items, get_resources, get_resources_types, @@ -34,8 +36,8 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use windmill_mcp::server::{ - LocalSessionManager, McpToken, MultiWorkspaceMcp, Runner, StreamableHttpServerConfig, - StreamableHttpService, + LocalSessionManager, McpToken, MultiWorkspaceMcp, NoEndpointTools, Runner, SessionRunner, + StreamableHttpServerConfig, StreamableHttpService, }; use windmill_mcp::WorkspaceId; @@ -64,6 +66,32 @@ pub struct WindmillBackend { } impl WindmillBackend { + /// The instructions only read, so they are built at the token's own read-only flag. + fn session_ctx<'a>(&'a self, auth: &'a ApiAuthed, w_id: &'a str) -> session::Ctx<'a> { + let access = if auth.read_only { + SessionAccess::ReadOnly + } else { + SessionAccess::Full + }; + self.session_ctx_with(auth, w_id, access) + } + + fn session_ctx_with<'a>( + &'a self, + auth: &'a ApiAuthed, + w_id: &'a str, + access: SessionAccess, + ) -> session::Ctx<'a> { + session::Ctx { + auth, + w_id, + base_internal_url: &self.base_internal_url, + access, + origin: None, + jwt: Default::default(), + } + } + pub fn new( db: DB, user_db: UserDB, @@ -242,6 +270,7 @@ impl McpBackend for WindmillBackend { args: Value, request: &McpRequest<'_>, ) -> BackendResult { + windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "script"); let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -269,6 +298,7 @@ impl McpBackend for WindmillBackend { args: Value, request: &McpRequest<'_>, ) -> BackendResult { + windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "flow"); let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -295,6 +325,12 @@ impl McpBackend for WindmillBackend { endpoint_tool: &EndpointTool, args: Value, ) -> BackendResult { + // Whether the generated endpoint tools are used at all, and which ones. + windmill_common::feature_usage::log_feature_usage( + "mcp", + "endpoint_tool_call", + &endpoint_tool.name, + ); let args_map = match &args { Value::Object(map) => map, _ => { @@ -421,8 +457,57 @@ impl McpBackend for WindmillBackend { fn all_endpoint_tools(&self) -> Vec { all_tools() } + + async fn session_instructions( + &self, + auth: &ApiAuthed, + workspace_id: &str, + ) -> BackendResult { + Ok(session::session_instructions(&self.session_ctx(auth, workspace_id)).await) + } + + fn generic_session_instructions(&self) -> String { + session::generic_session_instructions() + } + + async fn workspace_context(&self, auth: &ApiAuthed, workspace_id: &str) -> String { + session::workspace_context(&self.session_ctx(auth, workspace_id)).await + } + + fn session_tools(&self, access: SessionAccess) -> Vec { + session::session_tools(access) + } + + async fn call_session_tool( + &self, + auth: &ApiAuthed, + workspace_id: &str, + name: &str, + args: Value, + access: SessionAccess, + origin: Option<&str>, + ) -> BackendResult { + let ctx = session::Ctx { origin, ..self.session_ctx_with(auth, workspace_id, access) }; + session::call_session_tool(&ctx, name, args).await + } } +/// Set on a request whose token is scoped to a selection of scripts, flows and endpoints +/// rather than `mcp:all`: on `/api/mcp`, the tools server answers it. +#[derive(Clone, Copy)] +pub struct McpSelection; + +/// MCP scopes without `mcp:all`: favorites, chosen scripts, flows, endpoints or hub apps. +fn scoped_to_selection(scopes: &[String]) -> bool { + let mut mcp = scopes.iter().filter(|scope| scope.starts_with("mcp:")); + mcp.clone().next().is_some() && !mcp.any(|scope| scope == "mcp:all") +} + +/// The path of a workspace-less MCP URL, when it is not the tools server's +/// `/api/mcp/gateway`: each URL is its own OAuth protected resource. +#[derive(Clone, Copy)] +pub struct McpEndpoint(pub &'static str); + /// Extract workspace ID from path and store it in request extensions pub async fn extract_and_store_workspace_id( Path(params): Path, @@ -454,7 +539,6 @@ pub async fn add_www_authenticate_header( .body(axum::body::Body::from("Missing workspace_id in request")) .unwrap(); }; - let response = next.run(request).await; // Only add header to 401 Unauthorized responses @@ -541,27 +625,32 @@ pub async fn extract_workspace_from_token( .fetch_optional(&db) .await { - Ok(Some(row)) => match row.workspace_id { - Some(workspace_id) => { - request - .extensions_mut() - .insert(GatewayWorkspaceId(workspace_id.clone())); - request.extensions_mut().insert(WorkspaceId(workspace_id)); + Ok(Some(row)) => { + if scoped_to_selection(row.scopes.as_deref().unwrap_or_default()) { + request.extensions_mut().insert(McpSelection); } - None => { - // Only enter multi-workspace mode for genuine MCP tokens; a - // full-privilege global token without mcp scope is rejected - // by the runner's mcp-scope check anyway. - let is_mcp = row - .scopes - .as_deref() - .is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:"))); - if is_mcp { - request.extensions_mut().insert(MultiWorkspaceMcp); - request.extensions_mut().insert(McpToken(token)); + match row.workspace_id { + Some(workspace_id) => { + request + .extensions_mut() + .insert(GatewayWorkspaceId(workspace_id.clone())); + request.extensions_mut().insert(WorkspaceId(workspace_id)); + } + None => { + // Only enter multi-workspace mode for genuine MCP tokens; a + // full-privilege global token without mcp scope is rejected + // by the runner's mcp-scope check anyway. + let is_mcp = row + .scopes + .as_deref() + .is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:"))); + if is_mcp { + request.extensions_mut().insert(MultiWorkspaceMcp); + request.extensions_mut().insert(McpToken(token)); + } } } - }, + } Ok(None) => {} Err(e) => { tracing::error!("Gateway token workspace lookup failed: {}", e); @@ -580,11 +669,16 @@ pub async fn add_www_authenticate_header_gateway( use axum::http::StatusCode; use windmill_common::BASE_URL; + let endpoint = request + .extensions() + .get::() + .map_or("/api/mcp/gateway", |e| e.0); + let response = next.run(request).await; if response.status() == StatusCode::UNAUTHORIZED { let base_url = BASE_URL.load(); - let resource_url = format!("{}/api/mcp/gateway", base_url); + let resource_url = format!("{}{}", base_url, endpoint); let www_authenticate = format!("Bearer resource=\"{}\"", resource_url); let (mut parts, body) = response.into_parts(); @@ -606,11 +700,12 @@ pub async fn setup_mcp_server( user_db: UserDB, base_internal_url: String, auth_cache: Arc, -) -> anyhow::Result<(Router, CancellationToken)> { +) -> anyhow::Result<(Router, Router, CancellationToken)> { let cancellation_token = CancellationToken::new(); let session_manager = Arc::new(LocalSessionManager::default()); let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache); + let session_runner = SessionRunner::new(backend.clone()); let runner = Runner::new(backend); let service_config = StreamableHttpServerConfig::default() @@ -632,11 +727,38 @@ pub async fn setup_mcp_server( // much smaller default, which would 413 them with no way to raise it. .with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await); + let session_service = StreamableHttpService::new( + move || Ok(session_runner.clone()), + session_manager.clone(), + service_config.clone(), + ); let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); - let router = Router::new().route_service("/", service); - Ok((router, cancellation_token)) + let router = Router::new().route_service("/", service.clone()); + // `/api/mcp` serves both servers: a token scoped to a selection of scripts, flows and + // endpoints gets the tools server, any other the session's toolset. At `/mcp` itself, + // beside the `/mcp/...` routes, so it is merged rather than nested. + let session_router = Router::new().route( + "/mcp", + axum::routing::any(move |mut request: Request| { + let (service, session_service) = (service.clone(), session_service.clone()); + async move { + use tower::ServiceExt; + let response = if request.extensions().get::().is_some() { + request.extensions_mut().insert(NoEndpointTools); + service.oneshot(request).await + } else { + session_service.oneshot(request).await + }; + match response { + Ok(response) => response.into_response(), + Err(never) => match never {}, + } + } + }), + ); + Ok((router, session_router, cancellation_token)) } /// HTTP handler to list MCP tools as JSON @@ -649,3 +771,62 @@ async fn list_mcp_tools_handler() -> JsonResult> { pub fn list_tools_service() -> Router { Router::new().route("/", get(list_mcp_tools_handler)) } + +#[derive(serde::Deserialize)] +struct ToolsetQuery { + access: Option, +} + +#[derive(serde::Serialize)] +struct ToolsetEntry { + name: String, + description: String, +} + +/// The tools `/api/mcp` offers an `mcp:all` token at each access level, for the token +/// form to show: the server's own filter, so the list cannot drift from it. +async fn list_toolset_handler( + axum::extract::Query(query): axum::extract::Query, +) -> JsonResult> { + let access = match query.access.as_deref() { + Some("read") => SessionAccess::ReadOnly, + Some("read_run") => SessionAccess::ReadAndRun, + _ => SessionAccess::Full, + }; + Ok(Json( + session::session_tools(access) + .into_iter() + .map(|tool| ToolsetEntry { + name: tool.name.to_string(), + description: tool.description.map(|d| d.to_string()).unwrap_or_default(), + }) + .collect(), + )) +} + +pub fn toolset_service() -> Router { + Router::new().route("/", get(list_toolset_handler)) +} + +#[cfg(test)] +mod tests { + use super::scoped_to_selection; + + #[test] + fn only_a_token_without_mcp_all_is_a_selection() { + let selection = |scopes: &[&str]| { + scoped_to_selection(&scopes.iter().map(|s| s.to_string()).collect::>()) + }; + assert!(selection(&["mcp:favorites"])); + assert!(selection(&["mcp:scripts:f/x/*", "mcp:endpoints:listJobs"])); + assert!(selection(&["mcp:all:f/x/*"])); + assert!(!selection(&["mcp:all"])); + assert!(!selection(&[ + "mcp:all", + "mcp:read_run", + "mcp:workspaces:a,b" + ])); + assert!(!selection(&[])); + assert!(!selection(&["variables:read"])); + } +} diff --git a/backend/windmill-api/src/mcp/mod.rs b/backend/windmill-api/src/mcp/mod.rs index 5545d59a9f..20c5532b29 100644 --- a/backend/windmill-api/src/mcp/mod.rs +++ b/backend/windmill-api/src/mcp/mod.rs @@ -5,6 +5,7 @@ mod auto_generated_endpoints; mod core; +mod session; mod utils; // Re-export only what's needed externally @@ -12,5 +13,6 @@ pub mod oauth_server; pub use core::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service, - reject_token_query_param, setup_mcp_server, + reject_token_query_param, setup_mcp_server, toolset_service, McpEndpoint, }; +pub(crate) use session::register_router; diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 4ffb6ec602..de07647305 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -201,7 +201,7 @@ struct AuthorizationCode { code: String, client_id: String, user_email: String, - workspace_id: String, + workspace_id: Option, scopes: Vec, redirect_uri: String, code_challenge: Option, @@ -216,7 +216,7 @@ struct RefreshTokenRow { access_token_hash: String, client_id: String, user_email: String, - workspace_id: String, + workspace_id: Option, scopes: Vec, token_family: sqlx::types::Uuid, created_at: chrono::DateTime, @@ -232,6 +232,9 @@ fn supported_scopes() -> Vec { "mcp:scripts:*".to_string(), "mcp:flows:*".to_string(), "mcp:endpoints:*".to_string(), + "mcp:read_only".to_string(), + "mcp:read_run".to_string(), + "mcp:runnables_as_tools".to_string(), ] } @@ -795,10 +798,31 @@ async fn oauth_authorize_inner( } /// Shared approve logic — validates form, checks membership, stores auth code. +async fn ensure_member(db: &DB, workspace_id: &str, email: &str) -> Result<()> { + let is_member = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", + workspace_id, + email + ) + .fetch_one(db) + .await + .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? + .unwrap_or(false); + + if !is_member { + return Err(Error::NotAuthorized(format!( + "User is not a member of workspace '{workspace_id}'" + ))); + } + Ok(()) +} + +/// `workspace_id` is the workspace the minted token is bound to; without one, the token +/// reaches what its scopes allow (`mcp:workspaces:`), or every workspace of its owner. async fn oauth_approve_inner( db: &DB, authed: &ApiAuthed, - workspace_id: &str, + workspace_id: Option<&str>, form: ApprovalForm, ) -> Result> { // The code approved here is exchanged for a database token carrying only this @@ -808,21 +832,8 @@ async fn oauth_approve_inner( // the gateway approve route reach the exchange through here. forbid_elevated_job_token(db, &authed.email, authed.job_id).await?; - // Verify user is a member of the workspace - let is_member = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", - workspace_id, - authed.email - ) - .fetch_one(db) - .await - .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? - .unwrap_or(false); - - if !is_member { - return Err(Error::NotAuthorized( - "User is not a member of this workspace".to_string(), - )); + if let Some(workspace_id) = workspace_id { + ensure_member(db, workspace_id, &authed.email).await?; } // Verify client exists and redirect_uri is registered @@ -945,7 +956,7 @@ pub async fn workspaced_oauth_approve( authed: ApiAuthed, Json(form): Json, ) -> Result> { - oauth_approve_inner(&db, &authed, &workspace_id, form).await + oauth_approve_inner(&db, &authed, Some(&workspace_id), form).await } /// PKCE validation (S256 only) @@ -1027,6 +1038,16 @@ pub async fn gateway_protected_resource_metadata() -> Json Json { + let base_url = BASE_URL.load(); + Json(build_protected_resource_metadata( + "/api/mcp", + "/api/mcp/gateway/oauth/server", + &base_url, + )) +} + /// GET /api/mcp/gateway/oauth/server/authorize — gateway mode (no workspace in path) pub async fn gateway_oauth_authorize( Extension(db): Extension, @@ -1035,23 +1056,44 @@ pub async fn gateway_oauth_authorize( oauth_authorize_inner(&db, params, None).await } -/// POST /api/mcp/gateway/oauth/server/approve — workspace_id comes from JSON body +/// POST /api/mcp/gateway/oauth/server/approve — the workspaces come from the JSON body pub async fn gateway_oauth_approve( Extension(db): Extension, authed: ApiAuthed, Json(form): Json, ) -> Result> { - if form.workspace_id.is_empty() { - return Err(Error::BadRequest("workspace_id is required".to_string())); + let mut scope = form.scope; + let mut workspace_ids = form.workspace_ids; + if workspace_ids.is_empty() { + workspace_ids.extend(form.workspace_id); } + let workspace_id = if form.all_workspaces { + None + } else { + match workspace_ids.as_slice() { + [] => { + return Err(Error::BadRequest( + "Pick at least one workspace, or all of them".to_string(), + )) + } + [only] => Some(only.as_str()), + several => { + for workspace_id in several { + ensure_member(&db, workspace_id, &authed.email).await?; + } + scope.push_str(&format!(" mcp:workspaces:{}", several.join(","))); + None + } + } + }; oauth_approve_inner( &db, &authed, - &form.workspace_id, + workspace_id, ApprovalForm { client_id: form.client_id, redirect_uri: form.redirect_uri, - scope: form.scope, + scope, state: form.state, code_challenge: form.code_challenge, code_challenge_method: form.code_challenge_method, @@ -1060,7 +1102,8 @@ pub async fn gateway_oauth_approve( .await } -/// Gateway approval form — same as ApprovalForm but includes workspace_id +/// Gateway approval form — same as ApprovalForm, plus the workspaces the token reaches: +/// one binds the token to it, several are listed in its scopes. #[derive(Debug, Deserialize)] pub struct GatewayApprovalForm { pub client_id: String, @@ -1069,7 +1112,14 @@ pub struct GatewayApprovalForm { pub state: String, pub code_challenge: String, pub code_challenge_method: String, - pub workspace_id: String, + #[serde(default)] + pub workspace_ids: Vec, + /// The single workspace a consent page loaded before `workspace_ids` existed sends. + #[serde(default)] + pub workspace_id: Option, + /// Every workspace the user is a member of, including ones they join later. + #[serde(default)] + pub all_workspaces: bool, } // ── Router constructors ── diff --git a/backend/windmill-api/src/mcp/session/apps.rs b/backend/windmill-api/src/mcp/session/apps.rs new file mode 100644 index 0000000000..66ad43de47 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/apps.rs @@ -0,0 +1,1282 @@ +//! Raw app drafts: files, backend runnables and their test runs. + +use std::cmp::Ordering; +use std::collections::HashSet; +use std::sync::LazyLock; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use super::runs::finish_run; +use super::workspace::{ + api_message, app_source_to_draft_value, deployed_item_exists, find_and_replace, get_deployed, + get_draft, item_exists, normalize_app_draft, path_enc, persist_draft, Persist, +}; +use super::{ + bool_arg, js_head, js_len, matches_glob, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult, +}; + +// `init_app` is dispatched below but stays out of this list until `gen/app_templates.json` +// is emitted by the generator. +pub(super) const TOOLS: &[&str] = &[ + "init_app", + "read_app_file", + "search_app", + "write_app_file", + "delete_app_file", + "patch_app_file", + "write_app_runnable", + "delete_app_runnable", + "test_run_app_runnable", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "init_app" => init_app(ctx, args).await, + "read_app_file" => read_app_file(ctx, args).await, + "search_app" => search_app(ctx, args).await, + "write_app_file" => write_app_file(ctx, args).await, + "delete_app_file" => delete_app_file(ctx, args).await, + "patch_app_file" => patch_app_file(ctx, args).await, + "write_app_runnable" => write_app_runnable(ctx, args).await, + "delete_app_runnable" => delete_app_runnable(ctx, args).await, + "test_run_app_runnable" => test_run_app_runnable(ctx, args).await, + _ => unreachable!("{name} is not a apps tool"), + } +} + +const GENERATED_FILE: &str = "/wmill.d.ts"; + +const READ_DEFAULT_LINE_LIMIT: i64 = 1500; +const READ_CHAR_BUDGET: usize = 50_000; + +const SEARCH_DEFAULT_MAX_MATCHES: i64 = 100; +const SEARCH_MAX_MATCHES_CEILING: i64 = 200; +const SEARCH_MAX_LINE_CHARS: usize = 200; +const SEARCH_TOTAL_CHAR_BUDGET: usize = 12_000; +const SEARCH_CONTEXT_LINES: usize = 2; + +const MAX_TOOL_ERROR_LENGTH: usize = 2000; + +/// `{templates: {: {: content}}, starter_runnables: {: runnable}}`, +/// the frontend's `FRAMEWORK_TEMPLATES` and `STARTER_RUNNABLES`. +static APP_TEMPLATES: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/app_templates.json")) + .expect("gen/app_templates.json is valid") +}); + +// --------------------------------------------------------------------------- +// JS-shaped helpers +// --------------------------------------------------------------------------- + +fn entries(value: &Value, key: &str) -> Map { + value + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +/// The object under `key`, created (or replacing a non-object) when missing. +fn object_mut<'a>(value: &'a mut Value, key: &str) -> &'a mut Map { + let slot = &mut value[key]; + if !slot.is_object() { + *slot = json!({}); + } + slot.as_object_mut().expect("just made an object") +} + +fn text_of<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn is_inline(runnable: &Value) -> bool { + matches!(text_of(runnable, "type"), Some("inline" | "runnableByName")) +} + +fn is_by_path(runnable: &Value) -> bool { + matches!(text_of(runnable, "type"), Some("path" | "runnableByPath")) +} + +// --------------------------------------------------------------------------- +// File targets +// --------------------------------------------------------------------------- + +enum Target { + Frontend(String), + Backend { file_path: String, key: String, extension: &'static str }, +} + +impl Target { + fn file_path(&self) -> &str { + match self { + Target::Frontend(file_path) | Target::Backend { file_path, .. } => file_path, + } + } +} + +fn resolve_target(raw: &str) -> Target { + let trimmed = raw.trim(); + let backend = trimmed.strip_prefix("backend/").and_then(|rest| { + let (key, extension) = rest + .strip_suffix("/main.ts") + .map(|key| (key, "ts")) + .or_else(|| rest.strip_suffix("/main.py").map(|key| (key, "py")))?; + (!key.is_empty() && !key.contains('/')).then_some((key, extension)) + }); + match backend { + Some((key, extension)) => { + Target::Backend { file_path: trimmed.to_string(), key: key.to_string(), extension } + } + None if trimmed.starts_with('/') => Target::Frontend(trimmed.to_string()), + None => Target::Frontend(format!("/{trimmed}")), + } +} + +fn assert_not_generated(file_path: &str) -> Result<(), String> { + if file_path == GENERATED_FILE { + return Err(format!( + "\"{file_path}\" is generated automatically from backend runnables and cannot be modified directly." + )); + } + Ok(()) +} + +fn inline_extension(runnable: &Value) -> &'static str { + let language = runnable.get("inlineScript").and_then(|s| s.get("language")); + if language.and_then(Value::as_str) == Some("python3") { + "py" + } else { + "ts" + } +} + +/// The inline script behind a `backend//main.` target, and its runnable. +fn inline_runnable_content<'a>( + value: &'a Value, + key: &str, + extension: &str, + app_path: &str, +) -> Result<(&'a str, &'a Value), String> { + let runnable = value + .get("runnables") + .and_then(|r| r.get(key)) + .filter(|r| truthy(Some(r))) + .ok_or_else(|| format!("Backend runnable \"{key}\" not found in app \"{app_path}\"."))?; + if !is_inline(runnable) { + return Err(format!( + "Runnable \"{key}\" is not inline. Use read_workspace_item on the referenced {} instead.", + text_of(runnable, "runType").unwrap_or("item") + )); + } + let expected = inline_extension(runnable); + if extension != expected { + return Err(format!( + "Runnable \"{key}\" language is {expected}. Use backend/{key}/main.{expected}." + )); + } + let content = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + .unwrap_or_default(); + Ok((content, runnable)) +} + +// --------------------------------------------------------------------------- +// Loading and saving the draft +// --------------------------------------------------------------------------- + +struct Loaded { + value: Value, + last_sync: Option, +} + +/// The caller's draft of the app, else the deployed app shaped as one. +async fn load_app(ctx: &Ctx<'_>, path: &str) -> Result { + let draft = get_draft(ctx, "raw_app", path).await?; + let mut last_sync = None; + if let Some(draft) = draft { + if draft.value.get("files").is_some() { + return Ok(Loaded { value: draft.value, last_sync: draft.created_at }); + } + last_sync = draft.created_at; + } + let app = get_deployed(ctx, "app", None, path) + .await + .map_err(|e| api_message(&e).to_string())?; + // Only an explicit false: an app known by its draft alone carries no version, so no + // `raw_app`, and must not read as low-code. + if app.get("raw_app") == Some(&Value::Bool(false)) { + return Err(format!( + "\"{path}\" is a low-code app. This chat only edits code-based apps — open it in the app editor instead." + )); + } + Ok(Loaded { value: app_source_to_draft_value(&app, Some(&app)), last_sync }) +} + +/// What the backend requires of a draft from an operator with builder rights: sandboxed, +/// never in viewer mode, and running as nobody but its builder. +async fn conform_builder_policy(ctx: &Ctx<'_>, value: &mut Value) { + if !ctx.auth.is_operator { + return; + } + let Ok(memberships) = ctx.get("/workspaces/users").await else { + return; + }; + let builder = memberships + .get("workspaces") + .and_then(Value::as_array) + .and_then(|list| list.iter().find(|w| text_of(w, "id") == Some(ctx.w_id))) + .and_then(|w| w.get("operator_settings")) + .and_then(|s| s.get("builder_apps")) + == Some(&Value::Bool(true)); + if !builder { + return; + } + let policy = object_mut(value, "policy"); + policy.insert("sandbox".into(), json!(true)); + if policy.get("execution_mode").and_then(Value::as_str) == Some("viewer") { + policy.insert("execution_mode".into(), json!("publisher")); + } + let username = &ctx.auth.username; + let this_user = if username.contains('@') { + username.clone() + } else { + format!("u/{username}") + }; + let differs = |key: &str, own: &str| { + policy + .get(key) + .is_some_and(|v| !v.is_null() && v.as_str() != Some(own)) + }; + if differs("on_behalf_of", &this_user) || differs("on_behalf_of_email", &ctx.auth.email) { + policy.shift_remove("on_behalf_of"); + policy.shift_remove("on_behalf_of_email"); + } +} + +/// Save the draft and report: the success JSON, or the conflict / failed-save JSON. +async fn save_app( + ctx: &Ctx<'_>, + path: &str, + mut value: Value, + last_sync: Option<&str>, + message: String, + warning: Option, +) -> ToolResult { + conform_builder_policy(ctx, &mut value).await; + let value = normalize_app_draft(&value); + match persist_draft(ctx, "app", path, None, &value, last_sync, false).await? { + Persist::Failed(text) => Ok(text), + Persist::Saved(_) => { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("message".into(), json!(message)); + out.insert( + "preview_url".into(), + json!(ctx.page_url(&format!( + "/apps_raw/edit/{path}?view=preview&nomenubar=true" + ))), + ); + if let Some(warning) = warning { + out.insert("warning".into(), json!(warning)); + } + Ok(pretty(&Value::Object(out))) + } + } +} + +fn sha256_hex(text: &str) -> String { + hex::encode(Sha256::digest(text.as_bytes())) +} + +/// Rebuild `policy.triggerables_v2` from the runnables: the key names what a runnable +/// may execute (an inline script by the hash of its code), the entry which of its inputs +/// the caller cannot set. +fn recompute_policy(value: &mut Value) { + let mut triggerables = Map::new(); + for (id, runnable) in entries(value, "runnables") { + let target = if is_inline(&runnable) { + let content = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + .unwrap_or_default(); + format!("rawscript/{}", sha256_hex(content)) + } else if is_by_path(&runnable) { + let run_type = match text_of(&runnable, "runType") { + Some("hubscript") => "script", + Some(other) => other, + None => "undefined", + }; + format!( + "{run_type}/{}", + text_of(&runnable, "path").unwrap_or("undefined") + ) + } else { + continue; + }; + let fields = entries(&runnable, "fields"); + let flagged = |flag: &str| -> Vec<&String> { + fields + .iter() + .filter(|(_, field)| truthy(field.get(flag))) + .map(|(name, _)| name) + .collect() + }; + let static_inputs: Map = fields + .iter() + .filter(|(_, field)| text_of(field, "type") == Some("static")) + .filter_map(|(name, field)| Some((name.clone(), field.get("value")?.clone()))) + .collect(); + let mut entry = Map::new(); + entry.insert("static_inputs".into(), Value::Object(static_inputs)); + entry.insert("one_of_inputs".into(), json!({})); + entry.insert( + "allow_user_resources".into(), + json!(flagged("allowUserResources")), + ); + if let Some(secs) = runnable + .get("delete_after_secs") + .filter(|s| s.as_f64().is_some_and(|s| s >= 0.0)) + { + entry.insert("delete_after_secs".into(), secs.clone()); + } + let sensitive = flagged("sensitive"); + if !sensitive.is_empty() { + entry.insert("sensitive_inputs".into(), json!(sensitive)); + } + if let Some(tag) = runnable + .get("inlineScript") + .and_then(|s| s.get("tag")) + .filter(|tag| truthy(Some(tag))) + { + entry.insert("tag".into(), tag.clone()); + } + // The server honors it on inline runnables only, whose code is deployed with the app. + if is_inline(&runnable) && runnable.get("viewer_data_access") == Some(&Value::Bool(true)) { + entry.insert("viewer_data_access".into(), json!(true)); + } + triggerables.insert(format!("{id}:{target}"), Value::Object(entry)); + } + let policy = object_mut(value, "policy"); + policy.insert("triggerables_v2".into(), Value::Object(triggerables)); + if !truthy(policy.get("execution_mode")) { + policy.insert("execution_mode".into(), json!("publisher")); + } +} + +// --------------------------------------------------------------------------- +// init_app +// --------------------------------------------------------------------------- + +/// A table reference canonicalized to `/` or `/:
`. +/// A bare name is a table of the app's own schema. +fn app_table_ref(datatable: &str, schema: Option<&str>, reference: &str) -> String { + if reference.contains('/') { + return reference.to_string(); + } + let separator = if reference.contains(':') { ':' } else { '.' }; + let (schema, table) = match reference.split_once(separator) { + Some((schema, table)) => (Some(schema), table), + None => (schema, reference), + }; + match schema { + _ if table.is_empty() => datatable.to_string(), + None | Some("" | "public") => format!("{datatable}/{table}"), + Some(schema) => format!("{datatable}/{schema}:{table}"), + } +} + +async fn init_app(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let framework = str_arg(args, "framework")?; + let template = APP_TEMPLATES["templates"] + .get(framework) + .ok_or_else(|| format!("Invalid framework \"{framework}\""))?; + + if get_draft(ctx, "raw_app", path).await?.is_some() { + return Err(format!( + "A draft for app \"{path}\" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first." + )); + } + if item_exists(ctx, "app", None, path).await? { + return Err(format!( + "An app already exists at \"{path}\". Use read_workspace_item + write_app_file / write_app_runnable to modify it." + )); + } + + let starters = &APP_TEMPLATES["starter_runnables"]; + let mut value = Map::new(); + if let Some(summary) = opt_str_arg(args, "summary") { + value.insert("summary".into(), json!(summary)); + } + value.insert("files".into(), template.clone()); + value.insert("runnables".into(), starters.clone()); + value.insert("policy".into(), json!({ "sandbox": true })); + let data = args.get("data").filter(|d| truthy(Some(d))); + let schema = data.and_then(|d| text_of(d, "schema")); + let datatable = data + .and_then(|d| text_of(d, "datatable")) + .unwrap_or_default(); + if let Some(data) = data { + let tables: Vec = data + .get("tables") + .and_then(Value::as_array) + .map(|tables| { + tables + .iter() + .filter_map(Value::as_str) + .map(|reference| app_table_ref(datatable, schema, reference)) + .collect() + }) + .unwrap_or_default(); + let mut out = Map::new(); + out.insert("tables".into(), json!(tables)); + out.insert("datatable".into(), json!(datatable)); + if let Some(schema) = schema { + out.insert("schema".into(), json!(schema)); + } + value.insert("data".into(), Value::Object(out)); + } + let mut value = Value::Object(value); + recompute_policy(&mut value); + + let starter_keys: Vec<&str> = starters + .as_object() + .map(|s| s.keys().map(String::as_str).collect()) + .unwrap_or_default(); + let data_note = match schema.filter(|s| !s.is_empty()) { + Some(schema) => format!(" Its data config names schema \"{schema}\" of data table \"{datatable}\" without creating it: if it does not exist yet, create it before the tables that go in it."), + None => String::new(), + }; + let message = format!( + "Initialized a per-user draft app \"{path}\" from the {framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables {}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.{data_note} Open preview_url in a browser the user can see from this conversation if you have one, and otherwise give them the link: its live preview follows every later edit.", + starter_keys.join(", ") + ); + save_app(ctx, path, value, None, message, None).await +} + +// --------------------------------------------------------------------------- +// read_app_file +// --------------------------------------------------------------------------- + +fn format_file_read(content: &str, offset: Option, limit: Option) -> String { + let lines: Vec<&str> = content.split('\n').collect(); + let total = lines.len(); + let requested_start = offset.unwrap_or(1); + let start = (requested_start.saturating_sub(1).max(0) as usize).min(total); + let line_limit = limit.unwrap_or(READ_DEFAULT_LINE_LIMIT).max(0) as usize; + let end = start.saturating_add(line_limit).min(total); + let window = lines[start..end].join("\n"); + let window_chars = js_len(&window); + let char_truncated = window_chars > READ_CHAR_BUDGET; + let body = js_head(&window, READ_CHAR_BUDGET); + + if requested_start > total as i64 { + return format!("offset {requested_start} is past the end of the file ({total} lines)."); + } + if start == 0 && end == total && !char_truncated { + return body.to_string(); + } + let mut label = format!("lines {}-{end} of {total}", start + 1); + let mut more = String::new(); + if char_truncated { + label.push_str(&format!( + ", truncated to the first {READ_CHAR_BUDGET} of {window_chars} chars" + )); + more = format!(" Reached the {READ_CHAR_BUDGET}-char limit; re-read with a smaller limit (fewer lines). If a single line exceeds the limit the file is likely minified and not readable this way."); + } else if end < total { + more = format!( + " Call read_app_file again with offset={} to continue.", + end + 1 + ); + } + format!("{label}.{more}\n\n{body}") +} + +async fn read_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let target = resolve_target(str_arg(args, "file_path")?); + let value = load_app(ctx, path).await?.value; + let content = match &target { + Target::Frontend(file_path) => value + .get("files") + .and_then(|files| text_of(files, file_path)) + .ok_or_else(|| format!("Frontend file \"{file_path}\" not found in app \"{path}\"."))?, + Target::Backend { key, extension, .. } => { + inline_runnable_content(&value, key, extension, path)?.0 + } + }; + let int = |name: &str| args.get(name).and_then(Value::as_i64); + Ok(format_file_read(content, int("offset"), int("limit"))) +} + +// --------------------------------------------------------------------------- +// search_app +// --------------------------------------------------------------------------- + +/// Frontend files (the generated one aside) and inline runnables, each under the path +/// `read_app_file` takes. +fn searchable_files(value: &Value) -> Vec<(String, String)> { + let mut files: Vec<(String, String)> = entries(value, "files") + .into_iter() + .filter(|(file_path, _)| file_path != GENERATED_FILE) + .filter_map(|(file_path, content)| Some((file_path, content.as_str()?.to_string()))) + .collect(); + for (key, runnable) in entries(value, "runnables") { + let Some(content) = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + else { + continue; + }; + files.push(( + format!("backend/{key}/main.{}", inline_extension(&runnable)), + content.to_string(), + )); + } + files +} + +// ponytail: approximates `localeCompare` (punctuation, then digits, then letters, case +// ignored until a tie) rather than full ICU collation; only the order files are listed in +// depends on it. +fn locale_compare(a: &str, b: &str) -> Ordering { + let key = |text: &str| -> Vec<(u8, char)> { + text.chars() + .flat_map(char::to_lowercase) + .map(|c| { + let class = if c.is_alphabetic() { + 2 + } else if c.is_numeric() { + 1 + } else { + 0 + }; + (class, c) + }) + .collect() + }; + key(a).cmp(&key(b)).then_with(|| b.cmp(a)) +} + +fn search_files( + mut files: Vec<(String, String)>, + query: &str, + file_glob: Option<&str>, + max_matches: i64, +) -> String { + let needle = query.to_lowercase(); + files.sort_by(|a, b| locale_compare(&a.0, &b.0)); + if let Some(glob) = file_glob { + files.retain(|(file_path, _)| matches_glob(file_path, glob)); + } + + // (file, 1-based line, text) rows: every matching line with its context window. + let mut rows: Vec<(&str, usize, &str)> = Vec::new(); + let (mut total, mut rendered, mut file_count, mut truncated) = (0usize, 0i64, 0usize, false); + for (file_path, content) in &files { + let lines: Vec<&str> = content.split('\n').collect(); + let mut file_had_match = false; + for (i, line) in lines.iter().enumerate() { + if !line.to_lowercase().contains(&needle) { + continue; + } + total += 1; + file_had_match = true; + if rendered >= max_matches { + truncated = true; + continue; + } + rendered += 1; + let lo = i.saturating_sub(SEARCH_CONTEXT_LINES); + let hi = (i + SEARCH_CONTEXT_LINES).min(lines.len() - 1); + for j in lo..=hi { + rows.push((file_path, j + 1, lines[j])); + } + } + if file_had_match { + file_count += 1; + } + } + + if total == 0 { + return format!( + "No matches. Try a broader or differently-spelled term{}.", + if file_glob.is_some() { + ", or drop the file_glob" + } else { + "" + } + ); + } + + let plural = |n: usize, one: &str, many: &str| { + if n == 1 { + one.to_string() + } else { + many.to_string() + } + }; + let header = format!( + "{total} {} in {file_count} {}{}", + plural(total, "match", "matches"), + plural(file_count, "file", "files"), + if truncated { + format!(" (showing the first {max_matches}; narrow with file_glob or a more specific query)") + } else { + String::new() + } + ); + + let mut spent = js_len(&header); + let mut out = vec![header]; + let mut current_file = ""; + let mut budget_hit = false; + let mut seen = HashSet::new(); + for (file_path, line, text) in rows { + if !seen.insert((file_path, line)) { + continue; + } + let text = if js_len(text) > SEARCH_MAX_LINE_CHARS { + format!("{}… [line truncated]", js_head(text, SEARCH_MAX_LINE_CHARS)) + } else { + text.to_string() + }; + let new_file = file_path != current_file; + let row = if new_file { + format!("{file_path}\n {line}: {text}") + } else { + format!(" {line}: {text}") + }; + if spent + js_len(&row) + 1 > SEARCH_TOTAL_CHAR_BUDGET { + budget_hit = true; + break; + } + if new_file { + current_file = file_path; + } + spent += js_len(&row) + 1; + out.push(row); + } + if budget_hit { + out.push( + "… output truncated at the context budget — narrow with file_glob or a more specific query." + .to_string(), + ); + } + out.join("\n") +} + +async fn search_app(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let query = str_arg(args, "query")?; + if query.is_empty() { + return Err("search_app requires a non-empty query.".to_string()); + } + let value = load_app(ctx, path).await?.value; + let max_matches = args + .get("max_matches") + .and_then(Value::as_i64) + .unwrap_or(SEARCH_DEFAULT_MAX_MATCHES) + .min(SEARCH_MAX_MATCHES_CEILING); + let file_glob = opt_str_arg(args, "file_glob").filter(|glob| !glob.is_empty()); + Ok(search_files( + searchable_files(&value), + query, + file_glob, + max_matches, + )) +} + +// --------------------------------------------------------------------------- +// File writes +// --------------------------------------------------------------------------- + +async fn write_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let content = str_arg(args, "content")?; + let Target::Frontend(file_path) = resolve_target(str_arg(args, "file_path")?) else { + return Err("write_app_file only writes frontend files. Use write_app_runnable to set inline backend script content.".to_string()); + }; + assert_not_generated(&file_path)?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + object_mut(&mut value, "files").insert(file_path.clone(), json!(content)); + let message = format!("Updated draft app \"{path}\" with frontend file \"{file_path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +async fn delete_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let Target::Frontend(file_path) = resolve_target(str_arg(args, "file_path")?) else { + return Err("delete_app_file only deletes frontend files. Use delete_app_runnable for backend runnables.".to_string()); + }; + assert_not_generated(&file_path)?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + if object_mut(&mut value, "files") + .shift_remove(&file_path) + .is_none() + { + return Err(format!( + "Frontend file \"{file_path}\" not found in app \"{path}\"." + )); + } + let message = format!("Removed \"{file_path}\" from draft app \"{path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +async fn patch_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let old = str_arg(args, "old_string")?; + let new = str_arg(args, "new_string")?; + let replace_all = bool_arg(args, "replace_all"); + let target = resolve_target(str_arg(args, "file_path")?); + if let Target::Frontend(file_path) = &target { + assert_not_generated(file_path)?; + } + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + let label = format!("{} content", target.file_path()); + match &target { + Target::Frontend(file_path) => { + let current = value + .get("files") + .and_then(|files| text_of(files, file_path)) + .ok_or_else(|| { + format!("Frontend file \"{file_path}\" not found in app \"{path}\".") + })?; + let updated = find_and_replace(current, old, new, replace_all, &label)?; + object_mut(&mut value, "files").insert(file_path.clone(), json!(updated)); + } + Target::Backend { key, extension, .. } => { + let (current, runnable) = inline_runnable_content(&value, key, extension, path)?; + let updated = find_and_replace(current, old, new, replace_all, &label)?; + let language = runnable + .get("inlineScript") + .and_then(|s| s.get("language")) + .filter(|l| !l.is_null()) + .cloned() + .unwrap_or_else(|| json!(if *extension == "py" { "python3" } else { "bun" })); + let mut runnable = runnable.clone(); + runnable["inlineScript"] = json!({ "language": language, "content": updated }); + object_mut(&mut value, "runnables").insert(key.clone(), runnable); + } + } + let message = format!( + "Patched \"{}\" in draft app \"{path}\".", + target.file_path() + ); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +// --------------------------------------------------------------------------- +// Runnable writes +// --------------------------------------------------------------------------- + +/// The stored runnable for a `write_app_runnable` input. Converting between kinds drops +/// the other kind's fields, and a path runnable's `schema` survives only while its target +/// is unchanged: it describes the item pointed at. `viewer_data_access` is inline-only. +fn build_persisted_runnable(input: &Value, existing: Option<&Value>) -> Result { + let name = str_arg(input, "name")?; + let kind = str_arg(input, "type")?; + let existing = existing.and_then(Value::as_object); + let fields = match input.get("staticInputs").and_then(Value::as_object) { + Some(static_inputs) => Value::Object( + static_inputs + .iter() + .map(|(k, v)| { + ( + k.clone(), + json!({ "type": "static", "value": v, "fieldType": "object" }), + ) + }) + .collect(), + ), + None => existing + .and_then(|e| e.get("fields")) + .filter(|f| !f.is_null()) + .cloned() + .unwrap_or_else(|| json!({})), + }; + let mut out = existing.cloned().unwrap_or_default(); + let carried_as_viewer = out.shift_remove("viewer_data_access") == Some(Value::Bool(true)); + for key in ["runType", "path", "inlineScript"] { + out.shift_remove(key); + } + out.insert("name".into(), json!(name)); + + if kind == "inline" { + let inline = input + .get("inlineScript") + .filter(|s| truthy(Some(s))) + .ok_or("inlineScript is required when runnable type is \"inline\".")?; + out.insert("type".into(), json!("inline")); + out.insert( + "inlineScript".into(), + json!({ "content": inline.get("content"), "language": inline.get("language") }), + ); + let viewer_data_access = input + .get("viewerDataAccess") + .and_then(Value::as_bool) + .unwrap_or(carried_as_viewer); + if viewer_data_access { + out.insert("viewer_data_access".into(), json!(true)); + } + out.insert("fields".into(), fields); + return Ok(Value::Object(out)); + } + + let path = text_of(input, "path") + .filter(|p| !p.is_empty()) + .ok_or("path is required when runnable type is \"script\", \"flow\", or \"hubscript\".")?; + let same_target = existing.is_some_and(|e| { + matches!( + e.get("type").and_then(Value::as_str), + Some("path" | "runnableByPath") + ) && e.get("runType").and_then(Value::as_str) == Some(kind) + && e.get("path").and_then(Value::as_str) == Some(path) + }); + let schema = existing + .filter(|_| same_target) + .and_then(|e| e.get("schema")) + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + out.insert("type".into(), json!("path")); + out.insert("runType".into(), json!(kind)); + out.insert("path".into(), json!(path)); + out.insert("fields".into(), fields); + out.insert("schema".into(), schema); + Ok(Value::Object(out)) +} + +/// Whether the script or flow a path runnable names is deployed. None when it names +/// neither, or the probe failed: the answer only annotates a write. +async fn path_target_deployed(ctx: &Ctx<'_>, runnable: &Value) -> Option { + let kind = text_of(runnable, "runType").filter(|k| matches!(*k, "script" | "flow"))?; + let path = text_of(runnable, "path").filter(|p| !p.is_empty())?; + deployed_item_exists(ctx, kind, None, path).await.ok() +} + +async fn write_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let input = args + .get("runnable") + .filter(|r| r.is_object()) + .ok_or("Missing required object argument `runnable`")?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + let existing = value.get("runnables").and_then(|r| r.get(key)); + let persisted = build_persisted_runnable(input, existing)?; + let warning = match path_target_deployed(ctx, &persisted).await { + Some(false) => Some(format!( + "This runnable points at an item that is NOT deployed (\"{key}\" → {} \"{}\"), so it fails at runtime — a path runnable runs the deployed item, never a draft. Offer to deploy just that item with deploy_workspace_item; the app itself does not need deploying, since the preview runs its draft.", + text_of(&persisted, "runType").unwrap_or_default(), + text_of(&persisted, "path").unwrap_or_default(), + )), + _ => None, + }; + object_mut(&mut value, "runnables").insert(key.to_string(), persisted); + recompute_policy(&mut value); + let message = format!("Updated draft app \"{path}\" with runnable \"{key}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, warning).await +} + +async fn delete_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + if object_mut(&mut value, "runnables") + .shift_remove(key) + .is_none() + { + return Err(format!( + "Backend runnable \"{key}\" not found in app \"{path}\"." + )); + } + recompute_policy(&mut value); + let message = format!("Removed runnable \"{key}\" from draft app \"{path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +// --------------------------------------------------------------------------- +// test_run_app_runnable +// --------------------------------------------------------------------------- + +/// The `execute_component` body that runs a runnable the way the editor preview does. +fn execute_component_body(app_path: &str, key: &str, runnable: &Value, args: &Value) -> Value { + let fields = entries(runnable, "fields"); + let mut test_args = args.as_object().cloned().unwrap_or_default(); + let mut static_fields = Map::new(); + let mut sensitive = Vec::new(); + for (name, field) in &fields { + match text_of(field, "type") { + Some("static") => { + if let Some(value) = field.get("value") { + static_fields.insert(name.clone(), value.clone()); + } + } + // Filled by the server from `$ctx:`; left out, the argument is missing. + Some("ctx") => { + if let Some(prop) = text_of(field, "ctx").filter(|p| !p.is_empty()) { + test_args.insert(name.clone(), json!(format!("$ctx:{prop}"))); + } + } + Some("user") if truthy(field.get("sensitive")) => sensitive.push(name.clone()), + _ => {} + } + } + + let mut body = Map::new(); + body.insert("component".into(), json!(key)); + body.insert("args".into(), Value::Object(test_args)); + // Sent even when empty: its presence is what puts the server in preview mode, which + // is what lets inline draft code run at all. + body.insert( + "force_viewer_static_fields".into(), + Value::Object(static_fields), + ); + // The server encrypts a queued argument only when it is named here. + if !sensitive.is_empty() { + body.insert("force_viewer_sensitive_inputs".into(), json!(sensitive)); + } + if is_inline(runnable) { + if let Some(inline) = runnable.get("inlineScript").filter(|s| truthy(Some(s))) { + let id = inline.get("id").filter(|id| !id.is_null()); + let mut raw_code = Map::new(); + raw_code.insert( + "content".into(), + if id.is_some() { + json!("") + } else { + inline.get("content").cloned().unwrap_or_default() + }, + ); + raw_code.insert( + "language".into(), + inline + .get("language") + .filter(|l| !l.is_null()) + .cloned() + .unwrap_or_else(|| json!("")), + ); + raw_code.insert("path".into(), json!(format!("{app_path}/{key}"))); + match id { + Some(id) => { + body.insert("id".into(), id.clone()); + } + None => { + if let Some(lock) = inline.get("lock") { + raw_code.insert("lock".into(), lock.clone()); + } + } + } + if let Some(cache_ttl) = inline.get("cache_ttl") { + raw_code.insert("cache_ttl".into(), cache_ttl.clone()); + } + if let Some(tag) = inline.get("tag").filter(|tag| truthy(Some(tag))) { + raw_code.insert("tag".into(), tag.clone()); + } + body.insert("raw_code".into(), Value::Object(raw_code)); + } + } else if is_by_path(runnable) { + let run_type = match text_of(runnable, "runType") { + Some("hubscript") | None => "script", + Some(other) => other, + }; + body.insert( + "path".into(), + json!(format!( + "{run_type}/{}", + text_of(runnable, "path").unwrap_or_default() + )), + ); + } + Value::Object(body) +} + +async fn test_run_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let value = load_app(ctx, path).await?.value; + let runnables = entries(&value, "runnables"); + let Some(runnable) = runnables.get(key).filter(|r| truthy(Some(r))) else { + let known: Vec<&str> = runnables.keys().map(String::as_str).collect(); + return Err(format!( + "App \"{path}\" has no backend runnable \"{key}\".{}", + if known.is_empty() { + String::new() + } else { + format!(" Available runnables: {}.", known.join(", ")) + } + )); + }; + + let body = execute_component_body( + path, + key, + runnable, + args.get("args").unwrap_or(&Value::Null), + ); + let id = ctx + .request( + "POST", + &ctx.ws(&format!("apps_u/execute_component/{}", path_enc(path))), + Some(body), + ) + .await + .map_err(|e| { + let message = api_message(&e); + let message = if js_len(message) > MAX_TOOL_ERROR_LENGTH { + format!("{}... (truncated)", js_head(message, MAX_TOOL_ERROR_LENGTH)) + } else { + message.to_string() + }; + format!("Failed to execute test run: {message}") + })?; + let id = id.trim().trim_matches('"').to_string(); + + // A path runnable aimed at a flow queues a flow job, whose steps are worth a look. + let is_flow = text_of(runnable, "runType") == Some("flow"); + let label = format!("{path} / {key}"); + finish_run(ctx, Ok(id), &label, is_flow, "test", args).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn targets_resolve_to_frontend_files_or_inline_runnables() { + assert!(matches!(resolve_target(" App.tsx "), Target::Frontend(p) if p == "/App.tsx")); + assert!(matches!( + resolve_target("backend/a/main.py"), + Target::Backend { key, extension: "py", .. } if key == "a" + )); + // Not the virtual file of a runnable: a frontend path like any other. + assert!(matches!( + resolve_target("backend/a/b/main.ts"), + Target::Frontend(p) if p == "/backend/a/b/main.ts" + )); + } + + #[test] + fn file_reads_are_whole_or_annotated_windows() { + assert_eq!(format_file_read("a\nb", None, None), "a\nb"); + assert_eq!( + format_file_read("a\nb\nc", Some(2), Some(1)), + "lines 2-2 of 3. Call read_app_file again with offset=3 to continue.\n\nb" + ); + assert_eq!( + format_file_read("a\nb\nc", Some(2), None), + "lines 2-3 of 3.\n\nb\nc" + ); + assert_eq!( + format_file_read("a", Some(5), None), + "offset 5 is past the end of the file (1 lines)." + ); + let long = "x".repeat(READ_CHAR_BUDGET + 1); + assert!(format_file_read(&long, None, None).starts_with( + "lines 1-1 of 1, truncated to the first 50000 of 50001 chars. Reached the 50000-char limit;" + )); + } + + #[test] + fn search_caps_matching_lines_and_dedupes_context() { + let files = vec![ + ( + "/b.ts".to_string(), + "one\nFoo()\nfoo()\nfour\nfive\nsix".to_string(), + ), + ("/App.tsx".to_string(), "foo".to_string()), + ]; + assert_eq!( + search_files(files.clone(), "foo", None, 100), + "3 matches in 2 files\n/App.tsx\n 1: foo\n/b.ts\n 1: one\n 2: Foo()\n 3: foo()\n 4: four\n 5: five" + ); + assert_eq!( + search_files(files.clone(), "foo", Some("*.ts"), 1), + "2 matches in 1 file (showing the first 1; narrow with file_glob or a more specific query)\n/b.ts\n 1: one\n 2: Foo()\n 3: foo()\n 4: four" + ); + assert_eq!( + search_files(files, "nope", Some("*.ts"), 100), + "No matches. Try a broader or differently-spelled term, or drop the file_glob." + ); + } + + #[test] + fn globs_match_the_name_or_the_full_path() { + assert!(matches_glob("/components/Button.tsx", "*.tsx")); + assert!(!matches_glob("/components/Button.tsx", "/*.tsx")); + assert!(matches_glob("/components/Button.tsx", "/components/*")); + assert!(matches_glob("backend/a/main.ts", "backend/**")); + assert!(!matches_glob("/a.tsx", "?.ts")); + } + + #[test] + fn runnable_kind_changes_drop_the_other_kinds_fields() { + let existing = json!({ + "name": "old", "type": "path", "runType": "script", "path": "u/a/s", + "fields": { "x": { "type": "static", "value": 1 } }, "schema": { "k": 1 } + }); + let same = build_persisted_runnable( + &json!({ "name": "n", "type": "script", "path": "u/a/s" }), + Some(&existing), + ) + .unwrap(); + assert_eq!(same["schema"], json!({ "k": 1 })); + assert_eq!(same["fields"], existing["fields"]); + + let retargeted = build_persisted_runnable( + &json!({ "name": "n", "type": "flow", "path": "u/a/s" }), + Some(&existing), + ) + .unwrap(); + assert_eq!(retargeted["schema"], json!({})); + + let inline = build_persisted_runnable( + &json!({ + "name": "n", "type": "inline", "staticInputs": { "y": 2 }, + "inlineScript": { "language": "bun", "content": "c" } + }), + Some(&existing), + ) + .unwrap(); + assert!(inline.get("path").is_none() && inline.get("runType").is_none()); + assert_eq!( + inline["fields"], + json!({ "y": { "type": "static", "value": 2, "fieldType": "object" } }) + ); + assert_eq!( + build_persisted_runnable(&json!({ "name": "n", "type": "inline" }), None), + Err("inlineScript is required when runnable type is \"inline\".".to_string()) + ); + } + + #[test] + fn viewer_data_access_is_set_kept_or_cleared_on_inline_runnables_only() { + let inline = |viewer: Value| { + let mut input = json!({ + "name": "n", "type": "inline", + "inlineScript": { "language": "bun", "content": "c" } + }); + if !viewer.is_null() { + input["viewerDataAccess"] = viewer; + } + input + }; + let set = build_persisted_runnable(&inline(json!(true)), None).unwrap(); + assert_eq!(set["viewer_data_access"], json!(true)); + let kept = build_persisted_runnable(&inline(Value::Null), Some(&set)).unwrap(); + assert_eq!(kept["viewer_data_access"], json!(true)); + let cleared = build_persisted_runnable(&inline(json!(false)), Some(&set)).unwrap(); + assert!(cleared.get("viewer_data_access").is_none()); + let by_path = build_persisted_runnable( + &json!({ "name": "n", "type": "script", "path": "u/a/s", "viewerDataAccess": true }), + Some(&set), + ) + .unwrap(); + assert!(by_path.get("viewer_data_access").is_none()); + + let mut value = json!({ "runnables": { "a": set, "b": { + "type": "path", "runType": "script", "path": "u/a/s", "viewer_data_access": true + } } }); + recompute_policy(&mut value); + let triggerables = &value["policy"]["triggerables_v2"]; + let flagged: Vec<&String> = triggerables + .as_object() + .unwrap() + .iter() + .filter(|(_, e)| e["viewer_data_access"] == json!(true)) + .map(|(k, _)| k) + .collect(); + assert_eq!(flagged.len(), 1); + assert!(flagged[0].starts_with("a:rawscript/")); + } + + #[test] + fn policy_keys_inline_runnables_by_the_hash_of_their_code() { + let mut value = json!({ + "runnables": { + "a": { + "type": "inline", "inlineScript": { "content": "", "language": "bun" }, + "fields": { + "s": { "type": "static", "value": 1 }, + "u": { "type": "user", "sensitive": true } + } + }, + "b": { "type": "path", "runType": "hubscript", "path": "hub/1/x" } + }, + "policy": { "on_behalf_of": "u/x", "triggerables_v2": { "stale": {} } } + }); + recompute_policy(&mut value); + assert_eq!( + value["policy"], + json!({ + "on_behalf_of": "u/x", + "triggerables_v2": { + "a:rawscript/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": { + "static_inputs": { "s": 1 }, "one_of_inputs": {}, + "allow_user_resources": [], "sensitive_inputs": ["u"] + }, + "b:script/hub/1/x": { + "static_inputs": {}, "one_of_inputs": {}, "allow_user_resources": [] + } + }, + "execution_mode": "publisher" + }) + ); + } + + #[test] + fn preview_body_fills_static_ctx_and_sensitive_inputs() { + let runnable = json!({ + "type": "inline", + "inlineScript": { "content": "code", "language": "bun" }, + "fields": { + "s": { "type": "static", "value": 1 }, + "who": { "type": "ctx", "ctx": "username" }, + "pw": { "type": "user", "sensitive": true } + } + }); + assert_eq!( + execute_component_body("u/a/app", "k", &runnable, &json!({ "x": 1 })), + json!({ + "component": "k", + "args": { "x": 1, "who": "$ctx:username" }, + "force_viewer_static_fields": { "s": 1 }, + "force_viewer_sensitive_inputs": ["pw"], + "raw_code": { "content": "code", "language": "bun", "path": "u/a/app/k" } + }) + ); + let by_path = json!({ "type": "path", "runType": "flow", "path": "u/a/f" }); + assert_eq!( + execute_component_body("u/a/app", "k", &by_path, &Value::Null), + json!({ + "component": "k", "args": {}, "force_viewer_static_fields": {}, + "path": "flow/u/a/f" + }) + ); + } + + #[test] + fn table_refs_are_canonicalized_against_the_apps_schema() { + assert_eq!( + app_table_ref("main", Some("shop"), "orders"), + "main/shop:orders" + ); + assert_eq!(app_table_ref("main", None, "orders"), "main/orders"); + assert_eq!( + app_table_ref("main", Some("shop"), "public.orders"), + "main/orders" + ); + assert_eq!( + app_table_ref("main", Some("shop"), "crm:leads"), + "main/crm:leads" + ); + assert_eq!(app_table_ref("main", Some("shop"), "other/x"), "other/x"); + } +} diff --git a/backend/windmill-api/src/mcp/session/data.rs b/backend/windmill-api/src/mcp/session/data.rs new file mode 100644 index 0000000000..665647c7ce --- /dev/null +++ b/backend/windmill-api/src/mcp/session/data.rs @@ -0,0 +1,969 @@ +//! Data tables, DuckLake and database resource schemas. + +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::{json, Map, Value}; + +use super::runs::{backgrounded_summary, run_script_preview, wait_budget, wait_job}; +use super::workspace::{api_message, path_enc}; +use super::{array, enc, js_str, non_null, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "list_datatables", + "get_datatable_table_schema", + "exec_datatable_sql", + "list_data_metrics", + "list_ducklakes", + "get_db_schema", + "list_datatable_migrations", + "create_datatable_migration", + "run_datatable_migrations", + "rollback_datatable_migration", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "list_datatables" => Ok(list_datatables(ctx, args) + .await + .unwrap_or_else(|e| format!("Error listing datatables: {e}"))), + "get_datatable_table_schema" => get_datatable_table_schema(ctx, args).await, + "exec_datatable_sql" => exec_datatable_sql(ctx, args).await, + "list_data_metrics" => list_data_metrics(ctx, args).await, + "list_ducklakes" => Ok(list_ducklakes(ctx) + .await + .unwrap_or_else(|e| format!("Error listing DuckLake catalogs: {e}"))), + "get_db_schema" => get_db_schema(ctx, args).await, + "list_datatable_migrations" => list_datatable_migrations(ctx, args).await, + "create_datatable_migration" => create_datatable_migration(ctx, args).await, + "run_datatable_migrations" => run_datatable_migrations(ctx, args).await, + "rollback_datatable_migration" => rollback_datatable_migration(ctx, args).await, + _ => unreachable!("{name} is not a data tool"), + } +} + +const NO_DATATABLES_CONFIGURED_MESSAGE: &str = "No datatables are configured in this workspace. Datatable operations (querying data, creating or altering tables) are blocked until a datatable exists. Datatables are not created by SQL — the user must set one up in the workspace settings (Workspace settings → Data Tables) first. Do not call exec_datatable_sql or assume a \"main\" datatable exists; instead tell the user this is a required prerequisite and ask them to configure a datatable in their workspace settings."; + +const NO_DUCKLAKES_CONFIGURED_MESSAGE: &str = "No DuckLake catalogs are configured in this workspace. A data pipeline that materializes DuckLake tables or reads/writes S3 assets cannot run until object storage and a DuckLake catalog are set up. You can still draft the pipeline scripts, but tell the user how to enable it by role: a workspace admin adds object storage under Workspace settings → Object Storage (S3/Azure/GCS), then a DuckLake catalog on top of it; a user without admin rights should ask a workspace admin. Do not assume a default 'main' DuckLake exists."; + +const NO_DATA_METRICS_NOTE: &str = "Nothing matched. That does not establish that nothing is declared: declarations whose producing script you cannot read are omitted from this list, not flagged. You may write your own aggregate, but tell the user you found no declared measure you can read rather than stating none is declared."; + +const DEFAULT_DATA_METRICS_LIMIT: u64 = 200; +/// Rows of a SELECT handed to the model. +const MAX_ROWS: usize = 100; +/// Seconds a query is awaited before it is left running for `get_run`. +const MAX_SCHEMA_LENGTH: usize = 350_000; + +/// The worker's error for a data table that is not configured in the workspace. +static DATATABLE_NOT_FOUND: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)datatable\s+\S+\s+not found").expect("valid regex")); + +fn datatable_not_configured_message(name: &str) -> String { + format!( + "Datatable \"{name}\" is not configured in this workspace, so this operation cannot run. \ + Datatables are not created by SQL — they must be set up first by the user in the workspace settings \ + (Workspace settings → Data Tables) before any table can be queried or created. \ + This is a required, blocking prerequisite: do not retry on this or another name. \ + Tell the user they need to configure a datatable (e.g. named \"{name}\") in their workspace settings, then try again." + ) +} + +/// The optional `role` argument, held to the rule the server applies to a role name. +fn role_arg(args: &Value) -> Result, String> { + let role = opt_str_arg(args, "role"); + match role { + Some(r) + if r.is_empty() + || r.len() > 63 + || !r + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => + { + Err(format!( + "Invalid `role` '{r}': only letters, digits, '_' and '-' are allowed (1 to 63 characters)" + )) + } + _ => Ok(role), + } +} + +/// `path?k=v&...` over the parameters that are set. +fn with_query(path: String, params: &[(&str, Option<&str>)]) -> String { + let query: Vec = params + .iter() + .filter_map(|(k, v)| v.map(|v| format!("{k}={}", enc(v)))) + .collect(); + if query.is_empty() { + path + } else { + format!("{path}?{}", query.join("&")) + } +} + +async fn list_datatables(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let role = role_arg(args)?; + let datatable_name = opt_str_arg(args, "datatable_name"); + if role.is_some() && datatable_name.is_none() { + return Err("`role` needs `datatable_name`, the datatable it is a role of".to_string()); + } + let path = with_query( + ctx.ws("workspaces/list_datatable_tables"), + &[ + ("datatable_name", datatable_name), + ("role_for", role.and(datatable_name)), + ("role", role), + ], + ); + let metadata = ctx.get(&path).await?; + let metadata = array(Some(&metadata)); + if metadata.is_empty() { + return Ok(NO_DATATABLES_CONFIGURED_MESSAGE.to_string()); + } + let listed: Vec = metadata + .iter() + .map(|d| { + let mut out = Map::new(); + out.insert("datatable_name".into(), d["datatable_name"].clone()); + out.insert("schemas".into(), d["schemas"].clone()); + if truthy(d.get("error")) { + out.insert("error".into(), d["error"].clone()); + } + if truthy(d.get("permissioned")) { + for key in ["usable_roles", "default_role"] { + if let Some(v) = d.get(key) { + out.insert(key.into(), v.clone()); + } + } + } + Value::Object(out) + }) + .collect(); + Ok(pretty(&Value::Array(listed))) +} + +async fn get_datatable_table_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let schema_name = str_arg(args, "schema_name")?; + let table_name = str_arg(args, "table_name")?; + let role = role_arg(args)?; + let path = with_query( + ctx.ws("workspaces/get_datatable_table_schema"), + &[ + ("datatable_name", Some(datatable_name)), + ("schema_name", Some(schema_name)), + ("table_name", Some(table_name)), + ("role", role), + ], + ); + Ok(match ctx.get(&path).await { + Ok(schema) => pretty(&json!({ + "datatable_name": datatable_name, + "schema_name": schema_name, + "table_name": table_name, + "columns": schema["columns"], + })), + Err(e) if DATATABLE_NOT_FOUND.is_match(&e) => { + datatable_not_configured_message(datatable_name) + } + Err(e) => format!("Error getting table schema: {e}"), + }) +} + +async fn list_ducklakes(ctx: &Ctx<'_>) -> ToolResult { + let ducklakes = ctx.get(&ctx.ws("workspaces/list_ducklakes")).await?; + if ducklakes.as_array().is_none_or(Vec::is_empty) { + return Ok(NO_DUCKLAKES_CONFIGURED_MESSAGE.to_string()); + } + Ok(pretty(&json!({ "ducklakes": ducklakes }))) +} + +async fn list_data_metrics(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let limit = match args.get("limit") { + None | Some(Value::Null) => DEFAULT_DATA_METRICS_LIMIT, + Some(v) => v + .as_u64() + .filter(|n| (1..=1000).contains(n)) + .ok_or("`limit` must be an integer between 1 and 1000")?, + }; + let table = opt_str_arg(args, "table"); + let path = with_query( + ctx.ws("data_metrics/list"), + &[ + ("table", table), + ("path_prefix", opt_str_arg(args, "path_prefix")), + ("per_page", Some(&limit.to_string())), + ], + ); + let page = ctx.get(&path).await?; + let metrics = page.get("metrics").cloned().unwrap_or_else(|| json!([])); + let note = if truthy(page.get("next_cursor")) { + Some(format!("More declarations exist beyond the first {limit}. Re-call with table/path_prefix to target what you are looking for, or with a higher limit (max 1000) for the rest of the list, rather than concluding a measure is undeclared.")) + } else if metrics.as_array().is_some_and(Vec::is_empty) { + // The endpoint expands a table filter only once it contains a `/`, so a bare + // table name matches nothing whatever is declared. + let bare = table.map(|t| t.replacen("ducklake://", "", 1)); + Some(match (table, bare) { + (Some(table), Some(bare)) if !bare.is_empty() && !bare.contains('/') => format!("Nothing matched `{table}`: a table filter must name its lake, as `/{bare}`. Re-call with the lake before concluding anything about what is declared."), + _ => NO_DATA_METRICS_NOTE.to_string(), + }) + } else { + None + }; + let mut out = Map::new(); + out.insert("metrics".into(), metrics); + if let Some(note) = note { + out.insert("note".into(), note.into()); + } + Ok(pretty(&Value::Object(out))) +} + +async fn exec_datatable_sql(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = str_arg(args, "datatable_name")?; + let sql = str_arg(args, "sql")?; + let role = role_arg(args)?; + let failed = |e: String| format!("Failed to execute test run: {e}"); + + let database = match role { + None => format!("datatable://{name}"), + // A stored name holding a `?` is read whole by the server, so a role cannot follow it. + Some(role) if name.contains('?') => { + return Err(failed(format!("Data table '{name}' has a '?' in its name, so it can only be used here as its default role. Rename it to connect as role '{role}'."))); + } + Some(role) => format!("datatable://{name}?role={role}"), + }; + let id = run_script_preview( + ctx, + None, + "postgresql", + sql, + json!({ "database": database }), + ) + .await + .map_err(failed)?; + + match wait_job(ctx, &id, wait_budget(args), false).await { + Some(job) => Ok(format_datatable_sql_completion(&job, name)), + None => Ok(backgrounded_summary(&id, &format!("SQL · {name}"))), + } +} + +fn format_datatable_sql_completion(job: &Value, datatable_name: &str) -> String { + if job["success"] == true { + let rows = array(job.get("result")); + let row_count = rows.len(); + return pretty(&if row_count > MAX_ROWS { + json!({ + "success": true, + "rowCount": row_count, + "result": &rows[..MAX_ROWS], + "note": format!("Showing first {MAX_ROWS} of {row_count} rows"), + }) + } else { + json!({ "success": true, "rowCount": row_count, "result": rows }) + }); + } + let error = match job.get("result") { + None => json!("Unknown error"), + Some(result) => match result.pointer("/error/message") { + Some(message) if !message.is_null() => message.clone(), + _ => match result { + Value::String(_) => result.clone(), + other => Value::String(other.to_string()), + }, + }, + }; + let error = match error.as_str() { + Some(raw) if DATATABLE_NOT_FOUND.is_match(raw) => { + Value::String(datatable_not_configured_message(datatable_name)) + } + _ => error, + }; + json!({ "success": false, "error": error }).to_string() +} + +/// The optional `version` argument: a migration's timestamp. +fn version_arg(args: &Value) -> Result, String> { + match args.get("version") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_i64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)) + .map(Some) + .ok_or_else(|| "`version` must be an integer".to_string()), + } +} + +/// `workspaces//`, the shape of every migration route. +fn migrations_path(ctx: &Ctx<'_>, endpoint: &str, datatable_name: &str) -> String { + ctx.ws(&format!("workspaces/{endpoint}/{}", enc(datatable_name))) +} + +async fn migrations_status(ctx: &Ctx<'_>, datatable_name: &str) -> Result { + ctx.get(&migrations_path( + ctx, + "datatable_migrations_status", + datatable_name, + )) + .await + .map_err(|e| api_message(&e).to_string()) +} + +/// `POST` without a body: applying and rolling back take their target from the query. +async fn post_migrations(ctx: &Ctx<'_>, path: &str) -> Result { + let text = ctx + .request("POST", path, None) + .await + .map_err(|e| api_message(&e).to_string())?; + serde_json::from_str(&text).map_err(|e| format!("unexpected response: {e}")) +} + +fn migration_label(version: &Value, name: &Value) -> String { + format!("{} ({})", js_str(version), js_str(name)) +} + +fn labels(migrations: Option<&Value>) -> String { + array(migrations) + .iter() + .map(|m| migration_label(&m["version"], &m["name"])) + .collect::>() + .join(", ") +} + +fn migration_status(status: &Value) -> &'static str { + match status.as_str() { + Some("ran") => "applied", + Some("not_run") => "pending", + _ => "unknown", + } +} + +/// The rest of a leading comment line whose first word is `keyword`: an annotation attempt. +fn annotation_attempt<'a>(line: &'a str, keyword: &str) -> Option<&'a str> { + let body = line.strip_prefix("--")?.trim_start(); + let after = body + .get(..keyword.len()) + .filter(|w| w.eq_ignore_ascii_case(keyword)) + .map(|_| &body[keyword.len()..])?; + match after.chars().next() { + Some(c) if !(c.is_whitespace() || c == ':' || c == '=') => None, + _ => Some(after), + } +} + +/// The lines of `sql`'s leading comment block, where the server reads its annotations. +fn leading_block(sql: &str) -> impl Iterator { + sql.split('\n') + .map(str::trim) + .filter(|l| !l.is_empty()) + .take_while(|l| l.starts_with("--")) +} + +/// `sql` with every `keyword` attempt of its leading comment block removed, and `line`, if +/// any, on top. +fn with_annotation(sql: &str, keyword: &str, line: Option) -> String { + let lines: Vec<&str> = sql.split('\n').collect(); + let leading = lines + .iter() + .position(|l| !l.trim().is_empty() && !l.trim().starts_with("--")) + .unwrap_or(lines.len()); + let rest = lines[..leading] + .iter() + .filter(|l| annotation_attempt(l.trim(), keyword).is_none()) + .chain(&lines[leading..]) + .skip_while(|l| l.trim().is_empty()) + .map(|l| l.to_string()); + line.into_iter().chain(rest).collect::>().join("\n") +} + +/// `sql` declaring `role`: the server reads the role a migration runs as from a `-- role ` +/// annotation in its leading comment block, so any attempt there is replaced by one on top. +fn with_migration_role(sql: &str, role: Option<&str>) -> String { + match role { + Some(role) => with_annotation(sql, "role", Some(format!("-- role {role}"))), + None => sql.to_string(), + } +} + +fn is_annotatable_schema_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '-')) +} + +/// The scope `sql`'s `-- schemas a, b` annotation declares, lowercased, as the server's +/// `parse_migration_schemas` reads it. None for the whole database, which is also what a +/// malformed annotation stands for here. +fn declared_schemas(sql: &str) -> Option> { + let after = leading_block(sql).find_map(|l| annotation_attempt(l, "schemas"))?; + let rest = after.trim_start(); + let rest = rest.strip_prefix([':', '=']).unwrap_or(rest).trim(); + let rest = rest.strip_suffix(';').unwrap_or(rest); + let mut schemas: Vec = Vec::new(); + for name in rest.split(',').map(str::trim) { + if !is_annotatable_schema_name(name) { + return None; + } + let name = name.to_ascii_lowercase(); + if !schemas.contains(&name) { + schemas.push(name); + } + } + Some(schemas) +} + +/// Whether a listed migration's `schemas` overlaps `own`; an absent scope is the whole database. +fn scopes_overlap(theirs: &Value, own: Option<&[String]>) -> bool { + match (theirs.as_array(), own) { + (Some(theirs), Some(own)) => theirs + .iter() + .any(|s| own.iter().any(|o| s.as_str() == Some(o))), + _ => true, + } +} + +/// The optional `schemas` argument, held to the rule the server applies to a scope name. +fn schemas_arg(args: &Value) -> Result>, String> { + let Some(value) = non_null(args.get("schemas")) else { + return Ok(None); + }; + let names = value + .as_array() + .ok_or("`schemas` must be an array of schema names")?; + names + .iter() + .map(|name| match name.as_str() { + Some(name) if is_annotatable_schema_name(name) => Ok(name.to_ascii_lowercase()), + _ => Err(format!( + "Invalid schema name '{}' in `schemas`: only letters, digits, '_', '$' and '-' are allowed (1 to 63 characters)", + js_str(name) + )), + }) + .collect::>() + .map(Some) +} + +async fn list_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let version = version_arg(args)?; + let status = match migrations_status(ctx, datatable_name).await { + Ok(status) => status, + Err(e) => return Ok(format!("Error listing migrations: {e}")), + }; + if !truthy(status.get("enabled")) { + return Ok(format!("Migrations are not enabled on datatable \"{datatable_name}\": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables.")); + } + let migrations = array(status.get("migrations")); + let Some(version) = version else { + let mut out = Map::new(); + out.insert( + "migrations".into(), + migrations + .iter() + .map(|m| { + json!({ + "version": m["timestamp"], + "name": m["name"], + "status": migration_status(&m["status"]), + "schemas": non_null(m.get("schemas")).cloned().unwrap_or_else(|| json!("whole database")), + }) + }) + .collect(), + ); + if truthy(status.get("error")) { + out.insert("error".into(), status["error"].clone()); + } + return Ok(pretty(&Value::Object(out))); + }; + let Some(migration) = migrations + .iter() + .find(|m| m["timestamp"].as_i64() == Some(version)) + else { + return Ok(format!( + "No migration {version} on datatable \"{datatable_name}\"" + )); + }; + let mut out = Map::new(); + out.insert("version".into(), version.into()); + out.insert("name".into(), migration["name"].clone()); + out.insert( + "status".into(), + migration_status(&migration["status"]).into(), + ); + out.insert("code_up".into(), migration["code_up"].clone()); + if let Some(code_down) = non_null(migration.get("code_down")) { + out.insert("code_down".into(), code_down.clone()); + } + Ok(pretty(&Value::Object(out))) +} + +/// Saves the definition and answers its version, or the message the tool returns instead. +async fn save_migration( + ctx: &Ctx<'_>, + datatable_name: &str, + version: Option, + mut definition: Map, +) -> Result, String> { + let Some(version) = version else { + let created = ctx + .post( + &migrations_path(ctx, "create_datatable_migration", datatable_name), + Value::Object(definition), + ) + .await?; + return created["timestamp"] + .as_i64() + .map(Ok) + .ok_or_else(|| "the created migration has no version".to_string()); + }; + // The server inserts at a version it does not know, which would wedge a new migration + // into the middle of the history, and stores a down left out as none. + let status = migrations_status(ctx, datatable_name).await?; + let existing = array(status.get("migrations")) + .iter() + .find(|m| m["timestamp"].as_i64() == Some(version)); + let Some(existing) = existing.filter(|m| m["status"] == "not_run") else { + return Ok(Err(format!("Migration {version} is not a pending migration of datatable \"{datatable_name}\", so it cannot be rewritten. Omit `version` to add a new migration."))); + }; + definition.insert("timestamp".into(), version.into()); + if let Some(code_down) = non_null(existing.get("code_down")) { + definition + .entry("code_down") + .or_insert_with(|| code_down.clone()); + } + ctx.post( + &migrations_path(ctx, "upsert_datatable_migration", datatable_name), + Value::Object(definition), + ) + .await?; + Ok(Ok(version)) +} + +/// The labels of the migrations before `version`, in a scope overlapping `scope`, that are +/// not applied yet. +async fn earlier_pending( + ctx: &Ctx<'_>, + datatable_name: &str, + version: i64, + scope: Option<&[String]>, +) -> Result, String> { + let status = migrations_status(ctx, datatable_name).await?; + let earlier: Vec<&Value> = array(status.get("migrations")) + .iter() + .filter(|m| m["timestamp"].as_i64().is_some_and(|t| t < version)) + .filter(|m| scopes_overlap(&m["schemas"], scope)) + .collect(); + // Unreadable is not pending: reporting it so would send the user to apply migrations + // that already ran. + if truthy(status.get("error")) { + return Err(js_str(&status["error"])); + } + if earlier.iter().any(|m| m["status"] == "unknown") { + return Err("the applied migrations could not be read".to_string()); + } + Ok(earlier + .into_iter() + .filter(|m| m["status"] == "not_run") + .map(|m| migration_label(&m["timestamp"], &m["name"])) + .collect()) +} + +async fn create_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let name = str_arg(args, "name")?; + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid `name` '{name}': only letters, digits, '_' and '-' are allowed" + )); + } + let role = role_arg(args)?; + let version = version_arg(args)?; + let code_up = str_arg(args, "code_up")?; + // What the caller declares, else what `code_up` does; none is the whole database. + let scope = schemas_arg(args)? + .or_else(|| declared_schemas(code_up)) + .unwrap_or_default(); + let header = (!scope.is_empty()).then(|| format!("-- schemas {}", scope.join(", "))); + + let mut definition = Map::new(); + definition.insert("name".into(), name.into()); + definition.insert( + "code_up".into(), + with_annotation(&with_migration_role(code_up, role), "schemas", header).into(), + ); + if let Some(code_down) = opt_str_arg(args, "code_down") { + // A rollback has to run as the role that created what it drops. + definition.insert( + "code_down".into(), + with_migration_role(code_down, role).into(), + ); + } + let version = match save_migration(ctx, datatable_name, version, definition).await { + Ok(Ok(version)) => version, + Ok(Err(message)) => return Ok(message), + Err(e) => return Ok(format!("Error saving migration: {}", api_message(&e))), + }; + let label = format!("{version} ({name})"); + if args.get("apply") == Some(&Value::Bool(false)) { + return Ok(format!( + "Saved migration {label} as pending. It is not applied yet." + )); + } + + // Applying it alone would run it ahead of migrations it may depend on. + let own_scope = (!scope.is_empty()).then_some(scope.as_slice()); + let earlier = match earlier_pending(ctx, datatable_name, version, own_scope).await { + Ok(earlier) => earlier, + Err(e) => return Ok(format!("Saved migration {label} as pending, but could not check what else is pending, so it was not applied: {e}")), + }; + if !earlier.is_empty() { + return Ok(format!("Saved migration {label} as pending without applying it: earlier migrations are not applied yet ({}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.", earlier.join(", "))); + } + + let run = format!( + "{}?only={version}", + migrations_path(ctx, "run_datatable_migrations", datatable_name) + ); + Ok(match post_migrations(ctx, &run).await { + Ok(_) => format!("Applied migration {label} on datatable \"{datatable_name}\"."), + Err(e) => format!("Migration {label} is saved but failed to apply: {e}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version {version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables."), + }) +} + +async fn run_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let path = migrations_path(ctx, "run_datatable_migrations", datatable_name); + Ok(match post_migrations(ctx, &path).await { + Ok(result) => { + let applied = array(result.get("applied")); + if applied.is_empty() { + format!("No pending migration to apply on datatable \"{datatable_name}\".") + } else { + format!( + "Applied {} migration(s) on datatable \"{datatable_name}\": {}.", + applied.len(), + labels(result.get("applied")) + ) + } + } + Err(e) => format!("Error applying migrations: {e}. Migrations before the failing one may have been applied: list them to see."), + }) +} + +async fn rollback_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let version = version_arg(args)?.ok_or("Missing required number argument `version`")?; + let path = format!( + "{}?only={version}", + migrations_path(ctx, "rollback_datatable_migrations", datatable_name) + ); + Ok(match post_migrations(ctx, &path).await { + Ok(result) if array(result.get("rolled_back")).is_empty() => format!("Migration {version} is not applied on datatable \"{datatable_name}\", so there is nothing to roll back."), + Ok(result) => format!( + "Rolled back migration {} on datatable \"{datatable_name}\". It is pending again.", + labels(result.get("rolled_back")) + ), + Err(e) => format!("Error rolling back: {e}"), + }) +} + +const POSTGRESQL_SCHEMA_QUERY: &str = " +SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns +RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') +AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')"; +const MYSQL_SCHEMA_QUERY: &str = "SELECT DATABASE() AS default_db_name, TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.columns WHERE table_schema = DATABASE() OR table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', '_vt');"; +const SNOWFLAKE_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE from information_schema.columns where table_schema != 'INFORMATION_SCHEMA'"; +const MSSQL_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'"; +const BIGQUERY_SCHEMA_SCRIPT: &str = r#"import { BigQuery } from '@google-cloud/bigquery@7.5.0'; +export async function main(args: bigquery) { +const bq = new BigQuery({ + credentials: args +}) +const [datasets] = await bq.getDatasets(); +if (!datasets) return {} +const schema = {} as any +let queries = datasets.map(dataset => ` + SELECT + table_name, + '${dataset.id}' as dataset, + ARRAY_AGG(STRUCT( + if(is_nullable = 'YES', true, false) AS required, + column_name AS name, + data_type AS type, + if(column_default = 'NULL', null, column_default) AS \`default\` + ) ORDER BY ordinal_position) + AS schema + FROM \`${dataset.id}\`.INFORMATION_SCHEMA.COLUMNS + GROUP BY table_name` +) +let query = queries.join('\nUNION ALL \n') +const [rows] = await bq.query(query) +for (const row of rows) { + schema[row.dataset] ??= {} + schema[row.dataset][row.table_name] = {} + for (const col of row.schema) { + const colName = col.name + delete col.name + if (col.default === null) { + delete col.default + } + schema[row.dataset][row.table_name][colName] = col + } +} +return schema +}"#; + +/// How long a schema introspection is awaited. +const DB_SCHEMA_WAIT_SECS: u64 = 120; + +async fn get_db_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = opt_str_arg(args, "resourcePath") + .filter(|p| !p.is_empty()) + .ok_or("Database path not provided")?; + let resource = ctx + .get(&ctx.ws(&format!("resources/get/{}", path_enc(path)))) + .await?; + let resource_type = resource["resource_type"].as_str().unwrap_or_default(); + let not_found = || "Database not found".to_string(); + let (language, content, arg_name) = match resource_type { + "postgresql" => ("postgresql", POSTGRESQL_SCHEMA_QUERY, "database"), + "mysql" => ("mysql", MYSQL_SCHEMA_QUERY, "database"), + "ms_sql_server" | "mssql" => ("mssql", MSSQL_SCHEMA_QUERY, "database"), + "snowflake" | "snowflake_oauth" => ("snowflake", SNOWFLAKE_SCHEMA_QUERY, "database"), + "bigquery" => ("bun", BIGQUERY_SCHEMA_SCRIPT, "args"), + _ => return Err(not_found()), + }; + let id = run_script_preview( + ctx, + None, + language, + content, + json!({ arg_name: format!("$res:{path}") }), + ) + .await + .map_err(|_| not_found())?; + let job = wait_job(ctx, &id, DB_SCHEMA_WAIT_SECS, false) + .await + .ok_or_else(|| { + format!( + "The schema query did not finish within {DB_SCHEMA_WAIT_SECS} seconds (job {id})" + ) + })?; + if job["success"] != true { + return Err(not_found()); + } + let mut stringified = + stringify_db_schema(resource_type, &job["result"]).ok_or_else(not_found)?; + if stringified.chars().count() > MAX_SCHEMA_LENGTH { + stringified = stringified + .chars() + .take(MAX_SCHEMA_LENGTH) + .collect::() + + "..."; + } + Ok(format!( + "DATABASE SCHEMA (each column is in the format [name, type, required, default?]):\n{stringified}" + )) +} + +/// The object under `key`, created empty where a JavaScript object would first get it. +fn slot(map: &mut Map, key: String) -> &mut Map { + map.entry(key) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("only objects are inserted") +} + +/// The introspection result as `{schema: {table: [[name, type, required, default?]]}}`, +/// narrowed to the default schema's tables when the database has one. +fn stringify_db_schema(resource_type: &str, result: &Value) -> Option { + let mut schemas: Map = Map::new(); + let column = |name: String, ty: &Value, required: Value, default: Option<&Value>| { + let mut col = vec![Value::String(name), ty.clone(), required]; + if truthy(default) { + col.extend(default.cloned()); + } + Value::Array(col) + }; + if resource_type == "bigquery" { + for (dataset, tables) in result.as_object()? { + let dataset = slot(&mut schemas, dataset.clone()); + for (table, columns) in tables.as_object()? { + let table = slot(dataset, table.clone()); + for (name, col) in columns.as_object()? { + let entry = column( + name.clone(), + &col["type"], + col["required"].clone(), + col.get("default"), + ); + table.insert(name.clone(), entry); + } + } + } + } else { + for row in result.as_array()? { + let (schema, table, name, ty, required, default) = match resource_type { + "postgresql" => ( + "table_schema", + "table_name", + "column_name", + "udt_name", + row["is_nullable"] == "NO", + row.get("column_default"), + ), + // `required` reads IS_NULLABLE the way the session does: "YES" is true. + "snowflake" | "snowflake_oauth" => ( + "TABLE_SCHEMA", + "TABLE_NAME", + "COLUMN_NAME", + "DATA_TYPE", + row["IS_NULLABLE"] == "YES", + row.get("COLUMN_DEFAULT"), + ), + // These queries select neither nullability nor a default the session reads. + _ => ( + "TABLE_SCHEMA", + "TABLE_NAME", + "COLUMN_NAME", + "DATA_TYPE", + false, + None, + ), + }; + let schema = slot(&mut schemas, js_str(&row[schema])); + // A schema with no table comes back as one row of nulls. + if resource_type == "postgresql" && !truthy(row.get(table)) && !truthy(row.get(name)) { + continue; + } + let name = js_str(&row[name]); + let entry = column(name.clone(), &row[ty], json!(required), default); + slot(schema, js_str(&row[table])).insert(name, entry); + } + } + + for table in schemas.values_mut().filter_map(Value::as_object_mut) { + for columns in table.values_mut() { + if let Some(by_name) = columns.as_object_mut() { + *columns = Value::Array( + std::mem::take(by_name) + .into_iter() + .map(|(_, c)| c) + .collect(), + ); + } + } + } + let default_schema = ["public", "PUBLIC", "dbo"] + .into_iter() + .find(|k| schemas.contains_key(*k)) + .or_else(|| { + (resource_type == "mysql" && schemas.len() == 1) + .then(|| schemas.keys().next().map(String::as_str)) + .flatten() + }) + .map(str::to_string); + Some(match default_schema { + Some(key) => schemas[&key].to_string(), + None => Value::Object(schemas).to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn postgres_schema_is_compact_and_narrowed_to_public() { + let rows = json!([ + { "table_schema": "empty", "table_name": null, "column_name": null }, + { "table_schema": "public", "table_name": "t", "column_name": "id", "udt_name": "int4", "is_nullable": "NO", "column_default": "nextval('s')" }, + { "table_schema": "public", "table_name": "t", "column_name": "name", "udt_name": "text", "is_nullable": "YES", "column_default": null }, + { "table_schema": "other", "table_name": "u", "column_name": "x", "udt_name": "bool", "is_nullable": "YES", "column_default": "" }, + ]); + assert_eq!( + stringify_db_schema("postgresql", &rows).unwrap(), + r#"{"t":[["id","int4",true,"nextval('s')"],["name","text",false]]}"# + ); + let rows = json!([rows[0], rows[3]]); + assert_eq!( + stringify_db_schema("postgresql", &rows).unwrap(), + r#"{"empty":{},"other":{"u":[["x","bool",false]]}}"# + ); + assert_eq!(stringify_db_schema("postgresql", &json!({})), None); + } + + #[test] + fn migration_role_replaces_a_leading_annotation_only() { + assert_eq!(with_migration_role("BEGIN;\nEND;", None), "BEGIN;\nEND;"); + assert_eq!( + with_migration_role( + "\n-- note\n--ROLE: old;\n-- roles kept\nBEGIN;\n-- role below\nEND;", + Some("app_rw") + ), + "-- role app_rw\n-- note\n-- roles kept\nBEGIN;\n-- role below\nEND;" + ); + } + + #[test] + fn migration_scope_is_read_from_the_leading_block_and_overlaps() { + let sql = "-- role r\n--SCHEMAS: Sales, billing, sales;\nBEGIN;\n-- schemas other\nEND;"; + let scope = declared_schemas(sql).unwrap(); + assert_eq!(scope, ["sales", "billing"]); + assert_eq!(declared_schemas("BEGIN;\n-- schemas a\nEND;"), None); + assert_eq!(declared_schemas("-- schemas a b\nBEGIN;"), None); + assert_eq!(declared_schemas("-- schemasx a\nBEGIN;"), None); + assert_eq!( + with_annotation( + &with_migration_role(sql, Some("w")), + "schemas", + Some("-- schemas sales".into()) + ), + "-- schemas sales\n-- role w\nBEGIN;\n-- schemas other\nEND;" + ); + assert_eq!( + with_annotation(sql, "schemas", None), + "-- role r\nBEGIN;\n-- schemas other\nEND;" + ); + + assert!(scopes_overlap(&json!(["billing", "x"]), Some(&scope))); + assert!(!scopes_overlap(&json!(["x"]), Some(&scope))); + assert!(scopes_overlap(&Value::Null, Some(&scope))); + assert!(scopes_overlap(&json!(["x"]), None)); + } + + #[test] + fn sql_completion_caps_rows_and_rewrites_the_missing_datatable_error() { + let rows: Vec = (0..101).map(|i| json!({ "i": i })).collect(); + let out: Value = serde_json::from_str(&format_datatable_sql_completion( + &json!({ "success": true, "result": rows }), + "main", + )) + .unwrap(); + assert_eq!(out["rowCount"], 101); + assert_eq!(out["result"].as_array().unwrap().len(), 100); + assert_eq!(out["note"], "Showing first 100 of 101 rows"); + + assert_eq!( + format_datatable_sql_completion(&json!({ "success": true, "result": [] }), "main"), + "{\n \"success\": true,\n \"rowCount\": 0,\n \"result\": []\n}" + ); + assert_eq!( + format_datatable_sql_completion( + &json!({ "success": false, "result": { "error": { "message": "boom" } } }), + "main" + ), + r#"{"success":false,"error":"boom"}"# + ); + let missing = format_datatable_sql_completion( + &json!({ "success": false, "result": { "error": { "message": "datatable main not found" } } }), + "main", + ); + assert!(missing.contains("Datatable \\\"main\\\" is not configured")); + } +} diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs new file mode 100644 index 0000000000..e508a16174 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/deploy.rs @@ -0,0 +1,2121 @@ +//! Deploying and deleting workspace items, and diffing drafts against what is deployed. + +use std::sync::LazyLock; + +use futures::{stream, StreamExt}; +use regex::Regex; +use serde_json::{json, Map, Value}; + +use super::flows::drop_stale_step_locks; +use super::workspace::{ + api_message, app_source_to_draft_value, delete_draft, deployed_item_exists, draft_kind, + get_deployed, get_draft_item, item_exists, item_type_arg, path_enc, service, unified_patch, + ITEM_TYPES, TRIGGER_KINDS, +}; +use super::{ + array, bool_arg, is_not_found, js_str, matches_glob, non_null, opt_str_arg, pretty, str_arg, + truthy, Ctx, ToolResult, +}; + +pub(super) const TOOLS: &[&str] = &["deploy_workspace_item", "delete_workspace_item", "diff"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "deploy_workspace_item" => deploy_item(ctx, args).await, + "delete_workspace_item" => delete_item(ctx, args).await, + "diff" => diff(ctx, args).await, + _ => unreachable!("{name} is not a deploy tool"), + } +} + +/// An object without `keys`; `{}` for anything that is not an object. +fn without(value: &Value, keys: &[&str]) -> Value { + let mut out = value.as_object().cloned().unwrap_or_default(); + for key in keys { + out.shift_remove(*key); + } + Value::Object(out) +} + +fn uint_arg(args: &Value, name: &str) -> Option { + args.get(name) + .and_then(Value::as_u64) + .map(|n| usize::try_from(n).unwrap_or(usize::MAX)) +} + +// ============================================================================ +// deploy_workspace_item +// ============================================================================ + +fn assert_draft_based_on_latest( + item_type: &str, + path: &str, + base: Option<&Value>, + head: Option<&Value>, + force: bool, +) -> Result<(), String> { + let (Some(base), Some(head)) = (base.filter(|v| !v.is_null()), head.filter(|v| !v.is_null())) + else { + return Ok(()); + }; + if force || base == head { + return Ok(()); + } + Err(format!( + "This {item_type} draft \"{path}\" was started from an older deployed version (forked from {}, \ + latest is {}). Deploying now would overwrite the version deployed since. Call rebase_draft to \ + discard the stale draft and get your changes back as a diff, then re-apply them (the new draft \ + re-bases onto the latest version) and deploy. To deploy as-is and replace the newer version, call \ + deploy_workspace_item again with force: true.", + js_str(base), + js_str(head) + )) +} + +/// An editor draft of a script or flow can stage trigger edits, which the editors deploy +/// through per-kind forms. Deploying the item alone deletes the draft and those edits +/// with it, so it is refused instead. +fn refuse_staged_triggers(item_type: &str, path: &str, draft: &Value) -> Result<(), String> { + let staged = draft + .get("draft_triggers") + .and_then(Value::as_array) + .is_some_and(|triggers| triggers.iter().any(|t| truthy(t.get("draftConfig")))); + if staged { + return Err(format!( + "Draft {item_type} \"{path}\" also stages trigger changes made in the Windmill editor, which \ + cannot be deployed from here. Deploy it from the editor so they are not lost." + )); + } + Ok(()) +} + +async fn deploy_script(ctx: &Ctx<'_>, path: &str, message: Option<&str>) -> Result<(), String> { + let body_of = |e: String| api_message(&e).to_string(); + let row = ctx + .get(&ctx.ws(&format!("scripts/get/p/{}?get_draft=true", path_enc(path)))) + .await + .map_err(body_of)?; + let draft = non_null(row.get("draft")).unwrap_or(&row); + refuse_staged_triggers("script", path, draft)?; + let mut body = without( + draft, + &[ + "draft_triggers", + "draft_only", + "parent_hash", + "deployment_message", + "preserve_on_behalf_of", + // A draft seeded from the deployed script carries its lock, and the server skips + // the dependency job whenever one is set. + "lock", + "lock_error_logs", + ], + ); + body["path"] = non_null(draft.get("path")).cloned().unwrap_or(json!(path)); + if let Some(hash) = row.get("hash") { + body["parent_hash"] = hash.clone(); + } + if let Some(message) = message { + body["deployment_message"] = json!(message); + } + // Without the flag the server resets the identity to the deploying user. + if truthy(draft.get("on_behalf_of_email")) { + body["preserve_on_behalf_of"] = json!(true); + } + ctx.post(&ctx.ws("scripts/create"), body) + .await + .map_err(body_of)?; + Ok(()) +} + +async fn deploy_flow( + ctx: &Ctx<'_>, + path: &str, + draft_only: bool, + message: Option<&str>, +) -> Result<(), String> { + let body_of = |e: String| api_message(&e).to_string(); + let row = ctx + .get(&ctx.ws(&format!("flows/get/{}?get_draft=true", path_enc(path)))) + .await + .map_err(body_of)?; + let draft = non_null(row.get("draft")).unwrap_or(&row); + refuse_staged_triggers("flow", path, draft)?; + let or_empty = |key: &str| non_null(draft.get(key)).cloned().unwrap_or(json!("")); + let mut body = Map::new(); + body.insert( + "path".into(), + non_null(draft.get("draft_path")) + .or_else(|| non_null(draft.get("path"))) + .cloned() + .unwrap_or(json!(path)), + ); + body.insert("summary".into(), or_empty("summary")); + body.insert("description".into(), or_empty("description")); + for key in [ + "value", + "schema", + "tag", + "dedicated_worker", + "ws_error_handler_muted", + "visible_to_runner_only", + "on_behalf_of_email", + "on_behalf_of", + "labels", + "job_token_scopes", + ] { + if let Some(value) = draft.get(key) { + body.insert(key.into(), value.clone()); + } + } + if let Some(Value::Object(value)) = body.get_mut("value") { + // With nothing deployed the server fills `value` from the draft itself. + let deployed = row + .get("value") + .filter(|_| !draft_only && !truthy(row.get("no_deployed"))); + drop_stale_step_locks(value, deployed); + } + if truthy(draft.get("on_behalf_of_email")) { + body.insert("preserve_on_behalf_of".into(), json!(true)); + } + if let Some(message) = message { + body.insert("deployment_message".into(), json!(message)); + } + // A draft-only flow has no flow row to update. + let route = if draft_only { + "flows/create".to_string() + } else { + format!("flows/update/{}", path_enc(path)) + }; + ctx.post(&ctx.ws(&route), Value::Object(body)) + .await + .map_err(body_of)?; + Ok(()) +} + +async fn upsert( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, + body: Value, +) -> Result<(), String> { + let service = service(item_type, trigger_kind)?; + let route = if item_exists(ctx, item_type, trigger_kind, path).await? { + format!("{service}/update/{}", path_enc(path)) + } else { + format!("{service}/create") + }; + ctx.post(&ctx.ws(&route), body).await?; + Ok(()) +} + +/// An agent's draft never carries the identity it runs as, and without the flag the server +/// resets it to the deploying user, so an update restates the deployed one. +async fn deploy_resource(ctx: &Ctx<'_>, path: &str, draft: &Value) -> Result<(), String> { + if !item_exists(ctx, "resource", None, path).await? { + ctx.post(&ctx.ws("resources/create"), draft.clone()).await?; + return Ok(()); + } + let deployed = get_deployed(ctx, "resource", None, path).await?; + let mut body = draft.clone(); + let agent_obo = deployed + .get("value") + .and_then(|value| value.get("on_behalf_of")) + .filter(|obo| { + deployed.get("resource_type").and_then(Value::as_str) == Some("ai_agent") + && truthy(Some(obo)) + }); + if let (Some(obo), Some(body)) = (agent_obo, body.as_object_mut()) { + let mut value = body + .get("value") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + value.insert("on_behalf_of".into(), obo.clone()); + body.insert("value".into(), Value::Object(value)); + body.insert("preserve_on_behalf_of".into(), json!(true)); + } + ctx.post( + &ctx.ws(&format!("resources/update/{}", path_enc(path))), + body, + ) + .await?; + Ok(()) +} + +/// Deploys a variable draft; the note says when a secret's stored value was left alone. +async fn deploy_variable( + ctx: &Ctx<'_>, + path: &str, + draft: &Value, +) -> Result, String> { + let is_secret = truthy(draft.get("is_secret")); + let stages_no_value = draft.get("value").and_then(Value::as_str) == Some(""); + if !item_exists(ctx, "variable", None, path).await? { + if is_secret && stages_no_value { + return Err(format!( + "Draft variable \"{}\" is secret but stages no value, so it cannot be created. Run write_variable with its value first.", + draft.get("path").map(js_str).unwrap_or_default() + )); + } + ctx.post(&ctx.ws("variables/create"), draft.clone()).await?; + return Ok(None); + } + // A draft never carries a secret's or an OAuth-managed value, so `''` there means + // "none staged": sending it would blank the stored value or wipe a rotating token. + let protected = is_secret + || draft.get("is_oauth") == Some(&Value::Bool(true)) + || non_null(draft.get("account")).is_some(); + let keep_stored = protected && stages_no_value; + let body = if keep_stored { + without(draft, &["value"]) + } else { + draft.clone() + }; + ctx.post( + &ctx.ws(&format!("variables/update/{}", path_enc(path))), + body, + ) + .await?; + Ok((is_secret && keep_stored).then(|| { + "Its secret value was left unchanged, because the draft staged only metadata. If a new value was meant to be set, run write_variable again with it.".to_string() + })) +} + +/// A path runnable runs the DEPLOYED item at its path, so one pointing at a draft-only +/// script or flow fails at runtime. +async fn undeployed_runnable_targets(ctx: &Ctx<'_>, runnables: &Map) -> Vec { + let mut targets = Vec::new(); + for (key, runnable) in runnables { + let kind = runnable.get("type").and_then(Value::as_str); + let target_type = if matches!(kind, Some("path" | "runnableByPath")) { + runnable.get("runType").and_then(Value::as_str) + } else { + kind + }; + let (Some(target_type @ ("script" | "flow")), Some(path)) = ( + target_type, + runnable + .get("path") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()), + ) else { + continue; + }; + // Advisory: a failed probe must not block the deploy it annotates. + if let Ok(false) = deployed_item_exists(ctx, target_type, None, path).await { + targets.push(format!("\"{key}\" → {target_type} \"{path}\"")); + } + } + targets +} + +async fn guest_access_is_live(ctx: &Ctx<'_>) -> bool { + let (Ok(usage), Ok(settings)) = ( + ctx.get(&ctx.ws("workspaces/guest_usage")).await, + ctx.get(&ctx.ws("workspaces/get_public_settings")).await, + ) else { + return false; + }; + truthy(usage.get("available")) + && truthy(usage.get("instance_enabled")) + && truthy(settings.get("guest_access_enabled")) +} + +/// Deploys a raw app draft from its sources: the server bundles them on a worker and +/// derives the policy's triggerables from the runnables. Returns the note for the result. +async fn deploy_app( + ctx: &Ctx<'_>, + path: &str, + item: &Value, + draft: &Value, + message: Option<&str>, + force: bool, +) -> Result, String> { + if let Some(base) = non_null(item.get("parentVersionId")) { + let head = if item_exists(ctx, "app", None, path).await? { + get_deployed(ctx, "app", None, path) + .await? + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .cloned() + } else { + None + }; + assert_draft_based_on_latest("app", path, Some(base), head.as_ref(), force)?; + } + let object_or_empty = |key: &str| { + draft + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + let runnables = object_or_empty("runnables"); + let mut policy = object_or_empty("policy"); + if !truthy(policy.get("execution_mode")) { + policy.insert("execution_mode".into(), json!("publisher")); + } + + let mut notes = Vec::new(); + let undeployed = undeployed_runnable_targets(ctx, &runnables).await; + if !undeployed.is_empty() { + notes.push(format!( + "These backend runnables point at items that are NOT deployed, so they fail at runtime: \ + {}. Deploy those items too, and tell the user the app is not working until they are.", + undeployed.join(", ") + )); + } + // What a runnable runs as is the server's to decide, so the note names who can + // reach the app and never an identity. + match policy.get("execution_mode").and_then(Value::as_str) { + Some("anonymous") => notes.push( + "This app is deployed as anonymous: its backend runnables are now reachable by anyone \ + with the URL, without logging in. Tell the user plainly what is now reachable and by whom." + .to_string(), + ), + // `guest` is stored whether or not a guest is admitted: silent unless it is live. + Some("guest") if guest_access_is_live(ctx).await => notes.push( + "This app is deployed as guest: anyone the instance's identity provider authenticates \ + can now open it and run its backend runnables, member of this workspace or not. Tell \ + the user plainly what is now reachable and by whom." + .to_string(), + ), + _ => {} + } + + // An editor-created draft is parked at a synthetic storage key, its chosen path in + // the stored draft. Only a missing item justifies falling back to the storage path. + let target = match ctx + .get(&ctx.ws(&format!( + "apps/get/p/{}?get_draft=true&raw_app=true", + path_enc(path) + ))) + .await + { + Ok(row) => non_null(row.get("draft")) + .and_then(|d| non_null(d.get("draft_path")).or_else(|| non_null(d.get("path")))) + .or_else(|| non_null(row.get("draft_path"))) + .and_then(Value::as_str) + .unwrap_or(path) + .to_string(), + Err(e) if is_not_found(&e) => path.to_string(), + Err(e) => return Err(e), + }; + + let mut app = Map::new(); + app.insert("path".into(), json!(target)); + app.insert( + "value".into(), + json!({ + "files": object_or_empty("files"), + "runnables": runnables, + "data": non_null(draft.get("data")).cloned().unwrap_or_else(|| json!({ "tables": [] })), + }), + ); + app.insert( + "summary".into(), + non_null(draft.get("summary")) + .or_else(|| non_null(item.get("summary"))) + .cloned() + .unwrap_or(json!("")), + ); + if truthy(policy.get("on_behalf_of")) { + app.insert("preserve_on_behalf_of".into(), json!(true)); + } + app.insert("policy".into(), Value::Object(policy)); + if let Some(message) = message { + app.insert("deployment_message".into(), json!(message)); + } + if item_exists(ctx, "app", None, &target).await? { + // `custom_path` stays out of an update: the server keeps it when absent, and + // sending it takes admin rights. + ctx.post( + &ctx.ws(&format!("apps/update_raw_source/{}", path_enc(&target))), + Value::Object(app), + ) + .await?; + } else { + if let Some(custom_path) = draft.get("custom_path") { + app.insert("custom_path".into(), custom_path.clone()); + } + ctx.post(&ctx.ws("apps/create_raw_source"), Value::Object(app)) + .await?; + } + Ok((!notes.is_empty()).then(|| notes.join(" "))) +} + +fn mutation_result( + message: String, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, + url: Option, +) -> String { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("message".into(), json!(message)); + out.insert("type".into(), json!(item_type)); + out.insert("path".into(), json!(path)); + if let Some(trigger_kind) = trigger_kind { + out.insert("triggerKind".into(), json!(trigger_kind)); + } + if let Some(url) = url { + out.insert("url".into(), json!(url)); + } + pretty(&Value::Object(out)) +} + +/// Where a deployed item is seen in Windmill. +fn deployed_page(item_type: &str, path: &str, value: &Value) -> Option { + Some(match item_type { + "script" => format!("/scripts/get/{path}"), + "flow" => format!("/flows/get/{path}"), + "app" => format!("/apps_raw/get/{path}"), + "variable" => format!("/variables#{path}"), + "resource" if super::workspace::is_agent(value) => format!("/agents/get/{path}"), + "resource" => format!("/resources#/resource/{path}"), + "schedule" => format!("/schedules#{path}"), + _ => return None, + }) +} + +async fn deploy_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + let message = opt_str_arg(args, "deployment_message"); + let force = bool_arg(args, "force"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when deploying a trigger.".to_string()); + } + let no_draft = || format!("No draft found for {item_type} \"{path}\"."); + let kind = draft_kind(item_type, trigger_kind).ok_or_else(no_draft)?; + let (item, draft) = get_draft_item(ctx, item_type, path, trigger_kind) + .await? + .ok_or_else(no_draft)?; + let value = item + .get("value") + .ok_or_else(|| format!("Draft {item_type} \"{path}\" has no value to deploy."))?; + + let mut note = None; + match item_type { + "script" => { + let head = if item_exists(ctx, "script", None, path).await? { + get_deployed(ctx, "script", None, path) + .await? + .get("hash") + .cloned() + } else { + None + }; + assert_draft_based_on_latest( + "script", + path, + item.get("parentHash"), + head.as_ref(), + force, + )?; + deploy_script(ctx, path, message).await?; + } + "flow" => { + let exists = item_exists(ctx, "flow", None, path).await?; + let head = if exists { + get_deployed(ctx, "flow", None, path) + .await? + .get("version_id") + .cloned() + } else { + None + }; + assert_draft_based_on_latest( + "flow", + path, + item.get("parentVersionId"), + head.as_ref(), + force, + )?; + deploy_flow(ctx, path, !exists, message).await?; + } + "variable" => note = deploy_variable(ctx, path, value).await?, + "resource" => deploy_resource(ctx, path, value).await?, + "app" => note = deploy_app(ctx, path, &item, value, message, force).await?, + _ => upsert(ctx, item_type, trigger_kind, path, value.clone()).await?, + } + + // The baseline keeps a draft saved since the read above from being deleted undeployed. + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + + let note = note.map(|note| format!(" {note}")).unwrap_or_default(); + Ok(mutation_result( + format!("Deployed draft {item_type} \"{path}\" to the workspace. Draft removed.{note}"), + item_type, + path, + trigger_kind, + deployed_page(item_type, path, value).map(|page| ctx.page_url(&page)), + )) +} + +// ============================================================================ +// delete_workspace_item +// ============================================================================ + +async fn delete_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when deleting a trigger.".to_string()); + } + // Undoing a draft is a discard, not a delete: say so before the delete route 404s. + if !deployed_item_exists(ctx, item_type, trigger_kind, path).await? { + let draft = get_draft_item(ctx, item_type, path, trigger_kind).await?; + return Err(if draft.is_some() { + format!( + "No deployed {item_type} at \"{path}\" — it only exists as a draft, so there is nothing to delete \ + from the workspace. Call discard_local_draft with the same arguments to remove the draft." + ) + } else { + format!("No {item_type} at \"{path}\": neither a deployed item nor a draft. Nothing to delete.") + }); + } + + let path_in_url = path_enc(path); + if item_type == "script" { + ctx.request( + "POST", + &ctx.ws(&format!("scripts/delete/p/{path_in_url}")), + None, + ) + .await?; + } else { + ctx.delete(&ctx.ws(&format!( + "{}/delete/{path_in_url}", + service(item_type, trigger_kind)? + ))) + .await?; + } + if let Some(kind) = draft_kind(item_type, trigger_kind) { + delete_draft(ctx, &kind, path, None).await?; + } + Ok(mutation_result( + format!( + "Deleted {item_type} \"{path}\" from the workspace. Any matching draft was also cleared." + ), + item_type, + path, + trigger_kind, + None, + )) +} + +// ============================================================================ +// Stable YAML: what the session's `yaml` library prints for +// `stringify(value, { sortMapEntries: true, aliasDuplicateObjects: false, lineWidth: 0 })`, +// so a diff reads the same here as in a session. +// ============================================================================ + +static YAML_CONTROL_CHARS: LazyLock = + LazyLock::new(|| Regex::new(r"[\x00-\x08\x0b-\x1f\x7f-\u{9f}]").expect("valid regex")); +static YAML_NOT_PLAIN: LazyLock = LazyLock::new(|| { + Regex::new( + r#"^[\n\t ,\[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$"#, + ) + .expect("valid regex") +}); +/// Plain scalars the core schema would read back as null, a boolean or a number. +static YAML_NOT_A_STRING: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r"^(?:~|[Nn]ull|NULL)?$", + r"|^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$", + r"|^0o[0-7]+$|^[-+]?[0-9]+$|^0x[0-9a-fA-F]+$", + r"|^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$", + r"|^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+$", + r"|^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$", + )) + .expect("valid regex") +}); +static YAML_DOCUMENT_MARKER: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^(%|---|\.\.\.)").expect("valid regex")); + +/// Null object properties are dropped so `field: null` never diffs against an absent +/// field; array elements are kept so positions stay aligned. +fn prune_nulls(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(prune_nulls).collect()), + Value::Object(map) => Value::Object( + map.iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| (k.clone(), prune_nulls(v))) + .collect(), + ), + other => other.clone(), + } +} + +fn to_stable_yaml(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(value) => yaml_node(&prune_nulls(value), "") + "\n", + } +} + +fn yaml_node(value: &Value, indent: &str) -> String { + let nested = format!("{indent} "); + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => yaml_number(n), + Value::String(s) => yaml_string(s, indent, false), + Value::Array(items) if items.is_empty() => "[]".to_string(), + Value::Object(map) if map.is_empty() => "{}".to_string(), + Value::Array(items) => items + .iter() + .map(|item| format!("- {}", yaml_node(item, &nested))) + .collect::>() + .join(&format!("\n{indent}")), + Value::Object(map) => { + let mut entries: Vec<(&String, &Value)> = map.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + entries + .into_iter() + .map(|(key, value)| { + let key = yaml_string(key, &nested, true); + let rendered = yaml_node(value, &nested); + let is_block = match value { + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + _ => false, + }; + if is_block { + format!("{key}:\n{nested}{rendered}") + } else { + format!("{key}: {rendered}") + } + }) + .collect::>() + .join(&format!("\n{indent}")) + } + } +} + +// ponytail: prints what Rust prints for a float. JS switches to exponent notation +// below 1e-6 and from 1e21, and rounds integers past 2^53; port Number#toString if a +// diff of such numbers ever has to match a session's byte for byte. +fn yaml_number(n: &serde_json::Number) -> String { + match n.as_f64() { + Some(f) if !n.is_i64() && !n.is_u64() && f.fract() == 0.0 && f.abs() < 1e21 => { + format!("{f:.0}") + } + _ => n.to_string(), + } +} + +fn yaml_string(value: &str, indent: &str, implicit_key: bool) -> String { + if YAML_CONTROL_CHARS.is_match(value) { + return yaml_double_quoted(value, indent, implicit_key); + } + let multiline = value.contains('\n'); + if implicit_key && multiline { + return yaml_quoted(value, indent, implicit_key); + } + if multiline { + return yaml_block(value, indent); + } + let top_level_key = implicit_key && indent.len() == 2; + if YAML_NOT_PLAIN.is_match(value) + || YAML_NOT_A_STRING.is_match(value) + || (top_level_key && YAML_DOCUMENT_MARKER.is_match(value)) + { + return yaml_quoted(value, indent, implicit_key); + } + value.to_string() +} + +fn yaml_quoted(value: &str, indent: &str, implicit_key: bool) -> String { + let single = value.contains('"') && !value.contains('\'') && !value.contains('\n'); + if single { + format!("'{}'", value.replace('\'', "''")) + } else { + yaml_double_quoted(value, indent, implicit_key) + } +} + +/// Lines of a double-quoted scalar shorter than this stay on one line, newlines escaped. +const YAML_MIN_MULTILINE_LENGTH: usize = 40; + +fn yaml_double_quoted(value: &str, indent: &str, implicit_key: bool) -> String { + let json: Vec = serde_json::to_string(value) + .unwrap_or_default() + .chars() + .collect(); + let at = |i: usize| json.get(i).copied(); + let mut out = String::new(); + let mut start = 0; + let mut i = 0; + while i < json.len() { + let mut ch = json[i]; + // A space before a line break would be folded away unless escaped. + if ch == ' ' && at(i + 1) == Some('\\') && at(i + 2) == Some('n') { + out.extend(&json[start..i]); + out.push_str("\\ "); + i += 1; + start = i; + ch = '\\'; + } + if ch == '\\' { + match at(i + 1) { + Some('u') => { + out.extend(&json[start..i]); + let code: String = json[i + 2..(i + 6).min(json.len())].iter().collect(); + match code.as_str() { + "0000" => out.push_str("\\0"), + "0007" => out.push_str("\\a"), + "000b" => out.push_str("\\v"), + "001b" => out.push_str("\\e"), + _ if code.starts_with("00") => out.push_str(&format!("\\x{}", &code[2..])), + _ => out.push_str(&format!("\\u{code}")), + } + i += 5; + start = i + 1; + } + Some('n') => { + if implicit_key + || at(i + 2) == Some('"') + || json.len() < YAML_MIN_MULTILINE_LENGTH + { + i += 1; + } else { + // A real line break; the reader folds the first one away. + out.extend(&json[start..i]); + out.push_str("\n\n"); + while at(i + 2) == Some('\\') + && at(i + 3) == Some('n') + && at(i + 4) != Some('"') + { + out.push('\n'); + i += 2; + } + out.push_str(indent); + if at(i + 2) == Some(' ') { + out.push('\\'); + } + i += 1; + start = i + 1; + } + } + _ => i += 1, + } + } + i += 1; + } + if start == 0 { + return json.into_iter().collect(); + } + out.extend(&json[start.min(json.len())..]); + out +} + +/// Appends `indent` after every run of line breaks; `inner_only` leaves a run that +/// ends the text alone. +fn indent_after_breaks(text: &str, indent: &str, inner_only: bool) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + out.push(ch); + if ch == '\n' && chars.peek() != Some(&'\n') && !(inner_only && chars.peek().is_none()) { + out.push_str(indent); + } + } + out +} + +/// A literal block scalar, so multi-line text (code most of all) diffs line by line. +fn yaml_block(value: &str, indent: &str) -> String { + let is_space = |c: char| matches!(c, '\n' | '\t' | ' '); + let body_len = value.trim_end_matches(is_space).len(); + let (mut body, mut end) = value.split_at(body_len); + // A block cannot end in whitespace-only text after its last line break. + if end + .rfind('\n') + .is_some_and(|last_break| last_break + 1 < end.len()) + { + return yaml_quoted(value, indent, false); + } + let chomp = match end.find('\n') { + None => "-", + Some(first_break) if body.is_empty() || first_break != end.len() - 1 => "+", + Some(_) => "", + }; + end = end.strip_suffix('\n').unwrap_or(end); + let end = indent_after_breaks(end, indent, true); + + // Leading spaces need an explicit indentation indicator; leading line breaks are + // kept ahead of the body. + let lead_len = body.len() - body.trim_start_matches([' ', '\n']).len(); + let lead = &body[..lead_len]; + let starts_with_space = lead.contains(' '); + let start = lead.rfind('\n').map(|i| &lead[..=i]).unwrap_or(""); + body = &body[start.len()..]; + let indicator = match (starts_with_space, indent.is_empty()) { + (false, _) => "", + (true, true) => "1", + (true, false) => "2", + }; + format!( + "|{indicator}{chomp}\n{indent}{}{}{end}", + indent_after_breaks(start, indent, false), + indent_after_breaks(body, indent, false), + ) +} + +// ============================================================================ +// Patches +// ============================================================================ + +fn line_count(patch: &str) -> usize { + if patch.is_empty() { + 0 + } else { + patch.split('\n').count() + } +} + +/// Unified patch between two sides of an item, `""` when they render the same. +fn yaml_patch(before: Option<&Value>, after: Option<&Value>, labels: (&str, &str)) -> String { + let (before, after) = (to_stable_yaml(before), to_stable_yaml(after)); + if before == after { + return String::new(); + } + unified_patch(labels.0, labels.1, &before, &after, 3) +} + +/// Unified patch between two raw files; an absent side reads as empty. +fn text_patch(before: Option<&str>, after: Option<&str>, labels: (&str, &str)) -> String { + let (before, after) = (before.unwrap_or(""), after.unwrap_or("")); + if before == after { + return String::new(); + } + unified_patch(labels.0, labels.1, before, after, 3) +} + +/// Indices of the added and removed lines of a patch. The `---`/`+++` file labels only +/// occur ahead of the first hunk, while a changed source line may itself start with +/// `++` or `--`. +fn changed_line_indices(patch: &str) -> Vec { + let mut in_hunk = false; + let mut indices = Vec::new(); + for (i, line) in patch.split('\n').enumerate() { + if line.starts_with("@@") { + in_hunk = true; + } else if in_hunk && (line.starts_with('+') || line.starts_with('-')) { + indices.push(i); + } + } + indices +} + +const MAX_DIFF_PATCH_CHARS: usize = 50_000; +const DIFF_READ_DEFAULT_LINES: usize = 500; + +/// A window of a patch by lines, with a character backstop. When the backstop cuts +/// inside the window the continuation offset advances past the complete lines +/// delivered only, or the rest of the window would be skipped for good. +fn window_patch(patch: &str, offset: usize, limit: usize, max_chars: usize) -> String { + let lines: Vec<&str> = patch.split('\n').collect(); + let total = lines.len(); + if offset >= total { + return format!( + "(no lines at offset {offset} — the patch has {total} lines; call again with a smaller offset)" + ); + } + let requested_end = total.min(offset.saturating_add(limit)); + let mut body = lines[offset..requested_end].join("\n"); + let mut effective_end = requested_end; + let mut note = String::new(); + if body.chars().count() > max_chars { + body = body.chars().take(max_chars).collect(); + match body.rfind('\n') { + // One line over the whole budget: deliver its head and step past it. + None => { + effective_end = offset + 1; + note = format!("\n… [line truncated at {max_chars} chars]"); + } + Some(last_break) => { + body.truncate(last_break); + effective_end = offset + body.split('\n').count(); + note = format!("\n… window truncated at {max_chars} chars."); + } + } + } + if offset > 0 || effective_end < total { + let rest = if effective_end < total { + format!("; call diff again with offset={effective_end} for the rest") + } else { + String::new() + }; + note.push_str(&format!( + "\n(lines {}-{effective_end} of {total}{rest})", + offset + 1 + )); + } + body + note.as_str() +} + +struct FileDiff { + status: &'static str, + patch: String, + line_count: usize, +} + +#[derive(Default)] +struct DiffParts { + /// The whole-value patch, or the config-only patch of a multi-file app. + patch: String, + /// Changed files of a multi-file app, by name. + files: Option>, + line_count: usize, + has_changes: bool, +} + +/// A map of string contents marks a multi-file app value. +fn app_files(value: Option<&Value>) -> Option<&Map> { + value? + .get("files")? + .as_object() + .filter(|files| !files.is_empty() && files.values().all(Value::is_string)) +} + +/// Per-file text patches plus a config-only patch for a multi-file app, one whole-value +/// YAML patch for anything else. +fn diff_parts(before: Option<&Value>, after: Option<&Value>, labels: (&str, &str)) -> DiffParts { + let (before_files, after_files) = (app_files(before), app_files(after)); + if before_files.is_none() && after_files.is_none() { + let patch = yaml_patch(before, after, labels); + return DiffParts { + line_count: line_count(&patch), + has_changes: !patch.is_empty(), + patch, + files: None, + }; + } + let mut names: Vec<&String> = before_files + .into_iter() + .chain(after_files) + .flat_map(|files| files.keys()) + .collect(); + names.sort(); + names.dedup(); + fn content<'a>(files: Option<&'a Map>, name: &str) -> Option<&'a str> { + files?.get(name)?.as_str() + } + let mut files = Vec::new(); + let mut total = 0; + for name in names { + let (old, new) = (content(before_files, name), content(after_files, name)); + let patch = text_patch(old, new, labels); + // An empty file appearing or disappearing yields no patch, but its presence + // is a change in its own right. + if patch.is_empty() && old.is_some() == new.is_some() { + continue; + } + let line_count = line_count(&patch); + total += line_count; + let status = match (old, new) { + (None, _) => "added", + (_, None) => "deleted", + _ => "modified", + }; + files.push((name.clone(), FileDiff { status, patch, line_count })); + } + let config = |side: Option<&Value>| { + side.map(|v| { + if v.is_object() { + without(v, &["files"]) + } else { + v.clone() + } + }) + }; + let patch = yaml_patch(config(before).as_ref(), config(after).as_ref(), labels); + DiffParts { + line_count: total + line_count(&patch), + has_changes: !files.is_empty() || !patch.is_empty(), + patch, + files: Some(files), + } +} + +// ============================================================================ +// The two sides of a draft +// ============================================================================ + +/// The per-user markers a `get_draft=true` read adds beside the deployed item. +const OVERLAY_KEYS: &[&str] = &[ + "draft", + "is_draft", + "draft_saved_at", + "draft_base", + "no_deployed", + "other_drafts_users", +]; + +/// Server-managed script fields: identical noise, or recomputed at every deploy. +const SCRIPT_RUNTIME_KEYS: &[&str] = &[ + "workspace_id", + "hash", + "parent_hash", + "parent_hashes", + "created_at", + "created_by", + "archived", + "deleted", + "extra_perms", + "lock", + "lock_error_logs", + "starred", + "has_draft", + "draft_only", + "assets", + "marked", +]; + +/// Runtime fields of a schedule or trigger, which the workspace comparison ignores too. +const TRIGGER_RUNTIME_KEYS: &[&str] = &[ + "workspace_id", + "edited_by", + "edited_at", + "email", + "error", + "enabled", + "mode", + "server_id", + "last_server_ping", + "extra_perms", + "permissioned_as", + "subscription_id", + "push_auth_config", +]; + +struct DraftSides { + deployed: Value, + draft: Value, + /// False when the read carried no draft, and the item's own value stood in for it. + has_draft: bool, + /// True when the item was never deployed. + no_deployed: bool, +} + +/// A raw app on the flat draft shape, without the inline-script locks the server +/// recomputes at every deploy. +fn canonical_raw_app(source: &Value, path: Option<&Value>) -> Value { + let mut value = app_source_to_draft_value(source, None); + if let Some(runnables) = value.get_mut("runnables").and_then(Value::as_object_mut) { + for runnable in runnables.values_mut() { + if let Some(inline) = runnable + .get_mut("inlineScript") + .and_then(Value::as_object_mut) + { + inline.shift_remove("lock"); + } + } + } + // A staged rename changes where a deploy lands the app, so the path is compared too. + if let (Some(path), Some(value)) = (path, value.as_object_mut()) { + value.insert("path".into(), path.clone()); + } + value +} + +/// A low-code app draft split into its grid and the metadata the editor mirrors into +/// it: `(value, summary, draft_path)`. +fn classic_app_parts(json: Option<&Value>) -> (Value, Option, Option) { + let Some(Value::Object(obj)) = json else { + return (json.cloned().unwrap_or(Value::Null), None, None); + }; + // A legacy draft wraps the grid; a bare app always carries `grid` at the top. + let inner = obj.get("value").filter(|v| v.get("grid").is_some()); + if let (false, Some(inner)) = (obj.contains_key("grid"), inner) { + let (value, summary, draft_path) = classic_app_parts(Some(inner)); + let outer = |key: &str| obj.get(key).filter(|v| !v.is_null()).cloned(); + return ( + value, + outer("summary").or(summary), + outer("draft_path").or(draft_path), + ); + } + let mut value = obj.clone(); + value.shift_remove("parent_version"); + let draft_path = value.shift_remove("draft_path").filter(|v| !v.is_null()); + let summary = value.shift_remove("summary").filter(|v| !v.is_null()); + (Value::Object(value), summary, draft_path) +} + +/// A variable, resource, schedule or trigger on one field set, whether it comes as the +/// deployed row or as the editor state a draft stores. +fn canonical_drawer_value(kind: &str, raw: &Value, is_draft: bool) -> Value { + if !raw.is_object() { + return json!({}); + } + let or_empty = + |value: Option<&Value>| value.filter(|v| !v.is_null()).cloned().unwrap_or(json!("")); + let ws_specific = if is_draft { + "wsSpecific" + } else { + "ws_specific" + }; + let mut out = Map::new(); + match kind { + "variable" => { + let empty = json!({}); + let variable = if is_draft { + non_null(raw.get("variable")).unwrap_or(&empty) + } else { + raw + }; + let is_secret = truthy(variable.get("is_secret")); + out.insert( + "value".into(), + if is_secret { + json!("") + } else { + or_empty(variable.get("value")) + }, + ); + out.insert("is_secret".into(), json!(is_secret)); + out.insert("description".into(), or_empty(variable.get("description"))); + } + "resource" => { + let key = if is_draft { "args" } else { "value" }; + let mut value = non_null(raw.get(key)).cloned().unwrap_or(json!({})); + // An agent's identity is in its deployed value and never in a draft: not an edit. + if raw.get("resource_type").and_then(Value::as_str) == Some("ai_agent") { + if let Some(value) = value.as_object_mut() { + value.shift_remove("on_behalf_of"); + } + } + out.insert("value".into(), value); + out.insert("description".into(), or_empty(raw.get("description"))); + if let Some(resource_type) = non_null(raw.get("resource_type")) { + out.insert("resource_type".into(), resource_type.clone()); + } + } + _ => return without(raw, TRIGGER_RUNTIME_KEYS), + } + if let Some(labels) = non_null(raw.get("labels")) { + out.insert("labels".into(), labels.clone()); + } + if let Some(ws_specific) = non_null(raw.get(ws_specific)) { + out.insert("ws_specific".into(), ws_specific.clone()); + } + Value::Object(out) +} + +/// The deployed value and the draft value of an item, each normalised so that only a +/// real change differs. `kind` is a draft kind; an item that is neither deployed nor +/// drafted is the API's 404. +async fn draft_sides(ctx: &Ctx<'_>, kind: &str, path: &str) -> Result { + let path_in_url = path_enc(path); + let route = match kind { + "script" => format!("scripts/get/p/{path_in_url}?get_draft=true"), + "flow" => format!("flows/get/{path_in_url}?get_draft=true"), + // The server resolves a never-deployed app's draft kind from `raw_app`. + "app" | "raw_app" => format!( + "apps/get/p/{path_in_url}?get_draft=true&raw_app={}", + kind == "raw_app" + ), + "variable" => format!("variables/get/{path_in_url}?decrypt_secret=false&get_draft=true"), + "resource" => format!("resources/get/{path_in_url}?get_draft=true"), + "trigger_schedule" => format!("schedules/get/{path_in_url}?get_draft=true"), + _ => { + let trigger_kind = kind + .strip_prefix("trigger_") + .filter(|k| TRIGGER_KINDS.contains(k)) + .ok_or_else(|| format!("Draft diff not supported for kind {kind}"))?; + format!("{trigger_kind}_triggers/get/{path_in_url}?get_draft=true") + } + }; + Ok(sides_of_row(kind, &ctx.get(&ctx.ws(&route)).await?)) +} + +/// Splits a `get_draft=true` read into its two sides. +fn sides_of_row(kind: &str, row: &Value) -> DraftSides { + let draft = non_null(row.get("draft")); + let has_draft = draft.is_some(); + let no_deployed = row.get("no_deployed") == Some(&Value::Bool(true)); + let deployed = without(row, OVERLAY_KEYS); + let (deployed, draft) = match kind { + "script" => ( + without(&deployed, SCRIPT_RUNTIME_KEYS), + without(draft.unwrap_or(&deployed), SCRIPT_RUNTIME_KEYS), + ), + // The base a draft pinned differs from the head when it is stale: not a change. + "flow" => ( + without(&deployed, &["version_id"]), + without(draft.unwrap_or(&deployed), &["version_id"]), + ), + "app" | "raw_app" + if kind == "raw_app" || row.get("raw_app") == Some(&Value::Bool(true)) => + { + let row_path = row.get("path"); + let staged_path = draft + .and_then(|d| non_null(d.get("draft_path"))) + .or(row_path); + ( + canonical_raw_app(row, row_path), + canonical_raw_app(draft.unwrap_or(row), staged_path), + ) + } + "app" => { + let row_summary = non_null(row.get("summary")).cloned().unwrap_or(json!("")); + let deployed_parts = classic_app_parts(row.get("value")); + let value = deployed_parts.0.clone(); + let (draft_value, summary, draft_path) = match draft { + Some(draft) => classic_app_parts(Some(draft)), + None => deployed_parts, + }; + ( + json!({ "summary": row_summary, "value": value, "path": row.get("path") }), + json!({ + "summary": summary.unwrap_or(row_summary.clone()), + "value": draft_value, + "path": draft_path.or_else(|| row.get("path").cloned()), + }), + ) + } + _ => ( + canonical_drawer_value(kind, &deployed, false), + canonical_drawer_value(kind, draft.unwrap_or(&deployed), true), + ), + }; + DraftSides { deployed, draft, has_draft, no_deployed } +} + +const VARIABLE_VALUE_PLACEHOLDER: &str = ""; +const VARIABLE_VALUE_CHANGED_PLACEHOLDER: &str = ""; +const VARIABLE_MASKED_NOTE: &str = "Note: variable values are never shown in chat — the diff marks whether the value changed without revealing it.\n\n"; +const SECRET_UNCOMPARABLE_NOTE: &str = "Note: this is a SECRET variable — its value is never shown and cannot be compared, so it may ALSO have changed beyond what this diff shows.\n\n"; + +/// A variable's value never reaches a tool result, secret or not: both sides get a +/// placeholder that still shows WHETHER a readable value changed. The flag is set for a +/// secret, whose sides are never comparable — the deployed value is not decrypted and a +/// draft holds a marker — so equal sides prove nothing. +fn mask_variable_sides(before: &mut Option, after: &mut Option) -> bool { + let object = |side: &Option| side.as_ref().is_some_and(Value::is_object); + let both = object(before) && object(after); + let is_secret = |side: &Option| { + side.as_ref().and_then(|v| v.get("is_secret")) == Some(&Value::Bool(true)) + }; + let secret = is_secret(before) || is_secret(after); + let value = |side: &Option| side.as_ref().and_then(|v| v.get("value")).cloned(); + let changed = !secret && both && value(before) != value(after); + if let Some(Value::Object(before)) = before { + before.insert("value".into(), json!(VARIABLE_VALUE_PLACEHOLDER)); + } + if let Some(Value::Object(after)) = after { + let placeholder = if changed { + VARIABLE_VALUE_CHANGED_PLACEHOLDER + } else { + VARIABLE_VALUE_PLACEHOLDER + }; + after.insert("value".into(), json!(placeholder)); + } + secret && both +} + +// ============================================================================ +// diff +// ============================================================================ + +/// One of the caller's drafts, as the drafts listing reports it. +struct DraftRow { + kind: String, + /// The path the draft is stored under. + path: String, + /// The path the item is known by: a staged name when there is one. + display_path: String, + /// The item type and trigger kind a tool addresses the draft by, if any does. + addressing: Option<(&'static str, Option<&'static str>)>, +} + +fn addressing_of(kind: &str) -> Option<(&'static str, Option<&'static str>)> { + Some(match kind { + "script" => ("script", None), + "flow" => ("flow", None), + "resource" => ("resource", None), + "variable" => ("variable", None), + "app" | "raw_app" => ("app", None), + "trigger_schedule" => ("schedule", None), + _ => { + let trigger_kind = kind.strip_prefix("trigger_")?; + let trigger_kind = TRIGGER_KINDS.iter().find(|k| **k == trigger_kind)?; + ("trigger", Some(*trigger_kind)) + } + }) +} + +/// The caller's drafts, and how many drafts other users hold in the workspace. +async fn list_draft_rows(ctx: &Ctx<'_>) -> Result<(Vec, usize), String> { + let rows = ctx.get(&ctx.ws("drafts/list?all_users=true")).await?; + let rows = array(Some(&rows)); + let mine: Vec = rows + .iter() + .filter(|row| row.get("mine") != Some(&Value::Bool(false))) + .filter_map(|row| { + let kind = row.get("kind")?.as_str()?; + let path = row.get("path")?.as_str()?; + let display_path = row + .get("draft_path") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + .unwrap_or(path); + Some(DraftRow { + kind: kind.to_string(), + path: path.to_string(), + display_path: display_path.to_string(), + addressing: addressing_of(kind), + }) + }) + .collect(); + let others = rows.len() - mine.len(); + Ok((mine, others)) +} + +/// A draft's computed diff against what is deployed. +#[derive(Default)] +struct Materialized { + parts: DiffParts, + /// Variable content was masked: the patch marks a value change without showing it. + value_masked: bool, + value_uncomparable: bool, + no_deployed: bool, + error: Option, +} + +async fn materialize(ctx: &Ctx<'_>, kind: &str, path: &str) -> Materialized { + match draft_sides(ctx, kind, path).await { + Ok(sides) => materialized_of(kind, sides), + Err(e) => { + let error = if is_not_found(&e) { + "item not found (the draft may reference a deleted item)".to_string() + } else { + e + }; + Materialized { error: Some(error), ..Default::default() } + } + } +} + +fn materialized_of(kind: &str, sides: DraftSides) -> Materialized { + let mut before = (!sides.no_deployed).then_some(sides.deployed); + let mut after = Some(sides.draft); + let value_masked = kind == "variable"; + let value_uncomparable = value_masked && mask_variable_sides(&mut before, &mut after); + Materialized { + parts: diff_parts(before.as_ref(), after.as_ref(), ("deployed", "draft")), + value_masked, + value_uncomparable, + no_deployed: sides.no_deployed, + error: None, + } +} + +/// What an item read prints for a multi-file app: one file's patch when `file` is +/// given, otherwise the per-file summary plus the config changes. +fn render_files( + files: &[(String, FileDiff)], + config_patch: &str, + file: Option<&str>, + offset: usize, + limit: usize, +) -> ToolResult { + let window = |patch: &str| window_patch(patch, offset, limit, MAX_DIFF_PATCH_CHARS); + if let Some(requested) = file { + // App files are keyed with a leading slash: the slash-less spelling and a + // unique base name are accepted too. + let suffix = format!("/{}", requested.strip_prefix('/').unwrap_or(requested)); + let by_suffix: Vec<_> = files.iter().filter(|(n, _)| n.ends_with(&suffix)).collect(); + let resolved = files + .iter() + .find(|(name, _)| name == requested) + .or_else(|| { + files + .iter() + .find(|(name, _)| *name == format!("/{requested}")) + }) + .or_else(|| (by_suffix.len() == 1).then(|| by_suffix[0])); + let Some((name, file_diff)) = resolved else { + let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + let changed = if names.is_empty() { + "(none)".to_string() + } else { + names.join(", ") + }; + return Err(format!( + "No changes in file \"{requested}\". Changed files: {changed}." + )); + }; + if file_diff.patch.is_empty() { + return Ok(format!( + "File \"{name}\" was {} with empty content.", + file_diff.status + )); + } + return Ok(window(&file_diff.patch)); + } + let mut sections = Vec::new(); + if files.is_empty() { + sections.push("No file contents changed.".to_string()); + } else { + let lines: Vec = files + .iter() + .map(|(name, file_diff)| { + let size = match (file_diff.status, file_diff.line_count) { + ("deleted", _) => String::new(), + (_, 0) => " (empty file)".to_string(), + (_, n) => format!(" ({n} diff lines)"), + }; + format!("- {name} — {}{size}", file_diff.status) + }) + .collect(); + sections.push(format!( + "{} file(s) changed:\n{}\nRead one with file=\"\".", + lines.len(), + lines.join("\n") + )); + } + if !config_patch.is_empty() { + sections.push(format!("Config changes:\n{}", window(config_patch))); + } + Ok(sections.join("\n\n")) +} + +async fn diff_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let non_empty = |name: &str| opt_str_arg(args, name).filter(|s| !s.is_empty()); + let (Some(item_type), Some(path)) = (non_empty("type"), non_empty("path")) else { + return Err("type is required when path is provided.".to_string()); + }; + if !ITEM_TYPES.contains(&item_type) { + return Err(format!("Invalid type \"{item_type}\"")); + } + let kind = draft_kind(item_type, opt_str_arg(args, "trigger_kind")) + .ok_or("trigger_kind is required when type is trigger.")?; + let file = opt_str_arg(args, "file"); + let offset = uint_arg(args, "offset").unwrap_or(0); + let limit = uint_arg(args, "limit").unwrap_or(DIFF_READ_DEFAULT_LINES); + + // The listing is what knows a draft parked under a synthetic key by the name staged + // for it, and which of the two app kinds a draft at this path is. + let kinds: &[&str] = if item_type == "app" { + &["raw_app", "app"] + } else { + &[kind.as_str()] + }; + let (rows, _) = list_draft_rows(ctx).await?; + let listed = kinds + .iter() + .find_map(|kind| rows.iter().find(|r| r.kind == *kind && r.path == path)) + .or_else(|| { + rows.iter().find(|r| { + kinds.contains(&r.kind.as_str()) && (r.display_path == path || r.path == path) + }) + }); + + let mut caveat = ""; + let (parts, no_deployed, value_uncomparable) = match listed { + Some(row) => { + let m = materialize(ctx, &row.kind, &row.path).await; + if let Some(error) = m.error { + return Err(format!("Could not diff {item_type} \"{path}\": {error}")); + } + if m.value_masked { + caveat = if m.value_uncomparable { + SECRET_UNCOMPARABLE_NOTE + } else { + VARIABLE_MASKED_NOTE + }; + } + (m.parts, m.no_deployed, m.value_uncomparable) + } + // Not listed: no draft at all, nothing at the path, or a draft the listing + // does not show. The item's own read settles which. + None => { + let sides = draft_sides(ctx, &kind, path).await.map_err(|e| { + if is_not_found(&e) { + format!( + "No {item_type} found at \"{path}\" — it has neither a deployed version nor a draft." + ) + } else { + e + } + })?; + if !sides.no_deployed && !sides.has_draft { + return Ok(format!( + "No draft exists for {item_type} \"{path}\" — the deployed version is current." + )); + } + let mut before = (!sides.no_deployed).then_some(sides.deployed); + let mut after = Some(sides.draft); + let mut value_uncomparable = false; + if kind == "variable" { + value_uncomparable = mask_variable_sides(&mut before, &mut after); + caveat = if value_uncomparable { + SECRET_UNCOMPARABLE_NOTE + } else { + VARIABLE_MASKED_NOTE + }; + } + let patch = yaml_patch(before.as_ref(), after.as_ref(), ("deployed", "draft")); + ( + DiffParts { patch, ..Default::default() }, + sides.no_deployed, + value_uncomparable, + ) + } + }; + + let changed_files = parts.files.as_ref().map_or(0, Vec::len); + if parts.patch.is_empty() && changed_files == 0 { + // A secret is masked on both ends: an empty patch cannot prove it unchanged. + let message = if value_uncomparable { + format!( + "No visible changes for {item_type} \"{path}\" — but a secret's value cannot be compared and may have been updated in the draft." + ) + } else { + format!("Draft matches the deployed version of {item_type} \"{path}\" — no changes.") + }; + return Ok(format!("{caveat}{message}")); + } + let header = if no_deployed { + format!("{item_type} \"{path}\" has no deployed version yet — the entire draft is new.\n\n") + } else { + format!("Draft changes vs deployed for {item_type} \"{path}\":\n\n") + }; + let body = match &parts.files { + Some(files) => render_files(files, &parts.patch, file, offset, limit)?, + None if file.is_some() => { + return Err(format!( + "file only applies to multi-file apps; {item_type} \"{path}\" diffs as a single document — call again without file." + )) + } + None => window_patch(&parts.patch, offset, limit, MAX_DIFF_PATCH_CHARS), + }; + Ok(format!("{caveat}{header}{body}")) +} + +/// Patches computed per index read, so a workspace with hundreds of drafts still +/// answers; the rest report as pending and compute on an item read. +const EAGER_MATERIALIZE_CAP: usize = 50; +const FETCH_CONCURRENCY: usize = 6; +const DIFF_INDEX_DEFAULT_ITEMS: usize = 50; +const DIFF_INDEX_MAX_ITEMS: usize = 100; +const DIFF_INDEX_MAX_FILE_CHILDREN: usize = 20; + +struct IndexEntry { + row: DraftRow, + materialized: Option, +} + +/// Every draft of the caller with its diff, computed for the first `cap` addressable ones. +async fn diff_index_entries(ctx: &Ctx<'_>, cap: usize) -> Result<(Vec, usize), String> { + let (rows, others) = list_draft_rows(ctx).await?; + let eager: Vec = (0..rows.len()) + .filter(|i| rows[*i].addressing.is_some()) + .take(cap) + .collect(); + // Built ahead of the stream: a closure borrowing `rows` inside it does not make a + // `Send` future. + let fetches: Vec<_> = eager + .iter() + .map(|i| materialize(ctx, &rows[*i].kind, &rows[*i].path)) + .collect(); + let computed: Vec = stream::iter(fetches) + .buffered(FETCH_CONCURRENCY) + .collect() + .await; + let mut entries: Vec = rows + .into_iter() + .map(|row| IndexEntry { row, materialized: None }) + .collect(); + for (i, materialized) in eager.into_iter().zip(computed) { + entries[i].materialized = Some(materialized); + } + Ok((entries, others)) +} + +fn index_line(entry: &IndexEntry) -> String { + let row = &entry.row; + let Some((item_type, trigger_kind)) = row.addressing else { + return format!( + "- {} draft \"{}\" — not addressable in this chat", + row.kind, row.display_path + ); + }; + let label = match trigger_kind { + Some(trigger_kind) => format!("{trigger_kind} trigger"), + None => item_type.to_string(), + }; + let name = format!("{label} \"{}\"", row.display_path); + let Some(m) = &entry.materialized else { + return format!("- {name} — draft present (diff not computed yet; read it with type+path)"); + }; + let lines = m.parts.line_count; + match ( + &m.error, + m.no_deployed, + m.parts.has_changes, + m.value_uncomparable, + ) { + (Some(error), ..) => format!("- {name} — diff failed: {error}"), + (None, true, ..) => format!("- {name} — new, never deployed ({lines} lines)"), + (None, false, true, true) => { + format!("- {name} — modified ({lines} diff lines; secret value may also differ)") + } + (None, false, true, false) => format!("- {name} — modified ({lines} diff lines)"), + (None, false, false, true) => { + format!("- {name} — no visible changes (secret value cannot be compared; may differ)") + } + (None, false, false, false) => format!("- {name} — draft matches deployed"), + } +} + +/// The indented per-file rows under a multi-file app's index line. +fn file_children_lines(entry: &IndexEntry) -> Vec { + let Some(parts) = entry.materialized.as_ref().map(|m| &m.parts) else { + return Vec::new(); + }; + let Some(files) = &parts.files else { + return Vec::new(); + }; + let mut lines: Vec = files + .iter() + .take(DIFF_INDEX_MAX_FILE_CHILDREN) + .map(|(name, file_diff)| { + let size = match (file_diff.status, file_diff.line_count) { + ("deleted", _) => String::new(), + (_, 0) => " (empty file)".to_string(), + (_, n) => format!(" ({n} lines)"), + }; + format!(" · {name} — {}{size}", file_diff.status) + }) + .collect(); + if files.len() > DIFF_INDEX_MAX_FILE_CHILDREN { + lines.push(format!( + " · … {} more files", + files.len() - DIFF_INDEX_MAX_FILE_CHILDREN + )); + } + if !parts.patch.is_empty() { + lines.push(format!( + " · (config) — modified ({} lines)", + line_count(&parts.patch) + )); + } + lines +} + +async fn diff_index(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (entries, others) = diff_index_entries(ctx, EAGER_MATERIALIZE_CAP).await?; + let types: Vec<&str> = args + .get("types") + .and_then(Value::as_array) + .map(|types| types.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let prefix = opt_str_arg(args, "path_prefix").filter(|p| !p.is_empty()); + let matching: Vec<&IndexEntry> = entries + .iter() + .filter(|e| { + types.is_empty() + || e.row + .addressing + .is_some_and(|(item_type, _)| types.contains(&item_type)) + }) + .filter(|e| { + prefix.is_none_or(|prefix| { + e.row.display_path.starts_with(prefix) || e.row.path.starts_with(prefix) + }) + }) + .collect(); + let total = matching.len(); + let limit = uint_arg(args, "limit") + .unwrap_or(DIFF_INDEX_DEFAULT_ITEMS) + .min(DIFF_INDEX_MAX_ITEMS); + let shown = &matching[..total.min(limit)]; + + let filtered = !types.is_empty() || prefix.is_some(); + let mut lines = vec![if total > 0 { + format!("{total} draft(s) vs deployed:") + } else if filtered && !entries.is_empty() { + format!( + "No drafts match your filters ({} draft(s) exist in the workspace).", + entries.len() + ) + } else { + "No drafts in this workspace — nothing differs from the deployed state.".to_string() + }]; + for entry in shown { + lines.push(index_line(entry)); + lines.extend(file_children_lines(entry)); + } + if total > shown.len() { + lines.push(format!( + "Showing {} of {total} drafts — narrow with types/path_prefix or raise limit.", + shown.len() + )); + } + if others > 0 { + lines.push(format!( + "{others} draft(s) by other users exist in this workspace (not shown — drafts are per-user)." + )); + } + Ok(lines.join("\n")) +} + +const DIFF_SEARCH_DEFAULT_MAX_MATCHES: usize = 50; +const DIFF_SEARCH_MAX_MATCHES_CEILING: usize = 200; +const SEARCH_CONTEXT_LINES: usize = 2; +const SEARCH_MAX_LINE_CHARS: usize = 200; +const SEARCH_TOTAL_CHAR_BUDGET: usize = 12_000; + +/// Literal, case-insensitive search over the changed lines of every draft's diff. +async fn diff_search(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "search").unwrap_or_default(); + if query.is_empty() { + return Err("search requires a non-empty string.".to_string()); + } + if opt_str_arg(args, "against") == Some("parent_workspace") { + return Err(parent_comparison_unavailable(ctx).await); + } + let (entries, _) = diff_index_entries(ctx, usize::MAX).await?; + let file_glob = opt_str_arg(args, "file_glob").filter(|g| !g.is_empty()); + let max_matches = uint_arg(args, "max_matches") + .unwrap_or(DIFF_SEARCH_DEFAULT_MAX_MATCHES) + .min(DIFF_SEARCH_MAX_MATCHES_CEILING); + Ok(search_entries(&entries, query, file_glob, max_matches)) +} + +fn search_entries( + entries: &[IndexEntry], + query: &str, + file_glob: Option<&str>, + max_matches: usize, +) -> String { + // `(subject, patch)`: an item path, or `/` for a file inside an app. + let mut units: Vec<(String, &str)> = Vec::new(); + let mut failed: Vec<&str> = Vec::new(); + for entry in entries { + let Some(m) = &entry.materialized else { + continue; + }; + let path = entry.row.display_path.as_str(); + // A diff that could not be computed has no patch: reporting "no matches" for + // it would present an incomplete search as a definitive one. + if m.error.is_some() { + failed.push(path); + continue; + } + for (name, file_diff) in m.parts.files.iter().flatten() { + units.push(( + format!("{path}/{}", name.trim_start_matches('/')), + &file_diff.patch, + )); + } + if !m.parts.patch.is_empty() { + units.push((path.to_string(), &m.parts.patch)); + } + } + let mut warning = String::new(); + if !failed.is_empty() { + let one = failed.len() == 1; + warning = format!( + "\nWarning: {} could not be computed and {} NOT searched (matches may be missing): {}. Retry, or read the item{} directly for the error.", + if one { "this diff" } else { "these diffs" }, + if one { "was" } else { "were" }, + failed.join(", "), + if one { "" } else { "s" } + ); + } + if let Some(glob) = file_glob { + units.retain(|(subject, _)| matches_glob(subject, glob)); + } + // ponytail: byte order, where the session sorts with the locale's collation; the + // two only differ in which diff's matches come first. + units.sort_by(|a, b| a.0.cmp(&b.0)); + + let needle = query.to_lowercase(); + // `(unit, line index)` of every line to print: the matches and their context. + let mut rows: Vec<(usize, usize)> = Vec::new(); + let mut unit_lines: Vec> = Vec::new(); + let (mut total, mut rendered, mut subjects) = (0, 0, 0); + for (unit, (_, patch)) in units.iter().enumerate() { + let lines: Vec<&str> = patch.split('\n').collect(); + let mut unit_matched = false; + for i in changed_line_indices(patch) { + if !lines[i].to_lowercase().contains(&needle) { + continue; + } + total += 1; + unit_matched = true; + if rendered >= max_matches { + continue; + } + rendered += 1; + let last = (i + SEARCH_CONTEXT_LINES).min(lines.len() - 1); + rows.extend((i.saturating_sub(SEARCH_CONTEXT_LINES)..=last).map(|j| (unit, j))); + } + subjects += usize::from(unit_matched); + unit_lines.push(lines); + } + if total == 0 { + return format!( + "No changed lines match. Try a broader or differently-spelled term{}.{warning}", + if file_glob.is_some() { + ", or drop the file_glob" + } else { + "" + } + ); + } + + let plural = |n: usize| if n == 1 { "" } else { "s" }; + let header = format!( + "{total} changed line{} match in {subjects} diff{}{}", + plural(total), + plural(subjects), + if total > rendered { + format!( + " (showing the first {max_matches}; narrow with file_glob or a more specific query)" + ) + } else { + String::new() + } + ); + let mut spent = header.chars().count(); + let mut out = vec![header]; + let mut current_unit = None; + let mut seen = std::collections::HashSet::new(); + for row @ (unit, line) in rows { + if !seen.insert(row) { + continue; + } + let text = unit_lines[unit][line]; + let text = if text.chars().count() > SEARCH_MAX_LINE_CHARS { + let head: String = text.chars().take(SEARCH_MAX_LINE_CHARS).collect(); + format!("{head}… [line truncated]") + } else { + text.to_string() + }; + let subject = if current_unit == Some(unit) { + String::new() + } else { + format!("{}\n", units[unit].0) + }; + let row = format!("{subject} {}: {text}", line + 1); + let cost = row.chars().count() + 1; + if spent + cost > SEARCH_TOTAL_CHAR_BUDGET { + out.push( + "… output truncated at the context budget — narrow with file_glob or a more specific query." + .to_string(), + ); + break; + } + current_unit = Some(unit); + spent += cost; + out.push(row); + } + out.join("\n") + warning.as_str() +} + +/// Why `against="parent_workspace"` fails. A tool reaches the API with a credential +/// that is only valid in its own workspace, and the comparison reads the parent's +/// items under the caller's rights there. +async fn parent_comparison_unavailable(ctx: &Ctx<'_>) -> String { + let unavailable = "Comparing a fork with its parent workspace is not available on this server. Use diff without 'against' to compare drafts vs deployed versions, or the fork's compare page in Windmill for the comparison with the parent."; + let Ok(user) = ctx.get("/workspaces/users").await else { + return unavailable.to_string(); + }; + let workspace = user + .get("workspaces") + .and_then(Value::as_array) + .and_then(|workspaces| { + workspaces + .iter() + .find(|w| w.get("id").and_then(Value::as_str) == Some(ctx.w_id)) + }); + match workspace { + Some(workspace) if non_null(workspace.get("parent_workspace_id")).is_none() => format!( + "Workspace \"{}\" is not a fork — it has no parent workspace to compare against. Use diff without 'against' to compare drafts vs deployed versions.", + ctx.w_id + ), + _ => unavailable.to_string(), + } +} + +async fn diff(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + if args.get("search").is_some_and(|s| !s.is_null()) { + return diff_search(ctx, args).await; + } + if opt_str_arg(args, "against") == Some("parent_workspace") { + return Err(parent_comparison_unavailable(ctx).await); + } + if args.get("path").is_some_and(|p| !p.is_null()) { + diff_item(ctx, args).await + } else { + diff_index(ctx, args).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yaml_matches_the_session_emitter() { + // Expected texts are what the session's `yaml` library prints for these values. + let scalars = json!({ "s": [ + "true", "~", "12", "1e3", "yes", "-", "- a", "a: b", "a:b", "#x", "a #b", "x ", + "\"q\"", "it's", "say \"hi\"", "@at", "a,b", "[x]", "?q", "---", " lead", + "0 0 12 * * *", "$encrypted:abc==", "", null, 1.0, -2.5e-3, [], {}, [[1, 2], ["a"]] + ], "gone": null }); + assert_eq!( + to_stable_yaml(Some(&scalars)), + "s:\n - \"true\"\n - \"~\"\n - \"12\"\n - \"1e3\"\n - yes\n - \"-\"\n - \"- a\"\n - \"a: b\"\n - a:b\n - \"#x\"\n - \"a #b\"\n - \"x \"\n - '\"q\"'\n - it's\n - say \"hi\"\n - \"@at\"\n - a,b\n - \"[x]\"\n - ?q\n - ---\n - \" lead\"\n - 0 0 12 * * *\n - $encrypted:abc==\n - \"\"\n - null\n - 1\n - -0.0025\n - []\n - {}\n - - - 1\n - 2\n - - a\n" + ); + + let blocks = json!({ + "content": "export async function main() {\n return 1\n}\n", + "one": "a\nb", "keep": "a\n\n", "keeps": "a\n\n\nb\n\n\n", + "lead": " indented\nnext", "leadnl": "\n\nx\n", "blank": "a\n\n\nb", + "wsend": "a\n ", "onlynl": "\n", "nlnl": "\n\n", "crlf": "a\r\nb\r\n", "ctrl": "a\u{7}b", + "longcrlf": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\nbbbbbbbbbbbbbbbbbbbbbbbb\r\n\r\n cccc \r\nd", + "z": " \nfoo", "x": "a\n\n \nb\n", + "seq": [{ "a": "x\ny\n", "b": [{ "c": "p\nq" }] }, "m\nn"], + }); + assert_eq!( + to_stable_yaml(Some(&blocks)), + "blank: |-\n a\n\n\n b\ncontent: |\n export async function main() {\n return 1\n }\ncrlf: \"a\\r\\nb\\r\\n\"\nctrl: \"a\\ab\"\nkeep: |+\n a\n\nkeeps: |+\n a\n\n\n b\n\n\nlead: |2-\n indented\n next\nleadnl: |\n \n\n x\nlongcrlf: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\r\n\n bbbbbbbbbbbbbbbbbbbbbbbb\\r\n\n \\r\n\n \\ cccc \\r\n\n d\"\nnlnl: |+\n \n\none: |-\n a\n b\nonlynl: |+\n \nseq:\n - a: |\n x\n y\n b:\n - c: |-\n p\n q\n - |-\n m\n n\nwsend: \"a\\n \"\nx: |\n a\n\n \n b\nz: |2-\n \n foo\n" + ); + + let keys = json!({ + "": 1, "12": 1, "true": 1, "a b": 1, "a:b": 1, "a: b": 1, "#k": 1, "-": 1, + "x\ny": 1, "say \"hi\"": 1, "$schema": 1, "/App.tsx": 1, "---": 1, "~": 1, + }); + assert_eq!( + to_stable_yaml(Some(&keys)), + "\"\": 1\n\"#k\": 1\n$schema: 1\n\"-\": 1\n\"---\": 1\n/App.tsx: 1\n\"12\": 1\na b: 1\n\"a: b\": 1\na:b: 1\nsay \"hi\": 1\n\"true\": 1\n\"x\\ny\": 1\n\"~\": 1\n" + ); + assert_eq!(to_stable_yaml(None), ""); + assert_eq!(to_stable_yaml(Some(&json!({}))), "{}\n"); + } + + #[test] + fn yaml_patch_ignores_key_order_and_null_fields() { + let labels = ("deployed", "draft"); + let patch = |a: Value, b: Value| yaml_patch(Some(&a), Some(&b), labels); + assert_eq!(patch(json!({"a": 1, "b": 2}), json!({"b": 2, "a": 1})), ""); + assert_eq!(patch(json!({"a": 1, "tag": null}), json!({"a": 1})), ""); + assert!(patch(json!({"a": 1, "tag": "prod"}), json!({"a": 1})).contains("-tag: prod")); + + let code = patch( + json!({"content": "line1\nline2\nline3\n", "language": "bun"}), + json!({"content": "line1\nchanged\nline3\n", "language": "bun"}), + ); + assert!(code.contains("- line2") && code.contains("+ changed")); + assert!(!code.contains("- line1")); + + let new = yaml_patch(None, Some(&json!({"summary": "new item"})), labels); + assert!(new.contains("+summary: new item")); + } + + #[test] + fn window_patch_resumes_at_the_first_undelivered_line() { + let patch = (0..20) + .map(|i| format!("line-{i:02}")) + .collect::>() + .join("\n"); + // The cap fits five complete lines. + assert_eq!( + window_patch(&patch, 0, 20, 40), + "line-00\nline-01\nline-02\nline-03\nline-04\n… window truncated at 40 chars.\n(lines 1-5 of 20; call diff again with offset=5 for the rest)" + ); + assert!(window_patch(&patch, 5, 20, 40).starts_with("line-05\n")); + assert!(window_patch("a\nb", 10, 5, 100).contains("no lines at offset 10")); + + let long = format!("{}\nafter", "x".repeat(100)); + assert!(window_patch(&long, 0, 10, 40).contains("offset=1")); + assert_eq!(window_patch(&long, 1, 10, 40), "after\n(lines 2-2 of 2)"); + } + + #[test] + fn changed_lines_exclude_the_file_labels_only() { + let patch = text_patch( + Some("a\n--counter\nb\n"), + Some("a\n++counter\nb\n"), + ("deployed", "draft"), + ); + let lines: Vec<&str> = patch.split('\n').collect(); + let changed: Vec<&str> = changed_line_indices(&patch) + .into_iter() + .map(|i| lines[i]) + .collect(); + assert_eq!(changed, ["---counter", "+++counter"]); + } + + #[test] + fn variable_values_are_masked_on_both_sides() { + let mut before = Some(json!({"value": "old-plaintext", "is_secret": false})); + let mut after = Some(json!({"value": "new-plaintext", "is_secret": false})); + assert!(!mask_variable_sides(&mut before, &mut after)); + let patch = yaml_patch(before.as_ref(), after.as_ref(), ("deployed", "draft")); + assert!(!patch.contains("plaintext") && patch.contains("(changed)")); + + // Equal masked sides prove nothing about a secret. + let mut before = Some(json!({"value": "", "is_secret": true})); + let mut after = before.clone(); + assert!(mask_variable_sides(&mut before, &mut after)); + assert_eq!(before, after); + } + + #[test] + fn multi_file_apps_split_into_file_patches_and_a_config_patch() { + let before = json!({ + "summary": "Dash", + "files": { "src/App.tsx": "a\nb\nc\n", "old.js": "legacy\n", "gone.css": "", "kept.ts": "same\n" }, + }); + let after = json!({ + "summary": "Dash v2", + "files": { "src/App.tsx": "a\nB\nc\n", "new.ts": "fresh\n", "added.ts": "", "kept.ts": "same\n" }, + }); + let parts = diff_parts(Some(&before), Some(&after), ("deployed", "draft")); + let files = parts.files.unwrap(); + let statuses: Vec<(&str, &str, usize)> = files + .iter() + .map(|(name, f)| (name.as_str(), f.status, f.line_count)) + .collect(); + assert_eq!( + statuses, + [ + ("added.ts", "added", 0), + ("gone.css", "deleted", 0), + ("new.ts", "added", 6), + ("old.js", "deleted", 6), + ("src/App.tsx", "modified", 9), + ] + ); + assert!(parts.patch.contains("+summary: Dash v2") && !parts.patch.contains("App.tsx")); + assert!(parts.has_changes); + + let same = diff_parts(Some(&before), Some(&before), ("deployed", "draft")); + assert!(!same.has_changes && same.files.unwrap().is_empty()); + } + + #[test] + fn globs_match_file_names_or_full_paths() { + assert!(matches_glob("f/dash/main/src/App.tsx", "*.tsx")); + assert!(matches_glob("f/dash/main/src/App.tsx", "f/dash/**")); + assert!(!matches_glob("f/dash/main/src/App.tsx", "f/dash/*.tsx")); + assert!(!matches_glob("f/dash/main", "*.tsx")); + } +} diff --git a/backend/windmill-api/src/mcp/session/flows.rs b/backend/windmill-api/src/mcp/session/flows.rs new file mode 100644 index 0000000000..6911552c41 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/flows.rs @@ -0,0 +1,1184 @@ +//! Flow drafts: the compact view, structural patches and inline module code. +//! +//! The model never sees a rawscript body inside flow JSON: the compact view shows +//! `inline_script.` in its place, and a write resolves each such placeholder +//! back to a body (an existing module's, or an empty one for the module's own id). + +use std::collections::{HashMap, HashSet}; + +use serde_json::{json, Map, Value}; +use windmill_types::flows::FlowModule; + +use super::workspace::{ + compact_flow, empty_schema, find_and_replace, finish_draft_write, load_flow, persist_draft, + write_base, Persist, FLOW_VALUE_SETTINGS_KEYS, +}; +use super::{js_len, non_null, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "write_flow", + "patch_flow_json", + "read_flow_module_code", + "set_flow_module_code", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "write_flow" => write_flow(ctx, args).await, + "patch_flow_json" => patch_flow_json(ctx, args).await, + "read_flow_module_code" => read_flow_module_code(ctx, args).await, + "set_flow_module_code" => set_flow_module_code(ctx, args).await, + _ => unreachable!("{name} is not a flows tool"), + } +} + +const STRUCTURAL_KEYS: &[&str] = &[ + "modules", + "schema", + "preprocessor_module", + "failure_module", + "groups", + "notes", +]; +const SPECIAL_MODULES: [(&str, &str); 2] = [ + ("preprocessor_module", "preprocessor"), + ("failure_module", "failure"), +]; +const NOTE_COLORS: &[&str] = &[ + "yellow", "blue", "green", "purple", "pink", "orange", "red", "cyan", "lime", "gray", +]; +/// Names the flow editor reserves, which an agent tool cannot answer to. +const RESERVED_TOOL_NAMES: &[&str] = &[ + "do", + "bg", + "ctx", + "state", + "if", + "else", + "for", + "delete", + "while", + "new", + "in", + "failure", + "preprocessor", + "__wm_agent_root", + "as", + "Input", + "Result", + "Trigger", + "__wm_web_search", +]; +const PROVIDER_SHAPE: &str = r#"{ "type": "static", "value": { "kind": "", "resource": "$res:", "model": "" } }"#; + +fn module_id(module: &Value) -> &str { + module.get("id").and_then(Value::as_str).unwrap_or_default() +} + +fn value_type(value: &Map) -> &str { + value + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn module_type(module: &Value) -> &str { + module + .get("value") + .and_then(Value::as_object) + .map(value_type) + .unwrap_or_default() +} + +/// The module id an `inline_script.` placeholder names. +fn placeholder_ref(content: &str) -> Option<&str> { + content + .strip_prefix("inline_script.") + .filter(|id| !id.is_empty() && !id.contains(['\n', '\r', '\u{2028}', '\u{2029}'])) +} + +fn content_of(value: &Map) -> Option<&str> { + value.get("content").and_then(Value::as_str) +} + +/// Every rawscript the compact view shows as a placeholder, with its owner's id: the +/// modules of loops and branches, and an AI agent's own rawscript tools (not what those +/// tools nest). +fn visit_scripts(modules: Option<&mut Value>, f: &mut dyn FnMut(&str, &mut Map)) { + let Some(Value::Array(modules)) = modules else { + return; + }; + for module in modules { + let id = module_id(module).to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + continue; + }; + let kind = value_type(value).to_string(); + match kind.as_str() { + "rawscript" => f(&id, value), + "forloopflow" | "whileloopflow" => visit_scripts(value.get_mut("modules"), f), + "branchone" | "branchall" => { + if let Some(Value::Array(branches)) = value.get_mut("branches") { + for branch in branches { + visit_scripts(branch.get_mut("modules"), f); + } + } + if kind == "branchone" { + visit_scripts(value.get_mut("default"), f); + } + } + "aiagent" => { + let Some(Value::Array(tools)) = value.get_mut("tools") else { + continue; + }; + for tool in tools { + let tool_id = module_id(tool).to_string(); + let Some(tool_value) = tool.get_mut("value").and_then(Value::as_object_mut) + else { + continue; + }; + let is_flow_module = + tool_value.get("tool_type").and_then(Value::as_str) == Some("flowmodule"); + if is_flow_module && value_type(tool_value) == "rawscript" { + f(&tool_id, tool_value); + } + } + } + _ => {} + } + } +} + +/// `visit_scripts` over a flow value: its modules, then its preprocessor and failure +/// modules when they are rawscripts. +fn visit_flow_scripts( + flow: &mut Map, + f: &mut dyn FnMut(&str, &mut Map), +) { + visit_scripts(flow.get_mut("modules"), f); + for (key, _) in SPECIAL_MODULES { + let Some(module) = flow.get_mut(key) else { + continue; + }; + let id = module_id(module).to_string(); + if let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) { + if value_type(value) == "rawscript" { + f(&id, value); + } + } + } +} + +/// Every module of a tree, parents first, an AI agent's tools included as modules +/// (`in_tool` is set from there down). +fn walk(modules: Option<&mut Value>, in_tool: bool, f: &mut dyn FnMut(&mut Value, bool)) { + let Some(Value::Array(modules)) = modules else { + return; + }; + for module in modules { + f(module, in_tool); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + continue; + }; + let kind = value_type(value).to_string(); + match kind.as_str() { + "forloopflow" | "whileloopflow" => walk(value.get_mut("modules"), in_tool, f), + "branchone" | "branchall" => { + if kind == "branchone" { + walk(value.get_mut("default"), in_tool, f); + } + if let Some(Value::Array(branches)) = value.get_mut("branches") { + for branch in branches { + walk(branch.get_mut("modules"), in_tool, f); + } + } + } + "aiagent" => walk(value.get_mut("tools"), true, f), + _ => {} + } + } +} + +/// `walk` over a flow value: its modules, then its preprocessor and failure modules. +fn walk_flow(flow: &mut Map, f: &mut dyn FnMut(&mut Value, bool)) { + walk(flow.get_mut("modules"), false, f); + for (key, _) in SPECIAL_MODULES { + if let Some(module) = flow.get_mut(key).filter(|m| !m.is_null()) { + let mut single = Value::Array(vec![module.take()]); + walk(Some(&mut single), false, f); + *module = single[0].take(); + } + } +} + +/// Drops the lock of every step whose code differs from the same step in `base`: the worker +/// reuses any non-empty step lock, so a changed step keeping its lock runs against the old +/// dependencies. A step id `base` lacks (renamed or duplicated) keeps its lock only when some +/// `base` step has the same code. Without a base nothing is dropped: a copied flow's locks match. +pub(super) fn drop_stale_step_locks(value: &mut Map, base: Option<&Value>) { + let Some(mut base) = base.and_then(Value::as_object).cloned() else { + return; + }; + let code = |module: &Value| module.get("value").and_then(|v| v.get("content")).cloned(); + let mut base_code_by_id = HashMap::new(); + let mut base_codes = Vec::new(); + walk_flow(&mut base, &mut |module, _| { + base_code_by_id.insert(module_id(module).to_string(), code(module)); + base_codes.push(code(module)); + }); + walk_flow(value, &mut |module, _| { + let (id, content) = (module_id(module).to_string(), code(module)); + let Some(step) = module.get_mut("value").and_then(Value::as_object_mut) else { + return; + }; + if step.get("lock").is_none_or(Value::is_null) { + return; + } + let unchanged = match base_code_by_id.get(&id) { + Some(base_code) => *base_code == content, + None => base_codes.contains(&content), + }; + if !unchanged { + step.shift_remove("lock"); + } + }); +} + +/// Put the stored bodies back in place of the placeholders that name them. +fn restore_inline_scripts(flow: &mut Map, scripts: &HashMap) { + visit_flow_scripts(flow, &mut |_, value| { + let body = content_of(value) + .and_then(placeholder_ref) + .and_then(|id| scripts.get(id)); + if let Some(body) = body { + value.insert("content".into(), json!(body)); + } + }); +} + +fn unresolved_inline_scripts(flow: &mut Map) -> Vec { + let mut refs = Vec::new(); + visit_flow_scripts(flow, &mut |_, value| { + if let Some(id) = content_of(value).and_then(placeholder_ref) { + refs.push(id.to_string()); + } + }); + refs +} + +/// What is left of the placeholders once the stored bodies are back: one naming its own +/// module is a new body, saved empty; one naming anything else must not be saved as code. +fn finalize_inline_scripts(flow: &mut Map) -> Result<(), String> { + let mut blank = |module: &mut Value, _: bool| { + let id = module_id(module).to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + return; + }; + if value_type(value) == "rawscript" + && content_of(value).and_then(placeholder_ref) == Some(id.as_str()) + { + value.insert("content".into(), json!("")); + } + }; + walk_flow(flow, &mut blank); + let unresolved = unresolved_inline_scripts(flow); + if unresolved.is_empty() { + return Ok(()); + } + let refs = unresolved + .iter() + .map(|id| format!("\"inline_script.{id}\"")) + .collect::>() + .join(", "); + Err(format!("Unresolved inline script reference(s): {refs}. A rawscript \"content\" placeholder must be \"inline_script.\" (a new body, filled afterwards with the set-module-code tool) or reference an existing rawscript module to keep its current body. Nothing was saved.")) +} + +/// The inline scripts of a flow value that still have no body, in flow order. +fn empty_inline_script_ids(flow: &Map) -> Vec { + let mut scripts: Vec<(String, String)> = Vec::new(); + visit_flow_scripts(&mut flow.clone(), &mut |id, value| { + let Some(content) = content_of(value) else { + return; + }; + match scripts.iter_mut().find(|(known, _)| known == id) { + Some(script) => script.1 = content.to_string(), + None => scripts.push((id.to_string(), content.to_string())), + } + }); + scripts + .into_iter() + .filter(|(_, content)| content.trim().is_empty() || content.starts_with("inline_script.")) + .map(|(id, _)| id) + .collect() +} + +/// A patched compact view applied onto the flow value it was built from. The view is +/// the whole state of what it shows, so a key it no longer has is removed; fields of +/// the value it never showed are kept. +fn apply_editable( + original: Option<&Value>, + editable: &Map, + scripts: &HashMap, +) -> Map { + let mut value = original + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + value.insert( + "modules".into(), + editable + .get("modules") + .cloned() + .unwrap_or_else(|| json!([])), + ); + let optional = ["preprocessor_module", "failure_module", "groups", "notes"]; + for key in optional.iter().chain(FLOW_VALUE_SETTINGS_KEYS) { + match non_null(editable.get(*key)).cloned() { + Some(field) => value.insert((*key).into(), field), + None => value.shift_remove(*key), + }; + } + restore_inline_scripts(&mut value, scripts); + value +} + +fn json_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn validate_settings(flow: &Map) -> Result<(), String> { + let invalid = |path: &str, expected: &str, got: &Value| { + format!( + "Invalid flow setting {path}: Invalid input: expected {expected}, received {}", + json_type(got) + ) + }; + for key in FLOW_VALUE_SETTINGS_KEYS { + let Some(value) = flow.get(*key) else { + continue; + }; + let (expected, ok) = match *key { + "same_worker" + | "preserve_step_tags" + | "cache_ignore_s3_path" + | "chat_input_enabled" => ("boolean", value.is_boolean()), + "concurrency_key" | "debounce_key" | "skip_expr" | "early_return" => { + ("string", value.is_string()) + } + "debounce_args_to_accumulate" => ("array", value.is_array()), + "flow_env" => ("record", value.is_object()), + _ => ("number", value.is_number()), + }; + if !ok { + return Err(invalid(key, expected, value)); + } + for (index, entry) in value.as_array().into_iter().flatten().enumerate() { + if !entry.is_string() { + return Err(invalid(&format!("{key}.{index}"), "string", entry)); + } + } + } + Ok(()) +} + +/// What the server itself can read as a flow module: the shape it would refuse to run. +fn check_module_shape(module: &Value) -> Result<(), String> { + serde_json::from_str::(&module.to_string()) + .map_err(|e| e.to_string())? + .get_value() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn tool_name_error(name: &str, siblings: &[&str]) -> Option { + if name.is_empty() { + return Some("Tool name must not be empty".into()); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Some("Tool name must only contain letters, numbers and underscores".into()); + } + if RESERVED_TOOL_NAMES.contains(&name) { + return Some(format!("'{name}' is a reserved name")); + } + (siblings.iter().filter(|n| **n == name).count() > 1).then(|| "Duplicate tool name".into()) +} + +/// A provider config that cannot run whatever the workspace holds. +fn provider_shape_error(provider: Option<&Value>, step_type: &str) -> Option { + let provider = match provider { + Some(Value::String(reference)) => { + return Some(format!( + "provider must be an object {PROVIDER_SHAPE}, not the resource reference string {}", + json!(reference) + )) + } + Some(Value::Object(provider)) => provider, + _ => return Some(format!("provider must be an object {PROVIDER_SHAPE}")), + }; + let text = |key: &str| { + provider + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + }; + let kind = text("kind"); + if kind.is_empty() { + return Some(format!( + "provider.kind is missing. Expected {PROVIDER_SHAPE}" + )); + } + let decision_only = ["typesafe", "cloudflare"].contains(&kind); + if step_type == "aidecision" && !decision_only && kind != "openai" { + return Some(format!("an AI decision step runs on a decision model: provider.kind must be \"typesafe\", \"cloudflare\" or \"openai\", not \"{kind}\"")); + } + if step_type != "aidecision" && decision_only { + return Some(format!("\"{kind}\" serves decision models, which answer typed questions rather than messages: use an \"aidecision\" step rather than an AI agent")); + } + if !text("resource").starts_with("$res:") { + return Some(format!("provider.resource must be a \"$res:\" reference. Expected {PROVIDER_SHAPE}")); + } + text("model") + .is_empty() + .then(|| format!("provider.model is missing. Expected {PROVIDER_SHAPE}")) +} + +fn quoted_list(ids: &[String]) -> String { + ids.iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(", ") +} + +/// Checks the modules array and returns every id it holds, agent tools included. +fn validate_modules(modules: &mut Value) -> Result, String> { + if !modules.is_array() { + return Err("Flow modules must be an array".into()); + } + let mut shape_errors = Vec::new(); + let mut ids = Vec::new(); + let mut providerless = Vec::new(); + let mut tool_names = Vec::new(); + let mut providers = Vec::new(); + walk(Some(modules), false, &mut |module, in_tool| { + let id = module_id(module); + // A tool is not a flow module: the agent that owns it checks its shape. + if !in_tool && shape_errors.len() < 5 { + if let Err(e) = check_module_shape(module) { + shape_errors.push(format!("Module \"{id}\": {e}")); + } + } + if !id.is_empty() { + ids.push(id.to_string()); + } + let kind = module_type(module); + if kind != "aiagent" && kind != "aidecision" { + return; + } + let value = &module["value"]; + let linked = truthy(value.get("agent")); + let provider = value + .get("input_transforms") + .and_then(|transforms| transforms.get("provider")) + .filter(|provider| truthy(Some(provider))); + if kind == "aiagent" && !linked && provider.is_none() { + providerless.push(id.to_string()); + } + + // Only a flow module tool's summary is a name the agent calls. + let named: Vec<&Value> = value + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|tool| { + let tool_type = tool.get("value").and_then(|v| v.get("tool_type")); + !matches!(tool_type.and_then(Value::as_str), Some("mcp" | "websearch")) + }) + .collect(); + fn name_of(tool: &Value) -> &str { + tool.get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + } + let siblings: Vec<&str> = named.iter().map(|tool| name_of(tool)).collect(); + for tool in &named { + if let Some(error) = tool_name_error(name_of(tool), &siblings) { + tool_names.push(format!( + "agent \"{id}\" tool \"{}\" is named {} - {error}", + module_id(tool), + json!(name_of(tool)) + )); + } + } + + let is_static = |p: &&Value| p.get("type").and_then(Value::as_str) == Some("static"); + if let Some(provider) = provider.filter(|_| !linked).filter(is_static) { + if let Some(error) = provider_shape_error(provider.get("value"), kind) { + providers.push(format!("Step \"{id}\": {error}")); + } + } + }); + + if !shape_errors.is_empty() { + return Err(format!( + "Invalid flow modules:\n{}", + shape_errors.join("\n") + )); + } + let unique: HashSet = ids.iter().cloned().collect(); + if unique.len() != ids.len() { + return Err("Duplicate module IDs found in flow".into()); + } + if SPECIAL_MODULES.iter().any(|(_, id)| unique.contains(*id)) { + return Err("Special modules must be provided via preprocessor_module and failure_module, not inside modules".into()); + } + if !providerless.is_empty() { + return Err(format!( + "AI agent modules {} need a provider input transform, or an \"agent\" path linking them to a saved agent", + quoted_list(&providerless) + )); + } + if !tool_names.is_empty() { + return Err(format!( + "Invalid AI agent tool name(s): {}. The tool's \"summary\" is the name the agent calls it by: use underscores instead of spaces (e.g. \"search_docs\").", + tool_names.join("; ") + )); + } + if !providers.is_empty() { + return Err(format!( + "Invalid AI agent provider configuration:\n{}", + providers.join("\n") + )); + } + Ok(unique) +} + +fn check_color(color: Option<&Value>) -> Result<(), String> { + match color { + None | Some(Value::Null) => Ok(()), + Some(Value::String(color)) if NOTE_COLORS.contains(&color.as_str()) => Ok(()), + Some(_) => Err(format!("color must be one of {}", NOTE_COLORS.join(", "))), + } +} + +fn validate_groups(raw: Option<&Value>, module_ids: &HashSet) -> Result { + let groups = match raw { + None | Some(Value::Null) => return Ok(Value::Null), + Some(Value::Array(groups)) => groups, + Some(_) => return Err("Flow groups must be an array".into()), + }; + for (index, group) in groups.iter().enumerate() { + let fail = |what: String| format!("Invalid group at index {index}: {what}"); + let Value::Object(group) = group else { + return Err(fail("must be an object".into())); + }; + for key in ["start_id", "end_id"] { + let id = group.get(key).and_then(Value::as_str).unwrap_or_default(); + if id.is_empty() { + return Err(fail(format!("{key} must be a non-empty string"))); + } + } + for key in ["start_id", "end_id"] { + let id = group[key].as_str().unwrap_or_default(); + if !module_ids.contains(id) { + return Err(fail(format!( + "{key} \"{id}\" does not match any flow module" + ))); + } + } + check_color(group.get("color")).map_err(fail)?; + } + Ok(Value::Array(groups.clone())) +} + +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 9e15 { + json!(n as i64) + } else { + json!(n) + } +} + +const NOTE_WIDTH: f64 = 275.0; +const NOTE_MIN_HEIGHT: f64 = 60.0; +/// Where a free note without a position goes: a column left of the flow. +const NOTE_COLUMN_X: f64 = -(NOTE_WIDTH + 100.0); +const NOTE_GAP: f64 = 24.0; + +/// The editor shows a free note at a fixed size, so one written without a size needs a +/// box tall enough for its text (12px text, 1.4 line height, 16px padding). +fn free_note_height(text: &str) -> f64 { + let chars_per_line = ((NOTE_WIDTH - 32.0) / 6.2).floor().max(1.0); + let lines: f64 = text + .split('\n') + .map(|line| (js_len(line) as f64 / chars_per_line).ceil().max(1.0)) + .sum(); + (lines * 17.0 + 32.0).clamp(NOTE_MIN_HEIGHT, 600.0) +} + +/// Checks the notes and fills in what the editor needs of each: a type, a color and, +/// for a free note, the size and position without which it cannot be moved. +fn validate_notes(raw: Option<&Value>, module_ids: &HashSet) -> Result { + let notes = match raw { + None | Some(Value::Null) => return Ok(Value::Null), + Some(Value::Array(notes)) => notes, + Some(_) => return Err("Flow notes must be an array".into()), + }; + let mut seen = HashSet::new(); + let mut stack_y = 0.0_f64; + let mut out = Vec::new(); + for (index, note) in notes.iter().enumerate() { + let fail = |what: String| format!("Invalid note at index {index}: {what}"); + let Value::Object(note) = note else { + return Err(fail("must be an object".into())); + }; + let id = note.get("id").and_then(Value::as_str).unwrap_or_default(); + if id.is_empty() { + return Err(fail("id must be a non-empty string".into())); + } + if !seen.insert(id) { + return Err(fail(format!("duplicate note id \"{id}\""))); + } + let Some(text) = note.get("text").and_then(Value::as_str) else { + return Err(fail("text must be a string".into())); + }; + let kind = match note.get("type") { + None | Some(Value::Null) => "free", + Some(Value::String(kind)) if kind == "free" || kind == "group" => kind.as_str(), + Some(_) => return Err(fail("type must be \"free\" or \"group\"".into())), + }; + check_color(note.get("color")).map_err(fail)?; + let pair = |key: &str, a: &str, b: &str| -> Result, String> { + let Some(value) = note.get(key).filter(|v| !v.is_null()) else { + return Ok(None); + }; + let field = |name: &str| value.get(name).and_then(Value::as_f64); + match (value.is_object(), field(a), field(b)) { + (true, Some(a), Some(b)) => Ok(Some((a, b))), + _ => Err(fail(format!( + "{key} must be an object with numeric {a} and {b}" + ))), + } + }; + let position = pair("position", "x", "y")?; + let size = pair("size", "width", "height")?; + if kind == "group" { + if let Some(contained) = note.get("contained_node_ids") { + let ids: Option> = contained + .as_array() + .and_then(|ids| ids.iter().map(Value::as_str).collect()); + let Some(ids) = ids else { + return Err(fail( + "contained_node_ids must be an array of strings".into(), + )); + }; + if let Some(id) = ids.iter().find(|id| !module_ids.contains(**id)) { + return Err(fail(format!( + "contained_node_ids \"{id}\" does not match any flow module" + ))); + } + } + } + + let mut normalized = note.clone(); + normalized.insert("type".into(), json!(kind)); + if !note.get("color").is_some_and(Value::is_string) { + normalized.insert("color".into(), json!("green")); + } + if kind == "free" { + let (width, height) = size.unwrap_or_else(|| (NOTE_WIDTH, free_note_height(text))); + if size.is_none() { + normalized.insert( + "size".into(), + json!({ "width": number(width), "height": number(height) }), + ); + } + match position { + None => { + normalized.insert( + "position".into(), + json!({ "x": number(NOTE_COLUMN_X), "y": number(stack_y) }), + ); + stack_y += height + NOTE_GAP; + } + // A placed note standing in the column pushes the next unplaced one below it. + Some((x, y)) if x < NOTE_COLUMN_X + NOTE_WIDTH && x + width > NOTE_COLUMN_X => { + stack_y = stack_y.max(y + height + NOTE_GAP); + } + Some(_) => {} + } + } + out.push(Value::Object(normalized)); + } + Ok(Value::Array(out)) +} + +/// Checks a compact flow view (`modules, schema, preprocessor_module, failure_module, +/// groups, notes` and the top-level settings) and returns it with every structural key +/// present, `null` when unset. +fn validate_editable(raw: Value) -> Result, String> { + let Value::Object(mut flow) = raw else { + return Err("Flow JSON must be an object".into()); + }; + let allowed = || STRUCTURAL_KEYS.iter().chain(FLOW_VALUE_SETTINGS_KEYS); + // An unknown key is refused rather than dropped: the patch that added it would + // otherwise report success for an edit that never lands on the flow. + let unknown: Vec<&str> = flow + .keys() + .map(String::as_str) + .filter(|key| !allowed().any(|known| known == key)) + .collect(); + if !unknown.is_empty() { + return Err(format!( + "Unknown top-level flow key(s): {}. Allowed keys: {}", + unknown.join(", "), + allowed().copied().collect::>().join(", ") + )); + } + validate_settings(&flow)?; + + let mut modules = flow.remove("modules").unwrap_or(Value::Null); + let module_ids = validate_modules(&mut modules)?; + let schema = match flow.get("schema") { + None | Some(Value::Null) => Value::Null, + Some(schema @ Value::Object(_)) => schema.clone(), + Some(_) => return Err("Flow schema must be an object or null".into()), + }; + for (key, _) in SPECIAL_MODULES { + if let Some(module) = non_null(flow.get(key)) { + check_module_shape(&module).map_err(|e| format!("Invalid {key}: {e}"))?; + } + } + let groups = validate_groups(flow.get("groups"), &module_ids)?; + let notes = validate_notes(flow.get("notes"), &module_ids)?; + for (key, id) in SPECIAL_MODULES { + let Some(module) = non_null(flow.get(key)) else { + continue; + }; + if module_id(&module) != id { + return Err(format!("Invalid {key}: id must be \"{id}\"")); + } + if !["rawscript", "script"].contains(&module_type(&module)) { + return Err(format!( + "Invalid {key}: only \"rawscript\" and \"script\" modules are supported" + )); + } + } + + let mut editable = Map::new(); + editable.insert("modules".into(), modules); + editable.insert("schema".into(), schema); + for (key, _) in SPECIAL_MODULES { + editable.insert(key.into(), flow.remove(key).unwrap_or(Value::Null)); + } + editable.insert("groups".into(), groups); + editable.insert("notes".into(), notes); + for key in FLOW_VALUE_SETTINGS_KEYS { + if let Some(setting) = flow.remove(*key) { + editable.insert((*key).into(), setting); + } + } + Ok(editable) +} + +struct FlowWrite<'a> { + path: &'a str, + summary: Option<&'a str>, + description: Option<&'a str>, + value: Map, + schema: Option, + groups: Option, + /// Keep the fields of the current flow value that `value` cannot express. + preserve_base_settings: bool, + /// Name the inline scripts left without a body in the result. + warn_empty_scripts: bool, + force: bool, +} + +async fn write_flow_draft(ctx: &Ctx<'_>, write: FlowWrite<'_>) -> ToolResult { + let FlowWrite { path, mut value, .. } = write; + let base = write_base(ctx, "flow", path, None).await?; + let existed = base.base.is_some(); + if let Some(groups) = write.groups { + value.insert("groups".into(), groups); + } + let base_flow = match base.base { + Some(Value::Object(flow)) => Some(flow), + _ => None, + }; + if write.preserve_base_settings { + let base_value = base_flow.as_ref().and_then(|flow| flow.get("value")); + for (key, field) in base_value.and_then(Value::as_object).into_iter().flatten() { + if !STRUCTURAL_KEYS.contains(&key.as_str()) && !value.contains_key(key) { + value.insert(key.clone(), field.clone()); + } + } + } + // Also done at deploy; here too because a test run of the draft reads the locks. + drop_stale_step_locks( + &mut value, + base_flow.as_ref().and_then(|flow| flow.get("value")), + ); + let empty_scripts = if write.warn_empty_scripts { + empty_inline_script_ids(&value) + } else { + Vec::new() + }; + let draft = match base_flow { + Some(mut flow) => { + flow.insert("path".into(), json!(path)); + if let Some(summary) = write.summary { + flow.insert("summary".into(), json!(summary)); + } + if let Some(description) = write.description { + flow.insert("description".into(), json!(description)); + } + flow.insert("value".into(), Value::Object(value)); + if let Some(schema) = write.schema { + flow.insert("schema".into(), schema); + } + Value::Object(flow) + } + None => json!({ + "path": path, + "summary": write.summary.unwrap_or_default(), + "description": write.description.unwrap_or_default(), + "value": value, + "schema": write.schema.unwrap_or_else(empty_schema), + "edited_by": "", + "edited_at": "", + "archived": false, + "extra_perms": {}, + }), + }; + let persisted = persist_draft( + ctx, + "flow", + path, + None, + &draft, + base.last_sync.as_deref(), + write.force, + ) + .await?; + let item = match persisted { + Persist::Saved(item) => item, + Persist::Failed(text) => return Ok(text), + }; + let result = finish_draft_write(ctx, &item, existed); + if empty_scripts.is_empty() { + return Ok(result); + } + let Ok(Value::Object(mut parsed)) = serde_json::from_str::(&result) else { + return Ok(result); + }; + let message = parsed + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + let message = format!("{message}\n\nWarning: inline scripts {} have no code yet. Fill each one with set_flow_module_code(path, module_id, code) — do not re-send the whole flow.", quoted_list(&empty_scripts)); + parsed.insert("message".into(), json!(message)); + Ok(pretty(&Value::Object(parsed))) +} + +/// A flow-structure argument: a JSON string, or the value itself from a client that +/// did not stringify it. +fn json_arg(args: &Value, field: &str) -> Result { + let Some(Value::String(text)) = args.get(field) else { + return Ok(args.get(field).cloned().unwrap_or(Value::Null)); + }; + serde_json::from_str(text).map_err(|e| { + let hint = if ["modules", "preprocessor_module", "failure_module"].contains(&field) { + " A rawscript \"content\" string with multi-line code or quotes is the usual cause. Instead of inlining large code, create the rawscript module with empty content (\"\") and fill its body with set_flow_module_code afterwards." + } else { + "" + }; + format!("Invalid JSON for {field}: {e}{hint}") + }) +} + +async fn write_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let mut raw = Map::new(); + for key in STRUCTURAL_KEYS { + raw.insert((*key).into(), json_arg(args, key)?); + } + let mut editable = validate_editable(Value::Object(raw))?; + + // A placeholder keeps the body the current flow has for that module, so a rewrite + // does not have to send code it is not changing. + if !unresolved_inline_scripts(&mut editable).is_empty() { + let current = write_base(ctx, "flow", path, None).await?.base; + if let Some(value) = current.as_ref().and_then(|flow| flow.get("value")) { + restore_inline_scripts(&mut editable, &compact_flow(value, None).1); + } + finalize_inline_scripts(&mut editable)?; + } + + let schema = non_null(editable.get("schema")).cloned(); + let groups = non_null(editable.get("groups")).cloned(); + editable.remove("schema"); + editable.retain(|_, field| !field.is_null()); + write_flow_draft( + ctx, + FlowWrite { + path, + summary: opt_str_arg(args, "summary"), + description: opt_str_arg(args, "description"), + value: editable, + schema, + groups, + preserve_base_settings: true, + warn_empty_scripts: true, + force: args.get("override").and_then(Value::as_bool) == Some(true), + }, + ) + .await +} + +async fn patch_flow_json(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let base = load_flow(ctx, path).await?; + let (compact, scripts) = compact_flow( + base.flow.get("value").unwrap_or(&Value::Null), + base.flow.get("schema"), + ); + let updated = find_and_replace( + &compact.to_string(), + str_arg(args, "old_string")?, + str_arg(args, "new_string")?, + args.get("replace_all").and_then(Value::as_bool) == Some(true), + "compact flow JSON", + )?; + let parsed: Value = serde_json::from_str(&updated) + .map_err(|e| format!("Invalid JSON after replacement: {e}"))?; + let editable = validate_editable(parsed)?; + let mut value = apply_editable(base.flow.get("value"), &editable, &scripts); + finalize_inline_scripts(&mut value)?; + write_flow_draft( + ctx, + FlowWrite { + path, + summary: base.summary.as_deref(), + description: None, + value, + schema: non_null(editable.get("schema")).cloned(), + groups: non_null(editable.get("groups")).cloned(), + preserve_base_settings: false, + warn_empty_scripts: true, + force: false, + }, + ) + .await +} + +async fn read_flow_module_code(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (path, module_id) = (str_arg(args, "path")?, str_arg(args, "module_id")?); + let base = load_flow(ctx, path).await?; + let (_, mut scripts) = compact_flow(base.flow.get("value").unwrap_or(&Value::Null), None); + scripts.remove(module_id).ok_or_else(|| format!("Module \"{module_id}\" is not an inline rawscript in flow \"{path}\". (Path runnables, branches, and loops have no inline script content; use patch_flow_json to inspect them.)")) +} + +async fn set_flow_module_code(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (path, module_id) = (str_arg(args, "path")?, str_arg(args, "module_id")?); + let code = str_arg(args, "code")?; + let base = load_flow(ctx, path).await?; + let (compact, mut scripts) = compact_flow( + base.flow.get("value").unwrap_or(&Value::Null), + base.flow.get("schema"), + ); + if !scripts.contains_key(module_id) { + return Err(format!("Module \"{module_id}\" is not an inline rawscript in flow \"{path}\". Use patch_flow_json or write_flow for structural changes.")); + } + scripts.insert(module_id.to_string(), code.to_string()); + let editable = compact.as_object().cloned().unwrap_or_default(); + write_flow_draft( + ctx, + FlowWrite { + path, + summary: base.summary.as_deref(), + description: None, + value: apply_editable(base.flow.get("value"), &editable, &scripts), + schema: non_null(base.flow.get("schema")).cloned(), + groups: non_null(base.flow.get("groups")).cloned(), + preserve_base_settings: false, + warn_empty_scripts: false, + force: false, + }, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(id: &str, content: &str) -> Value { + json!({ "id": id, "value": { "type": "rawscript", "content": content, "language": "bun", "input_transforms": {} } }) + } + + fn content<'a>(flow: &'a Map, pointer: &str) -> &'a str { + Value::pointer(&flow["modules"], pointer) + .and_then(|module| module["value"]["content"].as_str()) + .unwrap() + } + + #[test] + fn placeholders_resolve_to_an_existing_body_or_an_empty_new_one() { + let scripts = HashMap::from([("a".to_string(), "A".to_string())]); + let mut flow = json!({ + "modules": [ + raw("a", "inline_script.a"), + raw("copy", "inline_script.a"), + { "id": "loop", "value": { "type": "forloopflow", "modules": [raw("new", "inline_script.new")] } }, + raw("multiline", "inline_script.a\nmore"), + ], + "failure_module": raw("failure", "inline_script.failure"), + }) + .as_object() + .cloned() + .unwrap(); + restore_inline_scripts(&mut flow, &scripts); + finalize_inline_scripts(&mut flow).unwrap(); + assert_eq!(content(&flow, "/0"), "A"); + assert_eq!(content(&flow, "/1"), "A"); + assert_eq!(content(&flow, "/2/value/modules/0"), ""); + assert_eq!(content(&flow, "/3"), "inline_script.a\nmore"); + assert_eq!(flow["failure_module"]["value"]["content"], ""); + assert_eq!( + empty_inline_script_ids(&flow), + ["new", "multiline", "failure"] + ); + + let mut dangling = json!({ "modules": [raw("b", "inline_script.gone")] }) + .as_object() + .cloned() + .unwrap(); + restore_inline_scripts(&mut dangling, &scripts); + let error = finalize_inline_scripts(&mut dangling).unwrap_err(); + assert!( + error.starts_with("Unresolved inline script reference(s): \"inline_script.gone\". ") + ); + } + + #[test] + fn only_steps_whose_code_changed_lose_their_lock() { + let locked = |id: &str, content: &str| { + let mut module = raw(id, content); + module["value"]["lock"] = json!("lock"); + module + }; + let base = json!({ "modules": [raw("edited", "A"), raw("kept", "B")] }); + let mut value = json!({ + "modules": [locked("edited", "B"), locked("kept", "B"), locked("renamed", "B"), locked("new", "C")], + "failure_module": locked("failure", "F"), + }) + .as_object() + .cloned() + .unwrap(); + let unlocked = value.clone(); + drop_stale_step_locks(&mut value, None); + assert_eq!(value, unlocked); + + drop_stale_step_locks(&mut value, Some(&base)); + let has_lock = |module: &Value| module["value"].get("lock").is_some(); + let kept: Vec = value["modules"] + .as_array() + .unwrap() + .iter() + .map(has_lock) + .collect(); + assert_eq!(kept, [false, true, true, false]); + assert!(!has_lock(&value["failure_module"])); + } + + #[test] + fn a_patch_of_the_compact_view_keeps_bodies_and_unsets_removed_settings() { + let value = json!({ + "modules": [raw("a", "A"), raw("b", "B")], + "same_worker": true, + "untouched": 1, + }); + let (compact, scripts) = compact_flow(&value, None); + let patched = compact + .to_string() + .replace(",\"same_worker\":true", "") + .replace("\"id\":\"b\"", "\"id\":\"c\""); + let editable = validate_editable(serde_json::from_str(&patched).unwrap()).unwrap(); + let mut applied = apply_editable(Some(&value), &editable, &scripts); + finalize_inline_scripts(&mut applied).unwrap(); + assert_eq!(content(&applied, "/0"), "A"); + // Renamed, so its placeholder still names the body it had. + assert_eq!(content(&applied, "/1"), "B"); + assert_eq!(applied.get("same_worker"), None); + assert_eq!(applied["untouched"], 1); + assert_eq!(applied.get("groups"), None); + } + + #[test] + fn validation_reports_what_the_editor_would_refuse() { + let check = |flow: Value| validate_editable(flow).unwrap_err(); + assert_eq!( + check(json!({ "modules": [raw("a", ""), raw("a", "")] })), + "Duplicate module IDs found in flow" + ); + assert!(check(json!({ "modules": [], "bogus": 1 })) + .starts_with("Unknown top-level flow key(s): bogus. Allowed keys: modules, schema,")); + assert_eq!( + check(json!({ "modules": [], "same_worker": "yes" })), + "Invalid flow setting same_worker: Invalid input: expected boolean, received string" + ); + assert!( + check(json!({ "modules": [{ "id": "a", "value": { "type": "rawscript" } }] })) + .starts_with("Invalid flow modules:\nModule \"a\": ") + ); + assert_eq!( + check(json!({ "modules": [], "failure_module": raw("oops", "") })), + "Invalid failure_module: id must be \"failure\"" + ); + let agent = |tools: Value| { + json!({ "modules": [{ "id": "agent", "value": { + "type": "aiagent", + "tools": tools, + "input_transforms": { "provider": { "type": "static", "value": { "kind": "openai", "resource": "$res:u/a/b", "model": "m" } } }, + } }] }) + }; + let tool = |summary: &str| { + let mut tool = raw("t", ""); + tool["summary"] = json!(summary); + tool["value"]["tool_type"] = json!("flowmodule"); + tool + }; + assert!(check(agent(json!([tool("search docs")]))).starts_with( + "Invalid AI agent tool name(s): agent \"agent\" tool \"t\" is named \"search docs\" - Tool name must only contain letters, numbers and underscores. " + )); + validate_editable(agent(json!([tool("search_docs")]))).unwrap(); + } + + #[test] + fn notes_get_the_geometry_the_editor_needs() { + let notes = validate_notes( + Some(&json!([ + { "id": "n1", "text": "hello\nworld" }, + { "id": "n2", "text": "", "color": "red", "position": { "x": 5, "y": 6 } }, + { "id": "n3", "text": "x" }, + ])), + &HashSet::new(), + ) + .unwrap(); + assert_eq!( + notes[0], + json!({ "id": "n1", "text": "hello\nworld", "type": "free", "color": "green", "size": { "width": 275, "height": 66 }, "position": { "x": -375, "y": 0 } }) + ); + assert_eq!(notes[1]["color"], "red"); + assert_eq!(notes[1]["position"], json!({ "x": 5, "y": 6 })); + assert_eq!(notes[2]["position"], json!({ "x": -375, "y": 90 })); + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/app_templates.json b/backend/windmill-api/src/mcp/session/gen/app_templates.json new file mode 100644 index 0000000000..02c1fa35b5 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/app_templates.json @@ -0,0 +1,123 @@ +{ + "templates": { + "react19": { + "/index.tsx": "\nimport React from 'react'\n\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\nconst root = createRoot(document.getElementById('root')!);\nroot.render();\n", + "/App.tsx": "import React, { useState } from 'react'\nimport { backend, backendAsync, getJob, streamJob } from './wmill'\nimport './index.css'\n\n// Windmill injects the viewer (absent for anonymous visitors) and the workspace.\nconst ctx = (window as any).ctx ?? {}\nconst viewer: { name?: string; username?: string } | undefined = ctx.ctx\nconst firstName = (viewer?.name || viewer?.username || '').split(' ')[0]\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nfunction errorMessage(e: any): string {\n return e?.message ?? (typeof e === 'string' ? e : JSON.stringify(e))\n}\n\nfunction Card(props: {\n step: string\n title: string\n description: string\n code: string\n children: React.ReactNode\n}) {\n return (\n
\n
\n {props.step}\n
\n

{props.title}

\n

{props.description}

\n
\n
\n
{props.children}
\n
\n        {props.code}\n      
\n
\n )\n}\n\nfunction GreetDemo() {\n const [name, setName] = useState(firstName || 'world')\n const [result, setResult] = useState<{ message: string; ran_at: string }>()\n const [error, setError] = useState()\n const [loading, setLoading] = useState(false)\n\n async function run() {\n setLoading(true)\n setError(undefined)\n try {\n setResult(await backend.greet({ name }))\n } catch (e) {\n setError(errorMessage(e))\n }\n setLoading(false)\n }\n\n return (\n \n {\n e.preventDefault()\n run()\n }}\n >\n setName(e.target.value)} placeholder=\"Your name\" />\n \n \n
\n {error ? (\n {error}\n ) : result ? (\n <>\n {result.message}\n Ran at {new Date(result.ran_at).toLocaleTimeString()}\n \n ) : (\n The result shows up here.\n )}\n
\n \n )\n}\n\ntype Stats = { words: number; characters: number; sentences: number; top_words: string[] }\n\nfunction StatsDemo() {\n const [text, setText] = useState(\"Windmill turns scripts into apps. Write the frontend in React or Svelte, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.\")\n const [stats, setStats] = useState()\n const [error, setError] = useState()\n const [loading, setLoading] = useState(false)\n\n async function run() {\n setLoading(true)\n setError(undefined)\n try {\n setStats(await backend.word_stats({ text }))\n } catch (e) {\n setError(errorMessage(e))\n }\n setLoading(false)\n }\n\n return (\n \n \n
\n \n
\n {#if statsError}\n
{statsError}
\n {/if}\n {#if stats}\n
\n
{stats.words}words
\n
{stats.sentences}sentences
\n
{stats.characters}characters
\n
\n {#each stats.top_words as word (word)}\n {word}\n {/each}\n
\n
\n {/if}\n \n
const stats = await backend.word_stats({ text })
\n \n\n
\n {@render header('3', 'Run jobs in the background', 'Start long jobs without blocking the UI, then poll their status. Start several at once.')}\n
\n
\n \n
\n {#if jobsError}\n
{jobsError}
\n {/if}\n
    \n {#each jobs as job (job.id)}\n
  • \n {job.status}\n {job.id.slice(-8)}\n {job.seconds}s\n
    \n
    \n
    \n
  • \n {:else}\n
  • No jobs yet.
  • \n {/each}\n
\n
\n
const id = await backendAsync.slow_task({ seconds })\nconst job = await getJob(id)
\n
\n\n
\n {@render header('4', 'Stream results', 'A runnable can yield its output bit by bit. Render it as it arrives, like an LLM answer.')}\n
\n
\n \n
\n
\n {#if streamError}\n {streamError}\n {:else if streamed || streaming}\n {streamed}{#if streaming}{/if}\n {:else}\n Streamed text shows up here.\n {/if}\n
\n
\n
await streamJob(id, (u) => append(u.new_result_stream))
\n
\n \n\n", + "/index.css": "/* The app renders inside an iframe whose host page has its own theme the\n iframe cannot read, so paint an opaque surface here: without one the host's\n background shows through and dark text lands on it unreadable. */\n:root {\n color-scheme: light;\n --bg: #f4f5f8;\n --card: #ffffff;\n --sunken: #efeff4;\n --border: #e2e3eb;\n --fg: #3d4758;\n --fg-strong: #1d2430;\n --muted: #718096;\n --accent: #5074f6;\n --accent-hover: #2c5beb;\n --accent-soft: #e8ebfb;\n --success: #15803d;\n --success-soft: #dcfce7;\n --error: #b91c1c;\n --error-soft: #fef2f2;\n --warning: #a16207;\n --warning-soft: #fef9c3;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n color-scheme: dark;\n --bg: #242832;\n --card: #2e3441;\n --sunken: #272c35;\n --border: #3b4252;\n --fg: #d4d7dd;\n --fg-strong: #eeeff2;\n --muted: #a9b0ba;\n --accent: #7085db;\n --accent-hover: #5670d5;\n --accent-soft: #343d5c;\n --success: #86efac;\n --success-soft: #1f3a2b;\n --error: #fca5a5;\n --error-soft: #431a1a;\n --warning: #fde68a;\n --warning-soft: #3d3419;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n min-height: 100vh;\n background: var(--bg);\n color: var(--fg);\n font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n font-size: 14px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n}\n\nh1,\nh2 {\n margin: 0;\n color: var(--fg-strong);\n}\n\ncode,\npre {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n}\n\n.muted {\n color: var(--muted);\n}\n\n.error {\n color: var(--error);\n}\n\n.app {\n max-width: 1040px;\n margin: 0 auto;\n padding: 40px 20px 56px;\n}\n\n.hero {\n margin-bottom: 28px;\n}\n\n.hero h1 {\n font-size: 1.75rem;\n font-weight: 650;\n letter-spacing: -0.01em;\n margin: 8px 0 6px;\n}\n\n.hero p {\n max-width: 620px;\n margin: 0;\n}\n\n.eyebrow {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 12px;\n font-weight: 600;\n}\n\n.grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));\n gap: 16px;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: 0 1px 2px rgb(16 24 40 / 0.04);\n overflow: hidden;\n}\n\n.card-header {\n display: flex;\n gap: 12px;\n padding: 18px 18px 0;\n}\n\n.card-header h2 {\n font-size: 15px;\n font-weight: 600;\n}\n\n.card-header p {\n margin: 2px 0 0;\n font-size: 13px;\n}\n\n.card-step {\n flex: none;\n display: grid;\n place-items: center;\n width: 26px;\n height: 26px;\n border-radius: 7px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 13px;\n font-weight: 700;\n}\n\n.card-body {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 16px 18px 18px;\n}\n\n.snippet {\n margin: 0;\n padding: 10px 18px;\n border-top: 1px solid var(--border);\n background: var(--sunken);\n color: var(--muted);\n font-size: 12px;\n white-space: pre-wrap;\n}\n\n.row {\n display: flex;\n gap: 8px;\n}\n\ninput,\ntextarea {\n flex: 1;\n min-width: 0;\n padding: 7px 10px;\n border: 1px solid var(--border);\n border-radius: 6px;\n background: var(--card);\n color: var(--fg-strong);\n font: inherit;\n resize: vertical;\n}\n\ninput:focus,\ntextarea:focus {\n outline: 2px solid var(--accent);\n outline-offset: -1px;\n}\n\nbutton {\n padding: 7px 14px;\n border: none;\n border-radius: 6px;\n background: var(--accent);\n color: #fff;\n font: inherit;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s;\n}\n\nbutton:hover:not(:disabled) {\n background: var(--accent-hover);\n}\n\nbutton:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.output {\n min-height: 44px;\n padding: 11px 12px;\n border-radius: 6px;\n background: var(--sunken);\n word-break: break-word;\n}\n\n.output.error {\n background: var(--error-soft);\n}\n\n.output.stream {\n min-height: 96px;\n white-space: pre-wrap;\n}\n\n.caret {\n display: inline-block;\n width: 7px;\n height: 1em;\n margin-left: 2px;\n vertical-align: text-bottom;\n background: var(--accent);\n animation: blink 1s steps(1) infinite;\n}\n\n@keyframes blink {\n 50% {\n opacity: 0;\n }\n}\n\n.stats {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n}\n\n.stats > div {\n display: flex;\n flex-direction: column;\n padding: 10px 12px;\n border-radius: 6px;\n background: var(--sunken);\n}\n\n.stats strong {\n font-size: 20px;\n color: var(--fg-strong);\n}\n\n.stats span {\n font-size: 12px;\n}\n\n.stats .tags {\n grid-column: 1 / -1;\n flex-direction: row;\n flex-wrap: wrap;\n gap: 6px;\n padding: 0;\n background: none;\n}\n\n.tag {\n padding: 2px 8px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n}\n\n.jobs {\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.jobs li {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n border-radius: 6px;\n background: var(--sunken);\n font-size: 13px;\n}\n\n.pill {\n min-width: 64px;\n padding: 1px 8px;\n border-radius: 999px;\n font-size: 12px;\n font-weight: 600;\n text-align: center;\n}\n\n.pill.queued {\n background: var(--warning-soft);\n color: var(--warning);\n}\n\n.pill.running {\n background: var(--accent-soft);\n color: var(--accent);\n}\n\n.pill.success {\n background: var(--success-soft);\n color: var(--success);\n}\n\n.pill.failure {\n background: var(--error-soft);\n color: var(--error);\n}\n\n.bar {\n flex: 1;\n height: 4px;\n border-radius: 999px;\n background: var(--border);\n overflow: hidden;\n}\n\n.bar-fill {\n width: 0;\n height: 100%;\n border-radius: inherit;\n background: var(--accent);\n}\n\n.bar-fill.running {\n animation: fill linear forwards;\n}\n\n.bar-fill.success {\n width: 100%;\n background: var(--success);\n}\n\n.bar-fill.failure {\n width: 100%;\n background: var(--error);\n}\n\n@keyframes fill {\n to {\n width: 95%;\n }\n}\n", + "/package.json": "{\n \"dependencies\": {\n \"svelte\": \"^5.56.8\"\n }\n}" + } + }, + "starter_runnables": { + "greet": { + "name": "greet", + "fields": { + "username": { + "type": "ctx", + "ctx": "username" + } + }, + "type": "inline", + "inlineScript": { + "content": "// import * as wmill from \"windmill-client\"\n\n// `username` is a context field: the app fills it with the viewer's username.\n// Direct API callers can pass any value, so don't use it for access control.\nexport async function main(name: string, username: string) {\n return {\n message: `Hello ${name}! ${username ? `You are signed in as ${username}.` : 'You are not signed in.'}`,\n ran_at: new Date().toISOString()\n }\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "username": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "name", + "username" + ], + "type": "object" + } + } + }, + "word_stats": { + "name": "word_stats", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "import re\nfrom collections import Counter\n\n\ndef main(text: str):\n words = re.findall(r\"[a-z']+\", text.lower())\n top = Counter(w for w in words if len(w) > 4).most_common(5)\n return {\n \"words\": len(words),\n \"characters\": len(text),\n \"sentences\": len(re.findall(r\"[.!?]+\", text)),\n \"top_words\": [word for word, _ in top],\n }\n", + "language": "python3", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "text": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + } + }, + "slow_task": { + "name": "slow_task", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "export async function main(seconds: number) {\n await new Promise((resolve) => setTimeout(resolve, seconds * 1000))\n return `Done after ${seconds}s`\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "seconds": { + "default": null, + "description": "", + "originalType": "number", + "type": "number" + } + }, + "required": [ + "seconds" + ], + "type": "object" + } + } + }, + "stream_text": { + "name": "stream_text", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "// Returning an async generator streams each yielded chunk to the caller.\nexport async function main() {\n const text =\n 'Every word of this text is yielded by a Bun script as soon as it is ready, ' +\n 'and the app renders it the moment it arrives. Use the same pattern to ' +\n 'stream an LLM answer, a long report or live logs.'\n return (async function* () {\n for (const word of text.split(' ')) {\n await new Promise((resolve) => setTimeout(resolve, 60))\n yield word + ' '\n }\n })()\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object" + } + } + } + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json new file mode 100644 index 0000000000..796bdb7ffd --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -0,0 +1,237 @@ +{ + "index": { + "script": { + "": "script", + "python3": "script.python3", + "deno": "script.deno", + "go": "script.go", + "bash": "script.bash", + "powershell": "script.powershell", + "postgresql": "script.postgresql", + "mysql": "script.mysql", + "bigquery": "script.bigquery", + "snowflake": "script.snowflake", + "mssql": "script.mssql", + "oracledb": "script.oracledb", + "graphql": "script.graphql", + "nativets": "script.nativets", + "bun": "script.bun", + "php": "script.php", + "rust": "script.rust", + "ansible": "script.ansible", + "csharp": "script.csharp", + "nu": "script.nu", + "java": "script.java", + "ruby": "script.ruby", + "rlang": "script.rlang", + "duckdb": "script.duckdb", + "bunnative": "script.bunnative", + "dbt": "script.dbt" + }, + "flow": { + "": "flow", + "python3": "flow", + "deno": "flow", + "go": "flow", + "bash": "flow", + "powershell": "flow", + "postgresql": "flow", + "mysql": "flow", + "bigquery": "flow", + "snowflake": "flow", + "mssql": "flow", + "oracledb": "flow", + "graphql": "flow", + "nativets": "flow", + "bun": "flow", + "php": "flow", + "rust": "flow", + "ansible": "flow", + "csharp": "flow", + "nu": "flow", + "java": "flow", + "ruby": "flow", + "rlang": "flow", + "duckdb": "flow", + "bunnative": "flow", + "dbt": "flow" + }, + "resource": { + "": "resource", + "python3": "resource", + "deno": "resource", + "go": "resource", + "bash": "resource", + "powershell": "resource", + "postgresql": "resource", + "mysql": "resource", + "bigquery": "resource", + "snowflake": "resource", + "mssql": "resource", + "oracledb": "resource", + "graphql": "resource", + "nativets": "resource", + "bun": "resource", + "php": "resource", + "rust": "resource", + "ansible": "resource", + "csharp": "resource", + "nu": "resource", + "java": "resource", + "ruby": "resource", + "rlang": "resource", + "duckdb": "resource", + "bunnative": "resource", + "dbt": "resource" + }, + "app": { + "": "app", + "python3": "app.python3", + "deno": "app", + "go": "app", + "bash": "app", + "powershell": "app", + "postgresql": "app", + "mysql": "app", + "bigquery": "app", + "snowflake": "app", + "mssql": "app", + "oracledb": "app", + "graphql": "app", + "nativets": "app", + "bun": "app", + "php": "app", + "rust": "app", + "ansible": "app", + "csharp": "app", + "nu": "app", + "java": "app", + "ruby": "app", + "rlang": "app", + "duckdb": "app", + "bunnative": "app", + "dbt": "app" + }, + "datatable": { + "": "datatable", + "python3": "datatable.python3", + "deno": "datatable", + "go": "datatable.go", + "bash": "datatable.go", + "powershell": "datatable.go", + "postgresql": "datatable.go", + "mysql": "datatable.go", + "bigquery": "datatable.go", + "snowflake": "datatable.go", + "mssql": "datatable.go", + "oracledb": "datatable.go", + "graphql": "datatable.go", + "nativets": "datatable", + "bun": "datatable", + "php": "datatable.go", + "rust": "datatable.go", + "ansible": "datatable.go", + "csharp": "datatable.go", + "nu": "datatable.go", + "java": "datatable.go", + "ruby": "datatable.go", + "rlang": "datatable.go", + "duckdb": "datatable.go", + "bunnative": "datatable", + "dbt": "datatable.go" + }, + "pipeline": { + "": "pipeline", + "python3": "pipeline", + "deno": "pipeline", + "go": "pipeline", + "bash": "pipeline", + "powershell": "pipeline", + "postgresql": "pipeline", + "mysql": "pipeline", + "bigquery": "pipeline", + "snowflake": "pipeline", + "mssql": "pipeline", + "oracledb": "pipeline", + "graphql": "pipeline", + "nativets": "pipeline", + "bun": "pipeline", + "php": "pipeline", + "rust": "pipeline", + "ansible": "pipeline", + "csharp": "pipeline", + "nu": "pipeline", + "java": "pipeline", + "ruby": "pipeline", + "rlang": "pipeline", + "duckdb": "pipeline", + "bunnative": "pipeline", + "dbt": "pipeline" + }, + "workspace_settings": { + "": "workspace_settings", + "python3": "workspace_settings", + "deno": "workspace_settings", + "go": "workspace_settings", + "bash": "workspace_settings", + "powershell": "workspace_settings", + "postgresql": "workspace_settings", + "mysql": "workspace_settings", + "bigquery": "workspace_settings", + "snowflake": "workspace_settings", + "mssql": "workspace_settings", + "oracledb": "workspace_settings", + "graphql": "workspace_settings", + "nativets": "workspace_settings", + "bun": "workspace_settings", + "php": "workspace_settings", + "rust": "workspace_settings", + "ansible": "workspace_settings", + "csharp": "workspace_settings", + "nu": "workspace_settings", + "java": "workspace_settings", + "ruby": "workspace_settings", + "rlang": "workspace_settings", + "duckdb": "workspace_settings", + "bunnative": "workspace_settings", + "dbt": "workspace_settings" + } + }, + "texts": { + "script": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n- No script language was provided. Default to `bun` only for new TypeScript scripts; if the user requested another language or you read an existing script, call get_instructions again with that language.\n\n# Windmill script authoring reference (bun)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Prefer `//native` when the runtime allows it\n\nIf a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method to use instead of guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync 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, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", + "script.python3": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (python3)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Python\n\n## Structure\n\nThe script must contain at least one function called `main`:\n\n```python\ndef main(param1: str, param2: int):\n # Your code here\n return {\"result\": param1, \"count\": param2}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function as TypedDict:\n\n```python\nfrom typing import TypedDict\n\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the database connection details\n pass\n```\n\n**Important rules:**\n\n- The resource type name must be **IN LOWERCASE**\n- Only include resource types if they are actually needed\n- If an import conflicts with a resource type name, **rename the imported object, not the type name**\n- Make sure to import TypedDict from typing **if you're using it**\n\n## Imports\n\nLibraries are installed automatically. Do not show installation instructions.\n\n```python\nimport requests\nimport pandas as pd\nfrom datetime import datetime\n```\n\nIf an import name conflicts with a resource type:\n\n```python\n# Wrong - don't rename the type\nimport stripe as stripe_lib\nclass stripe_type(TypedDict): ...\n\n# Correct - rename the import\nimport stripe as stripe_sdk\nclass stripe(TypedDict):\n api_key: str\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```python\nimport wmill\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```python\nfrom typing import TypedDict, Literal, Any\n\nclass Event(TypedDict):\n kind: Literal[\"webhook\", \"http\", \"websocket\", \"kafka\", \"email\", \"nats\", \"postgres\", \"sqs\", \"mqtt\", \"amqp\", \"gcp\", \"azure\"]\n body: Any\n headers: dict[str, str]\n query: dict[str, str]\n\ndef preprocessor(event: Event):\n # Transform the event into flow input parameters\n return {\n \"param1\": event[\"body\"][\"field1\"],\n \"param2\": event[\"query\"][\"id\"]\n }\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### Receiving an S3Object as a script parameter\n\nTo accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`):\n\n```python\nimport wmill\nfrom wmill import S3Object\n\ndef main(file: S3Object):\n content = wmill.load_s3_file(file)\n # ...\n```\n\n### S3 operations\n\n```python\nimport wmill\n\n# Load file content from S3\ncontent: bytes = wmill.load_s3_file(s3object)\n\n# Load file as stream reader\nreader: BufferedReader = wmill.load_s3_file_reader(s3object)\n\n# Write file to S3\nresult: S3Object = wmill.write_s3_file(\n s3object, # Target path (or None to auto-generate)\n file_content, # bytes or BufferedReader\n s3_resource_path, # Optional: specific S3 resource\n content_type, # Optional: MIME type\n content_disposition # Optional: Content-Disposition header\n)\n```\n\n\n# Python SDK (wmill)\n\nImport: import wmill\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe functions below are the surface to prefer. For an endpoint none of them covers,\nwmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this\ninstance. What does not exist is a function name you guessed at: if it is not listed below, do\nnot call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`os.environ.get(\"WM_END_USER_EMAIL\") or os.environ.get(\"WM_EMAIL\")`. WM_END_USER_EMAIL is the app\nviewer when the run was triggered from an app and empty otherwise (both variables are always\ndefined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job by path and return its job id.\n# \n# ``retry`` re-runs the job when it fails, with the policy a schedule takes,\n# e.g. ``{\"constant\": {\"attempts\": 3, \"seconds\": 10}}``. The returned id is\n# the first attempt's; each retry is a job of its own, recorded as a child of it.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Whether the worker running the job has received its shutdown signal.\n# \n# A draining worker waits for its current job to finish before exiting. A long-running\n# script can poll this and return early so the worker can be replaced.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# True if the worker running the job is draining\ndef worker_is_draining(job_id: Optional[str] = None) -> bool\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3:///`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3:///`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy\n# serves object reads, writes, deletes, listing and multipart uploads of that bucket only;\n# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials,\n# which passing the resource path returns.\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# expiry_secs: How long the signatures stay valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.\n# \n# Args:\n# step_key: Checkpoint key of the approval step, as passed to\n# ``wait_for_approval(key=...)``\n# approver: Optional approver name\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# Only meaningful on a data table under roles, and only for a role you are a\n# tenant of.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None)\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Create a script job and return its job ID.\n# \n# Args:\n# hash_or_path: Script hash or path (determined by presence of '/')\n# args: Script arguments\n# scheduled_in_secs: Delay before execution in seconds\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Job ID string\ndef run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from a `s3:///` URI string (`s3:///`\n# for the default storage) or S3Object format. Any other string raises\n# rather than falling back to an auto-generated key: an auto key is\n# requested by omitting the object, and a fallback would silently misplace\n# the upload on any typo.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Idempotently materialize the rows of `select_sql` into ducklake\n# `table` for one `partition` (or the whole table when `partition` is\n# None). Client-side equivalent of the `// materialize` engine: with\n# `unique_key` it upserts within the slice (delete-by-key + insert);\n# without it, it replaces (whole table → CREATE OR REPLACE; partition →\n# delete the partition + insert). Re-running the same slice is safe — the\n# backfill / failure-recovery contract.\n# \n# The partition value is bound as a DuckDB arg (never string-interpolated)\n# so it cannot inject SQL. `select_sql` is trusted (your own query).\ndef upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# INSERT-only materialization (no dedup / no replace) for an immutable\n# event-log table — for one `partition`, or the whole table when\n# `partition` is None. NOTE: unlike `upsert_partition`, re-running the same\n# slice duplicates rows — use only for append-only sources.\ndef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Read a materialized ducklake table, optionally a single partition.\ndef read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone, or called from inside another v2 task**: executes the\n# function body directly. A task runs as a single job, so the tasks it\n# calls run in that same job, without their own options.\n# \n# A task runs as its own job, so its result is always encoded as JSON and\n# decoded back before the caller sees it: a ``datetime`` comes back as a\n# string, a tuple as a list.\n# \n# ``retry`` re-dispatches the task after a failure, inside ``@workflow`` only.\n# Every attempt is a step of its own (``call_api``, ``call_api#2``, ...) and\n# the wait between two of them is a durable sleep, so a retrying task holds no\n# worker while it backs off. Keys: ``attempts`` (retries after the first\n# failure, a whole number from 0 to 100), ``delay`` (seconds before the first\n# retry, sub-second delays dropped), ``multiplier`` (applied to the delay\n# after each attempt, 1 keeps it constant), ``max_delay`` (ceiling in\n# seconds). ``attempts`` is required, and an out-of-range or unknown key is\n# rejected where the policy is written.\n# \n# A workflow sleeps once per round, so tasks backing off in the same fan-out\n# wait one after another rather than together: the delay before a fan-out\n# retries is the sum of every backoff pending in it, not the longest one, and\n# it grows with both the width of the fan-out and ``attempts``. Retries with\n# no ``delay`` all go out in a single round.\n# \n# ``cache_ttl`` serves a previous result of the task for that many seconds\n# instead of running it again. A task is keyed on its step key (its name and\n# call order) and the workflow's input, not on the arguments it is called\n# with, so cache one only when whether it runs, and what it receives, follow\n# from the workflow's input alone. A ``task_script`` target is keyed on the\n# arguments it is called with. It has no effect on a ``task_flow`` target,\n# which keeps its flow's own cache policy.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\n# \n# @task(retry={\"attempts\": 3, \"delay\": 30, \"multiplier\": 2})\n# async def call_api(payload: dict): ...\ndef task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\n# \n# ``fn``'s result is encoded as JSON and decoded back before it is returned,\n# so the round that runs the body sees the same types every replay sees:\n# a ``datetime`` comes back as a string, a tuple as a list.\n# \n# Called from inside a task body, ``fn`` runs in place and nothing is\n# checkpointed: a task is a single job and is never replayed.\nasync def step(name: str, fn)\n\n# Server-side sleep — suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs\n# that resume exactly this approval — route them through your own channel.\n# Without a key the steps are named ``approval``, ``approval_2``, ...\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# key: Optional checkpoint key naming this approval step.\n# skin: ``\"minimal\"`` shows approvers only the request (form and approve/reject)\n# instead of the detailed page with the workflow's details.\n# description: Shown to approvers above the form: a string, or a rich value such as\n# ``{\"markdown\": \"...\"}``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_approval_urls(\"manager\"))\n# await step(\"notify\", lambda: send_email(urls[\"resume\"], urls[\"cancel\"]))\n# result = await wait_for_approval(key=\"manager\", timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n", + "script.deno": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (deno)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Deno)\n\nDeno runtime with npm support via `npm:` prefix and native Deno libraries.\n\n**Prefer Bun (`write-script-bun`) for TypeScript.** Only use Deno when the script specifically requires the Deno runtime — Deno's standard library or `deno.land` URL imports that have no npm equivalent. For all other TypeScript, use Bun instead.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n```typescript\n// npm packages use npm: prefix\nimport Stripe from \"npm:stripe\";\nimport { someFunction } from \"npm:some-package\";\n\n// Deno standard library\nimport { serve } from \"https://deno.land/std/http/server.ts\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method instead of guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync 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, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", + "script.go": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (go)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Go\n\n## Structure\n\nThe file package must be `inner` and export a function called `main`:\n\n```go\npackage inner\n\nfunc main(param1 string, param2 int) (map[string]interface{}, error) {\n return map[string]interface{}{\n \"result\": param1,\n \"count\": param2,\n }, nil\n}\n```\n\n**Important:**\n- Package must be `inner`\n- Return type must be `({return_type}, error)`\n- Function name is `main` (lowercase)\n\n## Return Types\n\nThe return type can be any Go type that can be serialized to JSON:\n\n```go\npackage inner\n\ntype Result struct {\n Name string `json:\"name\"`\n Count int `json:\"count\"`\n}\n\nfunc main(name string, count int) (Result, error) {\n return Result{\n Name: name,\n Count: count,\n }, nil\n}\n```\n\n## Error Handling\n\nReturn errors as the second return value:\n\n```go\npackage inner\n\nimport \"errors\"\n\nfunc main(value int) (string, error) {\n if value < 0 {\n return \"\", errors.New(\"value must be positive\")\n }\n return \"success\", nil\n}\n```\n", + "script.bash": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bash)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n", + "script.powershell": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (powershell)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# PowerShell\n\n## Structure\n\nArguments are obtained by calling the `param` function on the first line:\n\n```powershell\nparam($Name, $Count = 0, [int]$Age)\n\n# Your code here\nWrite-Output \"Processing $Name, count: $Count, age: $Age\"\n\n# Return object\n@{\n name = $Name\n count = $Count\n age = $Age\n}\n```\n\n## Parameter Types\n\nYou can specify types for parameters:\n\n```powershell\nparam(\n [string]$Name,\n [int]$Count = 0,\n [bool]$Enabled = $true,\n [array]$Items\n)\n\n@{\n name = $Name\n count = $Count\n enabled = $Enabled\n items = $Items\n}\n```\n\n## Return Values\n\nReturn values by outputting them at the end of the script:\n\n```powershell\nparam($Input)\n\n$result = @{\n processed = $true\n data = $Input\n timestamp = Get-Date -Format \"o\"\n}\n\n$result\n```\n", + "script.postgresql": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (postgresql)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# PostgreSQL\n\nArguments are obtained directly in the statement with `$1::{type}`, `$2::{type}`, etc.\n\nName the parameters by adding comments at the beginning of the script (without specifying the type):\n\n```sql\n-- $1 name1\n-- $2 name2 = default_value\nSELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `jsonb` parameter — Parquet/CSV files\nare decoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `jsonb_to_recordset` (or any `jsonb` API):\n\n```sql\n-- $1 file (s3object)\nSELECT *\nFROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT);\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.mysql": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (mysql)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# MySQL\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (int) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text — Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `JSON_TABLE`:\n\n```sql\n-- ? file (s3object)\nSELECT id, name\nFROM JSON_TABLE(?, '$[*]'\n COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name')\n) AS r;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.bigquery": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bigquery)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter — Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n", + "script.snowflake": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (snowflake)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Snowflake\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (number) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Array parameters\n\nDeclare a list with a `[]` suffix on the element type (`(varchar[])`,\n`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array\ndirectly, so Windmill binds the list as JSON text: wrap the placeholder with\n`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one.\n\n```sql\n-- ? groups (varchar[])\nSELECT o.* FROM orders o\nWHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);\n```\n\nOr join on the elements with `FLATTEN`:\n\n```sql\n-- ? groups (varchar[])\nSELECT o.* FROM orders o\nJOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text — Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nWrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`:\n\n```sql\n-- ? file (s3object)\nSELECT\n v.value:id::NUMBER AS id,\n v.value:name::STRING AS name\nFROM LATERAL FLATTEN(input => PARSE_JSON(?)) v;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n", + "script.mssql": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (mssql)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Microsoft SQL Server (MSSQL)\n\nArguments use `@P1`, `@P2`, etc.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @P1 name1 (varchar)\n-- @P2 name2 (int) = 0\nSELECT * FROM users WHERE name = @P1 AND age > @P2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as `nvarchar(max)` JSON text — Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `OPENJSON`:\n\n```sql\n-- @P1 file (s3object)\nSELECT id, name\nFROM OPENJSON(@P1)\nWITH (id INT, name NVARCHAR(200));\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.oracledb": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (oracledb)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", + "script.graphql": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (graphql)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# GraphQL\n\n## Structure\n\nWrite GraphQL queries or mutations. Arguments can be added as query parameters:\n\n```graphql\nquery GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n}\n```\n\n## Variables\n\nVariables are passed as script arguments and automatically bound to the query:\n\n```graphql\nquery SearchProducts($query: String!, $limit: Int = 10) {\n products(search: $query, first: $limit) {\n edges {\n node {\n id\n name\n price\n }\n }\n }\n}\n```\n\n## Mutations\n\n```graphql\nmutation CreateUser($input: CreateUserInput!) {\n createUser(input: $input) {\n id\n name\n createdAt\n }\n}\n```\n", + "script.nativets": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (nativets)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync 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, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", + "script.bun": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bun)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Prefer `//native` when the runtime allows it\n\nIf a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method to use instead of guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync 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, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", + "script.php": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (php)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# PHP\n\n## Structure\n\nThe script must start with ` $param1, \"count\" => $param2];\n}\n```\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function. Always check if the class already exists using `class_exists`:\n\n```php\n/` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (rust)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Rust\n\n## Structure\n\nThe script must contain a function called `main` with proper return type:\n\n```rust\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct ReturnType {\n result: String,\n count: i32,\n}\n\nfn main(param1: String, param2: i32) -> anyhow::Result {\n Ok(ReturnType {\n result: param1,\n count: param2,\n })\n}\n```\n\n**Important:**\n- Arguments should be owned types\n- Return type must be serializable (`#[derive(Serialize)]`)\n- Return type is `anyhow::Result`\n\n## Dependencies\n\nPackages must be specified with a partial cargo.toml at the beginning of the script:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! ```\n\nuse anyhow::anyhow;\n// ... rest of the code\n```\n\n**Note:** Serde is already included, no need to add it again.\n\n## Async Functions\n\nIf you need to handle async functions (e.g., using tokio), keep the main function sync and create the runtime inside:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! ```\n\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct Response {\n data: String,\n}\n\nfn main(url: String) -> anyhow::Result {\n let rt = tokio::runtime::Runtime::new()?;\n rt.block_on(async {\n let resp = reqwest::get(&url).await?.text().await?;\n Ok(Response { data: resp })\n })\n}\n```\n", + "script.ansible": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (ansible)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Ansible\n\nWindmill runs Ansible playbooks with `ansible-playbook`. A script is a single YAML\ndocument made of two parts separated by a `---` line: a Windmill **header** and one or\nmore standard Ansible **plays**.\n\n## Structure\n\n```yaml\n---\n# Windmill header: configures inventories, file resources, arguments and dependencies\nextra_vars:\n world_qualifier:\n type: string\ndependencies:\n galaxy:\n collections:\n - name: community.general\n python:\n - jmespath\n---\n# Standard Ansible plays\n- name: Echo\n hosts: 127.0.0.1\n connection: local\n tasks:\n - name: Print debug message\n debug:\n msg: \"Hello, {{ world_qualifier }} world!\"\n```\n\n## Header\n\nThe header is **not** standard Ansible — it is parsed by Windmill to build the script's\ninputs and runtime environment. Supported keys:\n\n- `extra_vars`: defines the script arguments. Each entry is passed to the playbook via\n `--extra-vars` and becomes a Jinja variable usable as `{{ name }}` in the plays. Give\n each argument a `type` (`string`, `number`, `boolean`, `object`, ...) so Windmill can\n generate the input form.\n- `inventory`: lists inventories. Use `resource_type: ansible_inventory` (optionally\n pinned with `resource: u/user/your_resource`) or `resource_type: dynamic_inventory`.\n- `files`: writes Windmill resources/variables to files before the run, e.g.\n `- resource: u/user/template` with `target: ./config.j2`, or\n `- variable: u/user/ssh_key` with `target: ./ssh_key` and `mode: '0600'`.\n- `dependencies`: `galaxy` collections/roles (installed with `ansible-galaxy`) and\n `python` pip packages available to the playbook.\n- `options`: extra `ansible-playbook` flags such as `- verbosity: vvv`.\n- `vault_password`: a Windmill variable path to use as the Ansible Vault password.\n\n## Arguments\n\nReference header `extra_vars` directly as Jinja variables in the plays:\n\n```yaml\nextra_vars:\n name:\n type: string\n count:\n type: number\n---\n- hosts: localhost\n tasks:\n - debug:\n msg: \"{{ name }} x {{ count }}\"\n```\n\n## Environment variables\n\nWindmill contextual variables are available as environment variables and read with the\n`env` lookup:\n\n```yaml\n- debug:\n msg: \"Running in workspace {{ lookup('env', 'WM_WORKSPACE') }}\"\n```\n\n## Output\n\nTo return a result, write JSON to a `result.json` file in the job directory:\n\n```yaml\n- hosts: localhost\n tasks:\n - name: Write result\n copy:\n content: \"{{ { 'ok': true, 'value': 42 } | to_json }}\"\n dest: result.json\n```\n", + "script.csharp": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (csharp)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# C#\n\nThe script must contain a public static `Main` method inside a class:\n\n```csharp\npublic class Script\n{\n public static object Main(string name, int count)\n {\n return new { Name = name, Count = count };\n }\n}\n```\n\n**Important:**\n- Class name is irrelevant\n- Method must be `public static`\n- Return type can be `object` or specific type\n\n## NuGet Packages\n\nAdd packages using the `#r` directive at the top:\n\n```csharp\n#r \"nuget: Newtonsoft.Json, 13.0.3\"\n#r \"nuget: RestSharp, 110.2.0\"\n\nusing Newtonsoft.Json;\nusing RestSharp;\n\npublic class Script\n{\n public static object Main(string url)\n {\n var client = new RestClient(url);\n var request = new RestRequest();\n var response = client.Get(request);\n return JsonConvert.DeserializeObject(response.Content);\n }\n}\n```\n", + "script.nu": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (nu)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", + "script.java": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (java)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# Java\n\nThe script must contain a Main public class with a `public static main()` method:\n\n```java\npublic class Main {\n public static Object main(String name, int count) {\n java.util.Map result = new java.util.HashMap<>();\n result.put(\"name\", name);\n result.put(\"count\", count);\n return result;\n }\n}\n```\n\n**Important:**\n- Class must be named `Main`\n- Method must be `public static Object main(...)`\n- Return type is `Object` or `void`\n\n## Maven Dependencies\n\nAdd dependencies using comments at the top:\n\n```java\n//requirements:\n//com.google.code.gson:gson:2.10.1\n//org.apache.httpcomponents:httpclient:4.5.14\n\nimport com.google.gson.Gson;\n\npublic class Main {\n public static Object main(String input) {\n Gson gson = new Gson();\n return gson.fromJson(input, Object.class);\n }\n}\n```\n", + "script.ruby": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (ruby)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", + "script.rlang": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (rlang)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# R\n\n## Structure\n\nDefine a `main` function using `<-` or `=` assignment. Parameters become the script inputs:\n\n```r\nlibrary(dplyr)\nlibrary(jsonlite)\n\nmain <- function(x, name = \"default\", flag = TRUE) {\n df <- tibble(x = x, name = name)\n result <- df %>% mutate(greeting = paste(\"Hello\", name))\n return(toJSON(result, auto_unbox = TRUE))\n}\n```\n\n**Important:**\n- The `main` function is required\n- Use `library()` to load packages — they are resolved and installed automatically\n- `jsonlite` is always available (used internally for argument parsing)\n- Return values must be JSON-serializable\n\n## Parameters\n\nR types map to Windmill types:\n- `numeric` → float/int\n- `character` → string\n- `logical` → bool (use `TRUE`/`FALSE`)\n- `list` → object/dict\n- `NULL` → null\n\nDefault values are inferred from the function signature:\n\n```r\nmain <- function(\n name, # required string\n count = 10, # optional int, default 10\n verbose = FALSE # optional bool, default FALSE\n) {\n # ...\n}\n```\n\n## Resources and Variables\n\nUse the built-in Windmill helpers (no import needed):\n\n```r\nmain <- function() {\n # Get a variable\n api_key <- get_variable(\"f/my_folder/api_key\")\n\n # Get a resource (returns a list)\n db <- get_resource(\"f/my_folder/postgres_config\")\n host <- db$host\n port <- db$port\n\n return(list(host = host, port = port))\n}\n```\n\n## Output\n\nReturn any JSON-serializable value from `main`. The return value becomes the step result:\n\n```r\nmain <- function(x) {\n # Return a scalar\n return(x + 1)\n\n # Or a list (becomes JSON object)\n return(list(result = x + 1, status = \"ok\"))\n}\n```\n\n## Annotations\n\nControl execution behavior with comment annotations:\n\n```r\n#renv_verbose = true # Show verbose renv output during resolution\n#renv_install_verbose = true # Show verbose output during package installation\n#sandbox = true # Run in nsjail sandbox (requires nsjail)\n```\n", + "script.duckdb": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (duckdb)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# DuckDB\n\nArguments are defined with comments and used with `$name` syntax:\n\n```sql\n-- $name (text) = default\n-- $age (integer)\nSELECT * FROM users WHERE name = $name AND age > $age;\n```\n\n## Ducklake Integration\n\nAttach Ducklake for data lake operations:\n\n```sql\n-- Main ducklake\nATTACH 'ducklake' AS dl;\n\n-- Named ducklake\nATTACH 'ducklake://my_lake' AS dl;\n\n-- Then query\nSELECT * FROM dl.schema.table;\n```\n\n## External Database Connections\n\nConnect to external databases using resources:\n\n```sql\nATTACH '$res:path/to/resource' AS db (TYPE postgres);\nSELECT * FROM db.schema.table;\n```\n\n## S3 File Operations\n\nRead files from S3 storage:\n\n```sql\n-- Default storage\nSELECT * FROM read_csv('s3:///path/to/file.csv');\n\n-- Named storage\nSELECT * FROM read_csv('s3://storage_name/path/to/file.csv');\n\n-- Parquet files\nSELECT * FROM read_parquet('s3:///path/to/file.parquet');\n\n-- JSON files\nSELECT * FROM read_json('s3:///path/to/file.json');\n```\n\n### Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for it\nand binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader\nfunctions consume directly:\n\n```sql\n-- $file (s3object)\nSELECT * FROM read_parquet($file);\n```\n\nWorks with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc.\n\n### Writing query results to S3\n\nDuckDB writes to S3 natively via `COPY ... TO`:\n\n```sql\nCOPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET);\n```\n\nUse this instead of the `-- s3` streaming directive supported by the other SQL\ndialects — that directive is not available in DuckDB.\n", + "script.bunnative": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bunnative)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun Native)\n\nNative 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.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\n//native\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\n//native\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n**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:\n\n```typescript\n//native\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Pure computation: `//no_network`\n\nA native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments:\n\n```typescript\n//native\n//no_network\nexport async function main(items: { price: number; qty: number }[]) {\n return items.reduce((sum, i) => sum + i.price * i.qty, 0);\n}\n```\n\n## Windmill Client\n\n`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\n//native\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync 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, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", + "script.dbt": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (dbt)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", + "flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server\ntool, `websearch` for web search.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"static\", \"value\": \"\" } }\n }\n }\n ]\n }\n}\n```\n\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's\n`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool\nnames, so neither is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)\n- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n count:\n type: javascript\n expr: flow_input.iter.index + 1\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nCorrect `whileloopflow` shape carrying state via `results` (stop condition on the inner step):\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: results.advance_state ?? flow_input.initial_state\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n # iter.value is a number (the iteration index); there is no previous-iteration state\n expr: flow_input.iter.value.count\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\nAn approval step is a normal **script** step (`type: rawscript` or `type: script`) that is turned into an approval by adding a module-level `suspend`. Its script calls `wmill.getResumeUrls(approver)` to generate the secret resume/cancel URLs and returns them so they can be sent to the approver(s) (Slack, email, etc.) or approved from the run page.\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n- Do NOT use `type: identity` for an approval step. An identity step suspends but never produces the resume URLs, so approvers have no link to act on — it is not a functional approval.\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: rawscript\n language: bun\n input_transforms:\n approver:\n type: static\n value: ''\n content: |\n import * as wmill from \"windmill-client\"\n\n export async function main(approver?: string) {\n const urls = await wmill.getResumeUrls(approver)\n // send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email\n return urls\n }\n```\n\nIncorrect shape (suspend misplaced inside `value`):\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\nIncorrect shape (identity has no resume URLs — not a real approval):\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n value:\n type: identity\n```\n\n## Branch Result Scope Rules\n\n- A `branchone` runs the first of its `branches` whose `expr` is true, in order, and its `default` modules when none is; a `branchall` runs every branch (concurrently with `parallel: true`)\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Reusing Existing Scripts and Flows\n\nUnless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript:\n\n- a workspace script: `type: script` with `path` (e.g. `f/folder/send_email`)\n- a workspace flow, run as a subflow: `type: flow` with `path`\n- a Hub script: `type: script` with a `hub///` path\n\nThe step's `input_transforms` must cover the reused item's inputs, so read its input schema first.\n\n## Additional Prompt for AI\n\nA flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema.\n\n## Organizing Flows: Groups and Notes\n\nGroups and notes shape how a flow reads in the editor; neither changes what it does.\n\n**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage (\"fetch\", \"transform\", \"notify\"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for.\n\n`value.groups` lists the groups, each spanning the steps from `start_id` to `end_id`:\n\n- `start_id`, `end_id` (required): ids of the group's first and last step; the same id for both makes a one-step group\n- `summary`: the group's title\n- `note`: markdown shown under the title\n- `color`: one of `yellow`, `blue`, `green`, `purple`, `pink`, `orange`, `red`, `cyan`, `lime`, `gray`, never a hex code or CSS color; leave it out and the editor picks one\n- `autocollapse`: `true` shows the group collapsed by default\n\nThe editor refuses to draw a flow whose groups break any of these rules:\n\n- `start_id` and `end_id` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it\n- `start_id` does not come after `end_id` in that list\n- groups nest (one entirely inside another) but never partly overlap, and no two groups share both `start_id` and `end_id`\n- groups hold ordinary steps only: never `preprocessor`, `failure`, `Input`, `Result`, `Trigger`, or an AI agent's tools\n\n`value.notes` lists sticky notes, each with a unique `id`, markdown `text`, a `color` from the same list, and `type: free`. The `group` note type is deprecated; use `value.groups` instead.\nLeave a note's `position` and `size` out and they are filled in for you.\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores\n- every group starts and ends on steps of the same list, start before end, nesting without partial overlap\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### When No Resource Type Fits\n\nA connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name.\n\n- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services.\n- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it.\n- A plain variable is fine when the user asks for it.\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n\n\n## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names.\"},\"on_behalf_of\":{\"type\":\"string\",\"description\":\"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"preserve_step_tags\":{\"type\":\"boolean\",\"description\":\"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag.\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead.\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled.\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead.\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one.\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"},\"error_include_result\":{\"type\":\"boolean\",\"description\":\"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"},\"skin\":{\"type\":\"string\",\"enum\":[\"detailed\",\"minimal\"],\"description\":\"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"azure_foundry\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\",\"typesafe\",\"cloudflare\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"},\"reasoning_effort\":{\"type\":\"string\",\"description\":\"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryWindow\":{\"type\":\"object\",\"description\":\"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n`memory_id`). Without a memory id the agent runs without memory.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"window\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Number of most recent messages to load and store. 0 turns memory off.\"}},\"required\":[\"kind\",\"context_length\"]},\"MemoryAuto\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryCompaction\":{\"type\":\"object\",\"description\":\"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"compaction\"]},\"context_window\":{\"type\":\"integer\",\"description\":\"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryWindow\"},{\"$ref\":\"#/components/schemas/MemoryCompaction\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"window\":\"#/components/schemas/MemoryWindow\",\"compaction\":\"#/components/schemas/MemoryCompaction\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"},{\"$ref\":\"#/components/schemas/AiDecision\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\",\"aidecision\":\"#/components/schemas/AiDecision\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"bunnative\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\",\"dbt\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'.\"},\"description\":{\"type\":\"string\",\"description\":\"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\\nthe prompt; image output always needs it.\\n\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"memory_id\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the `memory_id` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\\nolder `auto` or `manual` memory reads neither history input.\\n\"},\"previous_messages\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while `memory` is off or absent: managed memory\\nignores it, and an older `auto` or `manual` memory reads neither history input.\\n\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"enabled_tools\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of strings naming which of the tools configured in `tools` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"},\"model\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n`flow_input.model` to choose the model per run.\\n\"},\"reasoning_effort\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside `provider`.\\n\"}}},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)\"},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"agent\":{\"type\":\"string\",\"description\":\"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\\n\"},\"tool_inputs\":{\"type\":\"object\",\"description\":\"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n\",\"additionalProperties\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}}},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"type\",\"input_transforms\"]},\"AiDecision\":{\"type\":\"object\",\"description\":\"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the decision mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"state\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n\"},\"questions\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n\"}},\"required\":[\"provider\",\"state\",\"questions\"]},\"type\":{\"type\":\"string\",\"enum\":[\"aidecision\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)\"}},\"required\":[\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}", + "resource": "# Global draft resource & variable instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- A resource draft is a workspace item: `{ type: 'resource', path, summary?, value, isDraft }`. `value` is a CreateResource body: `{ path, value, description?, resource_type, labels? }` where the inner `value` is the resource type's data shape.\n- Reading a variable returns `{ type: 'variable', path, summary?, isSecret, isDraft }` — never its value, secret or not. `isSecret` tells you whether the value is encrypted.\n- `write_variable` takes `{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, labels? }`. Creating a variable needs `value` and `is_secret`; editing one needs only the fields you are changing. Omitting `value` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any `value` you did not get from the user destroys it.\n- For secret fields in a resource value, create the variable with `write_variable` and `is_secret: true`, and deploy it before the resource (see \"Secrets\" in the reference below).\n- Reference formats inside resource values: `$var:g/all/name` (global), `$var:u/user/name` (user), `$var:f/folder/name` (folder). Reference another resource with `$res:path/to/resource`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.\n- Use `search_resource_types` to discover valid `resource_type` names and their JSON Schemas. Match the resource value to that schema.\n- For OAuth resources, the `is_oauth: true` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.\n\n# Windmill resource & variable reference\n\n# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Agents\n\nAn agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the\nagent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by\nlinking its path.\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Secrets\n\nNever put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as `$var:`.\n\n- `$var:` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable.\n- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource.\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Passing a Resource or Variable as a Run Argument\n\nA script or flow argument typed as a resource (schema `format: resource-`) is passed as\nthe **bare string** `$res:` — the whole argument value. Same for a variable, with\n`$var:`. This applies everywhere job arguments are supplied: `wmill script run/preview`,\n`wmill flow run/preview`, the `runScriptByPath` / `runFlowByPath` API, a schedule's `args`, a\ntrigger's configured static args.\n\n```json\n{\n \"db\": \"$res:f/databases/postgres_prod\",\n \"api_token\": \"$var:g/all/api_token\"\n}\n```\n\nThe reference is resolved when the job runs, under the job's run-as identity — the caller for an\nordinary run, but the configured principal for a schedule, a trigger, or a runnable set to run on\nbehalf of someone else. The run fails if that identity cannot read the referenced resource or\nvariable.\n\n**Never wrap it in an object.** The resolver only rewrites a JSON value that *is* a string\nstarting with `$res:` / `$var:`; keys are never inspected. These are all wrong and are passed\nthrough to the script unchanged:\n\n```json\n{ \"db\": { \"$res\": \"f/databases/postgres_prod\" } }\n{ \"db\": { \"resource\": \"f/databases/postgres_prod\" } }\n{ \"db\": \"f/databases/postgres_prod\" }\n```\n\nThe string may sit anywhere a string can — a top-level argument, a nested object field\n(`{ \"gh_auth\": { \"token\": \"$var:g/all/gh_token\" } }`), or an array element (array elements are\nwalked only while nested at most two levels deep, and only for arrays of at most 1000 items).\nThe prefix must be on the string itself.\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## When No Resource Type Fits\n\nThe Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service:\n\n- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both.\n- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in \"Custom Resource Types\" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields.\n- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references.\n\n## Custom Resource Types\n\nA custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python.\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"c_custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `c_custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n", + "app": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u//` for bare names; only use `f//` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend//main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the TypeScript SDK. For a `python3` runnable, call `get_instructions` again with `subject: \"app\"` and `language: \"python3\"`.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `
` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render()\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of four types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript)\n\nWhen `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise<any>\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise<string>\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable<string>): Promise<void>\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise<any>\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise<any>\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise<any>\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise<string>\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record<string, any> | 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, tag: string | null = null): Promise<string>\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise<any>\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise<void>\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise<void>\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise<void>\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise<number | null>\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise<boolean>\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise<void>\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise<any>\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise<any>\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise<string>\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise<void>\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise<string>\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>\n\n/**\n * 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.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise<string>\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise<void>} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise<void>\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise<void>\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow<T>(fn: (...args: any[]) => Promise<T>): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement<any>\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit<DucklakeMaterializeOptions, \"uniqueKey\">,): SqlStatement<any>\n", + "app.python3": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u/<current-user>/<name>` for bare names; only use `f/<folder>/<name>` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend/<key>/main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the Python SDK, for `python3` inline runnables. For a `bun` runnable, call `get_instructions` again with `subject: \"app\"` and no language.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id=\"root\">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render(<App />)\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of four types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript)\n\nWhen `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.\n\n\n# Python SDK (wmill)\n\nImport: import wmill\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe functions below are the surface to prefer. For an endpoint none of them covers,\nwmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this\ninstance. What does not exist is a function name you guessed at: if it is not listed below, do\nnot call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`os.environ.get(\"WM_END_USER_EMAIL\") or os.environ.get(\"WM_EMAIL\")`. WM_END_USER_EMAIL is the app\nviewer when the run was triggered from an app and empty otherwise (both variables are always\ndefined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job by path and return its job id.\n# \n# ``retry`` re-runs the job when it fails, with the policy a schedule takes,\n# e.g. ``{\"constant\": {\"attempts\": 3, \"seconds\": 10}}``. The returned id is\n# the first attempt's; each retry is a job of its own, recorded as a child of it.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Whether the worker running the job has received its shutdown signal.\n# \n# A draining worker waits for its current job to finish before exiting. A long-running\n# script can poll this and return early so the worker can be replaced.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# True if the worker running the job is draining\ndef worker_is_draining(job_id: Optional[str] = None) -> bool\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy\n# serves object reads, writes, deletes, listing and multipart uploads of that bucket only;\n# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials,\n# which passing the resource path returns.\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# expiry_secs: How long the signatures stay valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.\n# \n# Args:\n# step_key: Checkpoint key of the approval step, as passed to\n# ``wait_for_approval(key=...)``\n# approver: Optional approver name\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# Only meaningful on a data table under roles, and only for a role you are a\n# tenant of.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None)\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Create a script job and return its job ID.\n# \n# Args:\n# hash_or_path: Script hash or path (determined by presence of '/')\n# args: Script arguments\n# scheduled_in_secs: Delay before execution in seconds\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Job ID string\ndef run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`\n# for the default storage) or S3Object format. Any other string raises\n# rather than falling back to an auto-generated key: an auto key is\n# requested by omitting the object, and a fallback would silently misplace\n# the upload on any typo.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Idempotently materialize the rows of `select_sql` into ducklake\n# `table` for one `partition` (or the whole table when `partition` is\n# None). Client-side equivalent of the `// materialize` engine: with\n# `unique_key` it upserts within the slice (delete-by-key + insert);\n# without it, it replaces (whole table → CREATE OR REPLACE; partition →\n# delete the partition + insert). Re-running the same slice is safe — the\n# backfill / failure-recovery contract.\n# \n# The partition value is bound as a DuckDB arg (never string-interpolated)\n# so it cannot inject SQL. `select_sql` is trusted (your own query).\ndef upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# INSERT-only materialization (no dedup / no replace) for an immutable\n# event-log table — for one `partition`, or the whole table when\n# `partition` is None. NOTE: unlike `upsert_partition`, re-running the same\n# slice duplicates rows — use only for append-only sources.\ndef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Read a materialized ducklake table, optionally a single partition.\ndef read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone, or called from inside another v2 task**: executes the\n# function body directly. A task runs as a single job, so the tasks it\n# calls run in that same job, without their own options.\n# \n# A task runs as its own job, so its result is always encoded as JSON and\n# decoded back before the caller sees it: a ``datetime`` comes back as a\n# string, a tuple as a list.\n# \n# ``retry`` re-dispatches the task after a failure, inside ``@workflow`` only.\n# Every attempt is a step of its own (``call_api``, ``call_api#2``, ...) and\n# the wait between two of them is a durable sleep, so a retrying task holds no\n# worker while it backs off. Keys: ``attempts`` (retries after the first\n# failure, a whole number from 0 to 100), ``delay`` (seconds before the first\n# retry, sub-second delays dropped), ``multiplier`` (applied to the delay\n# after each attempt, 1 keeps it constant), ``max_delay`` (ceiling in\n# seconds). ``attempts`` is required, and an out-of-range or unknown key is\n# rejected where the policy is written.\n# \n# A workflow sleeps once per round, so tasks backing off in the same fan-out\n# wait one after another rather than together: the delay before a fan-out\n# retries is the sum of every backoff pending in it, not the longest one, and\n# it grows with both the width of the fan-out and ``attempts``. Retries with\n# no ``delay`` all go out in a single round.\n# \n# ``cache_ttl`` serves a previous result of the task for that many seconds\n# instead of running it again. A task is keyed on its step key (its name and\n# call order) and the workflow's input, not on the arguments it is called\n# with, so cache one only when whether it runs, and what it receives, follow\n# from the workflow's input alone. A ``task_script`` target is keyed on the\n# arguments it is called with. It has no effect on a ``task_flow`` target,\n# which keeps its flow's own cache policy.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\n# \n# @task(retry={\"attempts\": 3, \"delay\": 30, \"multiplier\": 2})\n# async def call_api(payload: dict): ...\ndef task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\n# \n# ``fn``'s result is encoded as JSON and decoded back before it is returned,\n# so the round that runs the body sees the same types every replay sees:\n# a ``datetime`` comes back as a string, a tuple as a list.\n# \n# Called from inside a task body, ``fn`` runs in place and nothing is\n# checkpointed: a task is a single job and is never replayed.\nasync def step(name: str, fn)\n\n# Server-side sleep — suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs\n# that resume exactly this approval — route them through your own channel.\n# Without a key the steps are named ``approval``, ``approval_2``, ...\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# key: Optional checkpoint key naming this approval step.\n# skin: ``\"minimal\"`` shows approvers only the request (form and approve/reject)\n# instead of the detailed page with the workflow's details.\n# description: Shown to approvers above the form: a string, or a rich value such as\n# ``{\"markdown\": \"...\"}``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_approval_urls(\"manager\"))\n# await step(\"notify\", lambda: send_email(urls[\"resume\"], urls[\"cancel\"]))\n# result = await wait_for_approval(key=\"manager\", timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n", + "datatable": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n", + "datatable.python3": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", + "datatable.go": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", + "pipeline": "# Data pipeline authoring\n\nA **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at `/pipeline/<folder>`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a \"data pipeline\" (or to \"ingest / transform / materialize\" data across steps), build pipeline-annotated scripts — do NOT build a flow.\n\n## Default to DuckDB + DuckLake\n\nA pipeline node that produces a table should almost always be a **`duckdb`** node that materializes its output into a **DuckLake** table with `-- materialize ducklake://<name>/<table>` (in a DuckDB node the annotation uses SQL `--` comment syntax; write the body as a bare `SELECT` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:\n\n- `postgresql` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).\n- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see \"Ingesting from Python / TypeScript\" below — rather than inventing a parallel store.\n\nDo not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.\n\n## Ingesting from Python / TypeScript\n\nA node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second \"load the raw file\" node. One ingestion node, one table.\n\nPass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:\n\n```python\n# pipeline\n# on schedule\nimport wmill\n\ndef main():\n rows = fetch_charges() # list[dict]\n lake = wmill.ducklake()\n lake.query(\"\"\"\n CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json($rows, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)\n \"\"\", rows=rows).fetch()\n```\n\n```ts\n// pipeline\n// on schedule\nimport * as wmill from \"windmill-client\"\n\nexport async function main() {\n const rows = await fetchCharges()\n const lake = wmill.ducklake()\n await lake`CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json(${rows}, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)`.fetch()\n}\n```\n\n- Use `INSERT INTO <table> SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first.\n- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/<table>`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms.\n- `wmill.ducklake(\"<name>\")` targets a non-default catalog; `wmill.datatable(\"<name>\")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).\n- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`.\n\n## Storage prerequisites\n\nA DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and `s3://` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.\nDrafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits \"storage not configured\" errors), say so and give the right next step **by role**:\n\n- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it;\n- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog.\n\nNever hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up.\n\n## What makes a script a pipeline node\n\nA script joins the pipeline when its source begins with the `pipeline` annotation as a top-of-file comment, **written in the script's own comment syntax** — `//` for TS/JS (bun), `--` for SQL (DuckDB/Postgres), `#` for Python/Bash. So it's `-- pipeline` in a DuckDB node, `# pipeline` in a Python node, `// pipeline` in a bun node. Every annotation below uses that same prefix (the `//` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file:\n\n- `// on <ref>` — declares an execution-DAG **input** (what triggers/feeds this node). `<ref>` is either:\n - an **asset URI** (the node runs when that asset is produced upstream): `ducklake://main/orders`, `datatable://main/users`, `$res:f/folder/my_resource`, `volume://name/path`, or an S3 object (see the S3 storage-form rule below).\n - a **native trigger kind**: `schedule`, `webhook`, `email`, `kafka`, `mqtt`, `amqp`, `nats`, `postgres`, `sqs`, `gcp`, or `data_upload` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **`data_upload` is special**: there is no trigger row — the node instead declares an **`S3Object` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the `data_upload` node:\n - Python (has `import wmill`): `def main(file: wmill.S3Object):` then `wmill.load_s3_file(file)`; TS (has `import * as wmill from \"windmill-client\"`): `export async function main(file: wmill.S3Object)`. Qualify the type as `wmill.S3Object` (or add `from wmill import S3Object` / `import { S3Object } from \"windmill-client\"`) — a bare `S3Object` is undefined.\n - **DuckDB** takes the s3object arg via a `-- $<name> (s3object)` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize:\n ```\n -- pipeline\n -- on data_upload\n -- materialize ducklake://main/raw_uploads\n -- $file (s3object)\n SELECT * FROM read_csv($file)\n ```\n- **Outputs** are inferred from what the body writes — a `CREATE TABLE`, a `wmill.writeS3File(...)`, a DuckLake/datatable write. To declare a managed output explicitly, use `// materialize <asset-uri>`.\n- Optional badges: `// partitioned <daily|hourly|weekly|monthly|dynamic>`, `// freshness <duration>` (e.g. `1h`), `// tag <worker-tag>`, `// retry <count> [delay]`, `// data_test <kind> ...` (managed DuckLake targets only — deploy rejects it beside a `dbt://` one), `// measure <name> = <agg> [where <pred>]` and `// dimension <name> = <expr>` (see \"Declared metrics\" below).\n\n## S3 object wiring (storage form matters)\n\nAn `s3://` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect:\n\n- `s3:///<key>` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses `s3:///<key>` — e.g. DuckDB `-- on s3:///orders/2024.parquet` and `read_parquet('s3:///orders/2024.parquet')`.\n- `s3://<storage>/<key>` (**double** slash, non-empty first segment) = a **named secondary** storage called `<storage>` — so `s3://ingest/x` means storage `ingest`, key `x`, NOT key `ingest/x`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge).\n\nTo make the producer side visible to lineage, a Python/TS node MUST pass the **`S3Object` form**, not a bare key string: Python `wmill.write_s3_file(wmill.S3Object(s3=\"<key>\"), data)` (or the import-free dict `{\"s3\": \"<key>\"}`), TS `wmill.writeS3File({ s3: \"<key>\" }, data)`. That records the default-storage asset `/<key>`, which a downstream `s3:///<key>` reader connects to (same key both sides). A bare `write_s3_file(\"<key>\", ...)` records **no** asset and produces **no** edge. Add `storage=\"<name>\"` only for a named secondary storage.\n\nThe key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so `write_s3_file(wmill.S3Object(s3=key_var), ...)` records no edge. Inline the literal (`s3=\"events/user_events.parquet\"`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (`ducklake://`, `datatable://`, `s3://`): write them literally, not via a variable.\n\n## Materialize (the managed output)\n\n> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). Deploy **rejects** a `ducklake://` `// materialize` on any other language (`python3`, `bun`, `postgresql`). For a non-DuckDB node writing the lake, do **not** use `// materialize` — write the table itself with `wmill.ducklake()` (see \"Ingesting from Python / TypeScript\") and let the output be inferred. Use `duckdb` when a node should materialize a DuckLake table.\n>\n> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: `// materialize manual dbt://<warehouse>/<schema>/<name>`, where `<warehouse>` is a warehouse the workspace configures under Settings → dbt. `manual` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql `CREATE TABLE` / `INSERT`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a `source`: the declared relation and the dbt model land on ONE graph node, and a downstream `// on dbt://<warehouse>/<schema>/<name>` fires when the ingestion node completes.\n\nA managed `// materialize ducklake://<name>/<table>` tells the runtime to write the node's output table **for you**: write the body as a single `SELECT` and the runtime wraps it in the create/replace — do **not** also write your own `CREATE TABLE` / `INSERT`. (The opposite holds for the `dbt://` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy:\n\n- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change);\n- `// materialize <uri> append` → INSERT-append rows (incremental);\n- `// materialize <uri> key=<col>` → merge/upsert on `<col>`.\n\n`// materialize manual <uri>` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage.\n\n`materialize` pairs with partitioning for incremental pipelines: a `// partitioned <daily|hourly|weekly|monthly|dynamic>` node runs **once per partition** (append/merge into a fixed-schema table). The `{partition}` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**:\n\n- To filter the source to the active slice on a time grain, use the runtime-injected macro: `WHERE wm_partition(<ts_col>) = {partition}`. `wm_partition(ts)` buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a `strftime` format.\n- Do NOT write `= TIMESTAMP {partition}`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time.\n- For `dynamic` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: `WHERE <your_key_col> = {partition}`.\n\n`materialize` is an output **declaration** on a node — not a command. There is no \"materialize run\".\n\n## Declared metrics (`measure` / `dimension`)\n\nOn a node that materializes a DuckLake table, `// measure <name> = <aggregate> [where <predicate>]` names the canonical way to aggregate that table (e.g. `// measure revenue = sum(amount) where not is_refund`), and `// dimension <name> = <expr>` names a way to slice it (e.g. `// dimension region = region`, `// dimension month = date_trunc('month', ordered_at)`). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing.\n\n- Keep the predicate in the `where` clause rather than folding it into the aggregate: it is rendered as `<agg> FILTER (WHERE <pred>)`, which is what lets two measures with different predicates share one GROUP BY.\n- DuckLake-only, and only meaningful next to `// materialize`.\n- Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures — an obvious `count(*)` earns nothing.\n- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it.\n\n## How to build one\n\n1. Put every node in the **same folder**: `f/<folder>/<name>`. The folder is the pipeline.\n2. Write each node as its own script. Default to `duckdb` materializing into DuckLake (see \"Default to DuckDB + DuckLake\" above); pick `postgresql`, `bun`, or `python3` only when that section says the work calls for it.\n3. Start each body with `// pipeline`, then the `// on` input declarations, then the transform that writes the output.\n4. **Chain nodes by asset URI**: read an upstream node's output asset, then `// on <that-same-uri>` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones.\n5. Don't deploy nodes unless the user asks to. A pipeline only \"runs\" once its scripts are deployed and their triggers exist.\n\n## Example (DuckDB → DuckLake, scheduled ingest + downstream transform)\n\nNode `f/sales/orders_ingest` (runs on a schedule, materializes a DuckLake table):\n\n```sql\n-- pipeline\n-- on schedule\n-- materialize ducklake://main/orders\nSELECT * FROM read_csv('s3:///raw/orders/*.csv')\n```\n\nNode `f/sales/orders_daily` (runs when `orders` is produced, writes a rollup):\n\n```sql\n-- pipeline\n-- on ducklake://main/orders\n-- materialize ducklake://main/orders_daily\nSELECT date_trunc('day', ts) AS day, count(*) AS n\nFROM ducklake.main.orders GROUP BY 1\n```\n", + "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/prompt.json b/backend/windmill-api/src/mcp/session/gen/prompt.json new file mode 100644 index 0000000000..b3ffea17b7 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/prompt.json @@ -0,0 +1,14 @@ +{ + "base": "You are working in a Windmill workspace through its MCP server.\n\nThe user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call \"the preview\"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.\n\nThe current user's workspace username is \"{{username}}\".\n\nUse tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.\n\nRules:\n- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.\n- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.\n- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.\n- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.\n- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.\n- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\") — a bare \"run X\" is not that. For those two, read the item with read_workspace_item version: \"deployed\" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.\n\nPath conventions:\n- A workspace path starts with one of two namespaces; its trailing <name> may itself contain \"/\", so a path has three or more segments:\n - `u/{{username}}/<name>` — your personal scope. Default for ad-hoc, exploratory, or scratch work.\n - `f/<folder>/<name>` — a shared folder scope; the <folder> must already exist (a bare `f/<name>` with no folder segment is INVALID and will fail).\n- If the user supplies a fully qualified `f/<folder>/...` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a `u/{{username}}/...` path unless a tool rejects it.\n- Default a bare name with no namespace prefix (e.g. \"create a flow called myflow\") to `u/{{username}}/<name>`. Never invent an `f/<folder>/...` path for a folder that does not exist; create one with `create_folder` only when the user explicitly asks for a new folder.\n{{folder_guidance}}\nMore rules:\n- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.\n- An agent, as the Agents page lists it, is a resource of type `ai_agent`: read it with read_workspace_item type \"resource\". You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there.\n- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so \"delete it\" / \"never mind\" / \"remove that\" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.\n- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against=\"parent_workspace\" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.\n- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. \"$var:path/to/variable\" is how a resource value references a variable — it is never a variable's own value.\n- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.\n- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.\n- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under `hub/<version>/<app>/<name>` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type \"script\" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its `integration` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the `suggested_integrations` a search hands back when it finds nothing.\n- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration `documented` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.\n- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself.\n- create_resource_type creates a resource type the workspace lacks. Show the user the schema you propose and create it once they agree.\n- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n- A \"data pipeline\" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file `pipeline` / `on <ref>` annotation comments written in each script's comment syntax (`--` for SQL, `#` for Python/Bash, `//` for TS — a `//` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject \"pipeline\" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.\n- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.\n- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.\n- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).\n- Keep context targeted.\n\nDocumentation:\n- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or \"does Windmill support X\") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.\n- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.\n- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.\n- If the first search returns nothing useful, retry with different or broader keywords before giving up.\n- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.\n\nFlows:\n- read_workspace_item returns compact flow JSON. Inline script bodies appear as \"inline_script.<moduleId>\".\n- Use read_flow_module_code and set_flow_module_code for inline script bodies.\n- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.\n\nRaw apps:\n- The app tools below only work on raw (code) apps. `rawApp` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.\n- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.\n- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.\n- Use write_app_file, patch_app_file, and delete_app_file for frontend files.\n- Use write_app_runnable and delete_app_runnable for backend runnables.\n- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with the user BEFORE init_app or any SQL (get_instructions subject \"app\" lists the questions), then pass the outcome as init_app's data.\n- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.\n\nData Tables:\n- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.\n- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a \"main\" datatable exists or call exec_datatable_sql.\n- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.\n- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.\n- A datatable that list_datatables reports with `migrations_enabled` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject \"datatable\" before your first one.\n- A datatable can be under roles; list_datatables then reports its `usable_roles` and `default_role`. When working on a raw app that pins a role (`data.roles` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a `permission denied`, call get_instructions with subject \"datatable\".\n- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject \"datatable\" and language \"bun\" for the TypeScript SQL SDK reference (or language \"python3\" for Python) — it returns only that language so you get just what you need.", + "folder_guidance": { + "admin": "- As a workspace admin you can write to any existing folder. Folders here include {{writable_folders}} (you can also write to others not listed; list_workspace_items with type \"folder\" lists them all). If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", + "admin_no_folders": "- As a workspace admin you can write to any existing folder. If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", + "member": "- Folders you can write to in this workspace: {{writable_folders}}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use rather than inventing a path. Use `create_folder` only when the user explicitly asks for a new folder.\n", + "member_no_folders": "- You have no shared folders you can write to in this workspace, so use `u/{{username}}/<name>`. If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask before placing shared work rather than inventing an `f/<folder>/...` path.\n", + "member_readonly": "- Folders you can write to in this workspace: {{writable_folders}}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use rather than inventing a path. Use `create_folder` only when the user explicitly asks for a new folder.\n- You can see but NOT write to: {{readonly_folders}} — never create or deploy items there.\n", + "member_no_folders_readonly": "- You have no shared folders you can write to in this workspace, so use `u/{{username}}/<name>`. If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask before placing shared work rather than inventing an `f/<folder>/...` path.\n- You can see but NOT write to: {{readonly_folders}} — never create or deploy items there.\n" + }, + "skills_header": "\n\nSkills:\n- Skills are reusable instruction sets available in this workspace, each covering a specific kind of task. The available skills are listed below by resource path and description.\n- When a user's request matches a skill's description, call read_skill with its exact path to load the full instructions BEFORE acting, then follow them.\n", + "skill_line": "- {{skill_path}}: {{skill_description}}", + "workspace_instructions": "\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n{{workspace_instructions}}" +} diff --git a/backend/windmill-api/src/mcp/session/gen/schemas.json b/backend/windmill-api/src/mcp/session/gen/schemas.json new file mode 100644 index 0000000000..f15242d1aa --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/schemas.json @@ -0,0 +1,16 @@ +{ + "trigger": { + "http": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when triggered\",\n \"type\": \"string\"\n },\n \"route_path\": {\n \"description\": \"The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). Must NOT start with a /.\",\n \"type\": \"string\"\n },\n \"workspaced_route\": {\n \"description\": \"If true, the route includes the workspace ID in the path\",\n \"type\": \"boolean\"\n },\n \"summary\": {\n \"anyOf\": [\n {\n \"description\": \"Short summary describing the purpose of this trigger\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"description\": {\n \"anyOf\": [\n {\n \"description\": \"Detailed description of what this trigger does\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"static_asset_config\": {\n \"anyOf\": [\n {\n \"description\": \"Configuration for serving static assets (s3 bucket, storage path, filename)\",\n \"type\": \"object\",\n \"properties\": {\n \"s3\": {\n \"description\": \"S3 bucket path for static assets\",\n \"type\": \"string\"\n },\n \"storage\": {\n \"description\": \"Storage path for static assets\",\n \"type\": \"string\"\n },\n \"filename\": {\n \"description\": \"Filename for the static asset\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"s3\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"http_method\": {\n \"description\": \"HTTP method (get, post, put, delete, patch) that triggers this endpoint\",\n \"type\": \"string\",\n \"enum\": [\n \"get\",\n \"post\",\n \"put\",\n \"delete\",\n \"patch\"\n ]\n },\n \"authentication_resource_path\": {\n \"anyOf\": [\n {\n \"description\": \"Path to the resource containing authentication configuration (for api_key, basic_http, custom_script, signature methods)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"is_async\": {\n \"description\": \"Deprecated, use request_type instead\",\n \"type\": \"boolean\"\n },\n \"request_type\": {\n \"description\": \"How the request is handled - 'sync' waits for result, 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events\",\n \"type\": \"string\",\n \"enum\": [\n \"sync\",\n \"async\",\n \"sync_sse\"\n ]\n },\n \"authentication_method\": {\n \"description\": \"How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature'\",\n \"type\": \"string\",\n \"enum\": [\n \"none\",\n \"windmill\",\n \"api_key\",\n \"basic_http\",\n \"custom_script\",\n \"signature\"\n ]\n },\n \"is_static_website\": {\n \"description\": \"If true, serves static files from S3/storage instead of running a script\",\n \"type\": \"boolean\"\n },\n \"wrap_body\": {\n \"description\": \"If true, wraps the request body in a 'body' parameter\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"raw_string\": {\n \"description\": \"If true, passes the request body as a raw string instead of parsing as JSON\",\n \"type\": \"boolean\"\n },\n \"allowed_origins\": {\n \"anyOf\": [\n {\n \"description\": \"Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. When set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin the runnable returns via wm_headers. Use ['*'] to opt out of any restriction, including the http_route_default_allowed_origins instance setting. An empty list is not a configuration and resolves exactly as null does. When null, the instance setting applies, or Access-Control-Allow-Origin: * if it is unset. Ignored on a static website, which has no authentication of its own and so hands out public files: restricting which browsers may read them protects nothing while breaking cross-origin webfonts and fetches. A single-file static asset is not exempt, since it can carry an authentication_method.\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"route_path\",\n \"is_flow\",\n \"http_method\",\n \"authentication_method\",\n \"is_static_website\"\n ],\n \"additionalProperties\": false\n}", + "websocket": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"url\": {\n \"description\": \"The WebSocket URL to connect to (can be a static URL or computed by a runnable)\",\n \"type\": \"string\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"filters\": {\n \"description\": \"Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`.\",\n \"type\": \"array\",\n \"items\": {\n \"description\": \"Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) — addressed by `key` for a top-level field or `path` for a dotted path into nested objects — or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\\n\",\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"key\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays.\",\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"path\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"any_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"any_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"all_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"all_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"none_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"none_of\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"filter_logic\": {\n \"default\": \"and\",\n \"description\": \"Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.\",\n \"type\": \"string\",\n \"enum\": [\n \"and\",\n \"or\"\n ]\n },\n \"initial_messages\": {\n \"anyOf\": [\n {\n \"description\": \"Messages to send immediately after connecting (can be raw strings or computed by runnables)\",\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"raw_message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"raw_message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"runnable_result\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"path\",\n \"args\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"runnable_result\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"url_runnable_args\": {\n \"anyOf\": [\n {\n \"description\": \"Arguments to pass to the script/flow that computes the WebSocket URL\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"can_return_message\": {\n \"description\": \"If true, the script can return a message to send back through the WebSocket\",\n \"type\": \"boolean\"\n },\n \"can_return_error_result\": {\n \"description\": \"If true, error results are sent back through the WebSocket\",\n \"type\": \"boolean\"\n },\n \"heartbeat\": {\n \"anyOf\": [\n {\n \"description\": \"Optional periodic heartbeat message configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"interval_secs\": {\n \"description\": \"Interval in seconds between heartbeat messages\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"message\": {\n \"description\": \"Message to send as heartbeat. Use {{state}} as a placeholder for a value extracted from incoming messages (see state_field).\",\n \"type\": \"string\"\n },\n \"state_field\": {\n \"description\": \"Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message.\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"interval_secs\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"url\",\n \"filters\",\n \"can_return_message\",\n \"can_return_error_result\"\n ],\n \"additionalProperties\": false\n}", + "kafka": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"kafka_resource_path\": {\n \"description\": \"Path to the Kafka resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"group_id\": {\n \"description\": \"Kafka consumer group ID for this trigger\",\n \"type\": \"string\"\n },\n \"topics\": {\n \"description\": \"Array of Kafka topic names to subscribe to\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"filters\": {\n \"description\": \"Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`.\",\n \"type\": \"array\",\n \"items\": {\n \"description\": \"Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) — addressed by `key` for a top-level field or `path` for a dotted path into nested objects — or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\\n\",\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"key\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays.\",\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"path\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"any_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"any_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"all_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"all_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"none_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"none_of\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"filter_logic\": {\n \"default\": \"and\",\n \"description\": \"Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.\",\n \"type\": \"string\",\n \"enum\": [\n \"and\",\n \"or\"\n ]\n },\n \"auto_offset_reset\": {\n \"default\": \"latest\",\n \"description\": \"Initial offset behavior when consumer group has no committed offset.\",\n \"type\": \"string\",\n \"enum\": [\n \"latest\",\n \"earliest\"\n ]\n },\n \"auto_commit\": {\n \"default\": true,\n \"description\": \"When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint.\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"kafka_resource_path\",\n \"group_id\",\n \"topics\",\n \"filters\"\n ],\n \"additionalProperties\": false\n}", + "nats": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"nats_resource_path\": {\n \"description\": \"Path to the NATS resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"use_jetstream\": {\n \"description\": \"If true, uses NATS JetStream for durable message delivery\",\n \"type\": \"boolean\"\n },\n \"stream_name\": {\n \"anyOf\": [\n {\n \"description\": \"JetStream stream name (required when use_jetstream is true)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"consumer_name\": {\n \"anyOf\": [\n {\n \"description\": \"JetStream consumer name (required when use_jetstream is true)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"subjects\": {\n \"description\": \"Array of NATS subjects to subscribe to\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"nats_resource_path\",\n \"use_jetstream\",\n \"subjects\"\n ],\n \"additionalProperties\": false\n}", + "postgres": "{\n \"type\": \"object\",\n \"properties\": {\n \"replication_slot_name\": {\n \"description\": \"Name of the PostgreSQL logical replication slot to use\",\n \"type\": \"string\"\n },\n \"publication_name\": {\n \"description\": \"Name of the PostgreSQL publication to subscribe to for change data capture\",\n \"type\": \"string\"\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when database changes are detected\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"postgres_resource_path\": {\n \"description\": \"Path to the PostgreSQL resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"publication\": {\n \"description\": \"Configuration for creating/managing the publication (tables, operations)\",\n \"type\": \"object\",\n \"properties\": {\n \"table_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"schema_name\": {\n \"type\": \"string\"\n },\n \"table_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"table_name\": {\n \"type\": \"string\"\n },\n \"columns_name\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"where_clause\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"table_name\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"schema_name\",\n \"table_to_track\"\n ],\n \"additionalProperties\": false\n }\n },\n \"transaction_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"transaction_to_track\"\n ],\n \"additionalProperties\": false\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"postgres_resource_path\"\n ],\n \"additionalProperties\": false\n}", + "mqtt": "{\n \"type\": \"object\",\n \"properties\": {\n \"mqtt_resource_path\": {\n \"description\": \"Path to the MQTT resource containing broker connection configuration\",\n \"type\": \"string\"\n },\n \"subscribe_topics\": {\n \"description\": \"Array of MQTT topics to subscribe to, each with topic name and QoS level\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"qos\": {\n \"type\": \"string\",\n \"enum\": [\n \"qos0\",\n \"qos1\",\n \"qos2\"\n ]\n },\n \"topic\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"qos\",\n \"topic\"\n ],\n \"additionalProperties\": false\n }\n },\n \"client_id\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT client ID for this connection\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"v3_config\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT v3 specific configuration (clean_session)\",\n \"type\": \"object\",\n \"properties\": {\n \"clean_session\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"v5_config\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval)\",\n \"type\": \"object\",\n \"properties\": {\n \"clean_start\": {\n \"type\": \"boolean\"\n },\n \"topic_alias_maximum\": {\n \"type\": \"number\"\n },\n \"session_expiry_interval\": {\n \"type\": \"number\"\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"client_version\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT protocol version ('v3' or 'v5')\",\n \"type\": \"string\",\n \"enum\": [\n \"v3\",\n \"v5\"\n ]\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"mqtt_resource_path\",\n \"subscribe_topics\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "amqp": "{\n \"type\": \"object\",\n \"properties\": {\n \"amqp_resource_path\": {\n \"description\": \"Path to the AMQP resource containing broker connection configuration\",\n \"type\": \"string\"\n },\n \"queue_name\": {\n \"description\": \"Name of the queue to consume messages from\",\n \"type\": \"string\"\n },\n \"exchange\": {\n \"anyOf\": [\n {\n \"description\": \"Optional exchange binding for the consumed queue\",\n \"type\": \"object\",\n \"properties\": {\n \"exchange_name\": {\n \"description\": \"Name of the exchange to bind the consumed queue to\",\n \"type\": \"string\"\n },\n \"routing_keys\": {\n \"description\": \"Routing keys used to bind the queue to the exchange\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"exchange_name\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"options\": {\n \"anyOf\": [\n {\n \"description\": \"Optional consumer options (queue declaration, prefetch)\",\n \"type\": \"object\",\n \"properties\": {\n \"declare_queue\": {\n \"description\": \"Declare the queue (durable) before consuming; when false the queue is declared passively and must already exist\",\n \"type\": \"boolean\"\n },\n \"prefetch_count\": {\n \"description\": \"Maximum number of unacknowledged messages the broker delivers at once (1-65535)\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 65535\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"amqp_resource_path\",\n \"queue_name\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "sqs": "{\n \"type\": \"object\",\n \"properties\": {\n \"queue_url\": {\n \"description\": \"The full URL of the AWS SQS queue to poll for messages\",\n \"type\": \"string\"\n },\n \"aws_auth_resource_type\": {\n \"description\": \"Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect\",\n \"type\": \"string\",\n \"enum\": [\n \"oidc\",\n \"credentials\"\n ]\n },\n \"aws_resource_path\": {\n \"description\": \"Path to the AWS resource containing credentials or OIDC configuration\",\n \"type\": \"string\"\n },\n \"message_attributes\": {\n \"anyOf\": [\n {\n \"description\": \"Array of SQS message attribute names to include with each message\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"queue_url\",\n \"aws_auth_resource_type\",\n \"aws_resource_path\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "gcp": "{\n \"description\": \"Data for creating or updating a Google Cloud Pub/Sub trigger.\",\n \"type\": \"object\",\n \"properties\": {\n \"gcp_resource_path\": {\n \"description\": \"Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select.\",\n \"type\": \"string\"\n },\n \"project_id\": {\n \"description\": \"GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is.\",\n \"type\": \"string\"\n },\n \"subscription_mode\": {\n \"description\": \"The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new subscription.\",\n \"type\": \"string\",\n \"enum\": [\n \"existing\",\n \"create_update\"\n ]\n },\n \"topic_id\": {\n \"description\": \"Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects/<project>/topics/<id>).\",\n \"type\": \"string\"\n },\n \"subscription_id\": {\n \"description\": \"Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>).\",\n \"type\": \"string\"\n },\n \"base_endpoint\": {\n \"description\": \"Base URL for push delivery endpoint.\",\n \"type\": \"string\"\n },\n \"delivery_type\": {\n \"description\": \"Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger actively fetches messages.\",\n \"type\": \"string\",\n \"enum\": [\n \"push\",\n \"pull\"\n ]\n },\n \"delivery_config\": {\n \"anyOf\": [\n {\n \"description\": \"Configuration for push delivery mode.\",\n \"type\": \"object\",\n \"properties\": {\n \"audience\": {\n \"description\": \"The audience claim for OIDC tokens used in push authentication.\",\n \"type\": \"string\"\n },\n \"authenticate\": {\n \"description\": \"If true, push messages will include OIDC authentication tokens.\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"authenticate\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received.\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script.\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"auto_acknowledge_msg\": {\n \"description\": \"If true, automatically acknowledge messages after processing.\",\n \"type\": \"boolean\"\n },\n \"ack_deadline\": {\n \"description\": \"Time in seconds within which the message must be acknowledged. If not provided, defaults to the subscription's acknowledgment deadline (600 seconds).\",\n \"type\": \"integer\",\n \"minimum\": 10,\n \"maximum\": 600\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script or flow to run when the triggered job fails.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler.\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions.\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"subscription_mode\",\n \"topic_id\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "azure": "{\n \"description\": \"Data for creating or updating an Azure Event Grid trigger.\",\n \"type\": \"object\",\n \"properties\": {\n \"azure_resource_path\": {\n \"type\": \"string\"\n },\n \"azure_mode\": {\n \"description\": \"Azure Event Grid trigger mode.\",\n \"type\": \"string\",\n \"enum\": [\n \"basic_push\",\n \"namespace_push\",\n \"namespace_pull\"\n ]\n },\n \"scope_resource_id\": {\n \"type\": \"string\"\n },\n \"topic_name\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"subscription_name\": {\n \"type\": \"string\"\n },\n \"base_endpoint\": {\n \"description\": \"Base URL for push delivery endpoints (push modes only).\",\n \"type\": \"string\"\n },\n \"event_type_filters\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"azure_resource_path\",\n \"azure_mode\",\n \"scope_resource_id\",\n \"subscription_name\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "email": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"script_path\": {\n \"type\": \"string\"\n },\n \"local_part\": {\n \"type\": \"string\"\n },\n \"workspaced_local_part\": {\n \"type\": \"boolean\"\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n },\n \"error_handler_path\": {\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"local_part\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}" + }, + "schedule": "{\n \"type\": \"object\",\n \"properties\": {\n \"on_failure_times\": {\n \"anyOf\": [\n {\n \"description\": \"Number of consecutive failures before the on_failure handler is triggered (default 1)\",\n \"type\": \"number\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_failure_exact\": {\n \"anyOf\": [\n {\n \"description\": \"If true, trigger on_failure handler only on exactly N failures, not on every failure after N\",\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_failure_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_recovery_times\": {\n \"anyOf\": [\n {\n \"description\": \"Number of consecutive successes before the on_recovery handler is triggered (default 1)\",\n \"type\": \"number\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_recovery_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_success_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"ws_error_handler_muted\": {\n \"description\": \"If true, the workspace-level error handler will not be triggered for this schedule's failures\",\n \"type\": \"boolean\"\n },\n \"retry\": {\n \"anyOf\": [\n {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"no_flow_overlap\": {\n \"description\": \"If true, skip this schedule's execution if the previous run is still in progress (prevents concurrent runs)\",\n \"type\": \"boolean\"\n },\n \"tag\": {\n \"anyOf\": [\n {\n \"description\": \"Worker tag to route jobs to specific worker groups\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"paused_until\": {\n \"anyOf\": [\n {\n \"description\": \"ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time\",\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^(?:(?:\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\\\d|30)|(?:02)-(?:0[1-9]|1\\\\d|2[0-8])))T(?:(?:[01]\\\\d|2[0-3]):[0-5]\\\\d(?::[0-5]\\\\d(?:\\\\.\\\\d+)?)?(?:Z|([+-](?:[01]\\\\d|2[0-3]):[0-5]\\\\d)))$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"cron_version\": {\n \"anyOf\": [\n {\n \"description\": \"Cron parser version. Use 'v2' for extended syntax with additional features\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"dynamic_skip\": {\n \"anyOf\": [\n {\n \"description\": \"Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this schedule runs as. Used during deployment to preserve the original schedule owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n}" +} diff --git a/backend/windmill-api/src/mcp/session/gen/tools.json b/backend/windmill-api/src/mcp/session/gen/tools.json new file mode 100644 index 0000000000..4db0f3e4a4 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/tools.json @@ -0,0 +1,2543 @@ +[ + { + "name": "read_skill", + "description": "Load the full instructions for an AI skill by resource path. Skills are listed in the system prompt under \"Skills\"; call this before acting on a task a skill covers, then follow its instructions.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The exact skill resource path as listed in the Skills section of the system prompt.", + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "open_page", + "description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.", + "parameters": { + "type": "object", + "properties": { + "page": { + "description": "Which page to open", + "type": "string", + "enum": [ + "runs", + "schedules", + "variables", + "resources", + "assets", + "audit_logs", + "folders", + "groups", + "triggers", + "compare" + ] + }, + "path": { + "description": "Runs/Schedules/Variables/Resources/Assets: the script, flow or item path to filter by. On Runs, comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter).", + "type": "string" + }, + "status": { + "description": "Runs: filter by job execution status", + "type": "string", + "enum": [ + "running", + "success", + "failure", + "canceled", + "waiting", + "suspended" + ] + }, + "schedule_path": { + "description": "Runs: only runs triggered by this schedule path. Schedules: filter by this exact schedule path.", + "type": "string" + }, + "job_kinds": { + "description": "Runs: filter by job category (defaults to top-level runs)", + "type": "string", + "enum": [ + "all", + "runs", + "dependencies", + "previews", + "deploymentcallbacks" + ] + }, + "user": { + "description": "Runs: filter by the user who created the job. comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter). (e.g. 'admin' or '!admin')", + "type": "string" + }, + "folder": { + "description": "Runs: filter by the folder containing the script or flow — one folder name, without the 'f/' prefix and without ! or commas (use path for anything finer)", + "type": "string" + }, + "job_trigger_kind": { + "description": "Runs: filter by how the job was triggered — one of webhook, default_email, http, websocket, kafka, email, nats, mqtt, amqp, sqs, postgres, schedule, gcp, azure, google, github, asset, freshness, app. comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter). (e.g. '!schedule' to hide scheduled runs)", + "type": "string" + }, + "label": { + "description": "Runs: filter by a custom label attached to the job. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "tag": { + "description": "Runs: filter by worker tag. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "worker": { + "description": "Runs: filter by the worker instance that ran the job. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "concurrency_key": { + "description": "Runs: filter by concurrency limit key, e.g. custom-key or a full script path. Cannot be combined with worker, search, or a waiting/suspended status — that view has no way to apply them.", + "type": "string" + }, + "arg": { + "description": "Runs: only runs whose arguments contain these key/value pairs, as a JSON object string, e.g. {\"customer_id\":\"42\"}", + "type": "string" + }, + "result": { + "description": "Runs: only runs whose result contains these key/value pairs, as a JSON object string, e.g. {\"status\":\"ko\"}", + "type": "string" + }, + "search": { + "description": "Runs: free-text search matched case-insensitively across several run fields at once. Prefer a specific filter when you know which one applies.", + "type": "string" + }, + "timeframe": { + "description": "Runs: relative time window to look at, ending now. Ignored when min_ts or max_ts is set.", + "type": "string", + "enum": [ + "Latest runs", + "Within 30 seconds", + "Within last minute", + "Within last 5 minutes", + "Within last 30 minutes", + "Within last 24 hours", + "Within last 7 days", + "Within last month" + ] + }, + "min_ts": { + "description": "Runs: only runs after this instant, as an ISO 8601 timestamp (e.g. 2026-08-01T09:00:00Z); a bare 2026-08-01 means local midnight. Use it with max_ts for an absolute window; prefer timeframe for a relative one.", + "type": "string" + }, + "max_ts": { + "description": "Runs: only runs before this instant, as an ISO 8601 timestamp. A bare 2026-08-01 means local midnight, so it excludes that day — pass the next day, or an explicit time, to include it.", + "type": "string" + }, + "resolved": { + "description": "Runs: filter failures by whether they have been marked as handled — 'unresolved' hides the ones already resolved", + "type": "string", + "enum": [ + "all", + "unresolved", + "resolved" + ] + }, + "show_skipped": { + "description": "Runs: include skipped flow steps (excluded by default)", + "type": "boolean" + }, + "show_future_jobs": { + "description": "Runs: include jobs scheduled for later (included by default — pass false to hide)", + "type": "boolean" + }, + "open": { + "description": "Schedules/Triggers/Variables/Resources: exact item path to open in its editor, e.g. f/foo/my_schedule — in a session, a preview tab of its own instead of the list page. Use it whenever the user should act on one specific item (e.g. fill in credentials) so they land directly in its editor.", + "type": "string" + }, + "summary": { + "description": "Schedules: search text matched against schedule summaries", + "type": "string" + }, + "trigger_kind": { + "description": "Triggers: which trigger kind page to open", + "type": "string", + "enum": [ + "http", + "websocket", + "postgres", + "mqtt", + "amqp", + "email" + ] + }, + "resource_type": { + "description": "Resources: filter by resource type, e.g. postgres", + "type": "string" + }, + "owner": { + "description": "Variables/Resources: filter by owner, e.g. u/alice or f/team", + "type": "string" + }, + "username": { + "description": "Audit logs: filter by the acting username", + "type": "string" + }, + "operation": { + "description": "Audit logs: filter by operation, e.g. jobs.run", + "type": "string" + }, + "resource": { + "description": "Audit logs: filter by the affected resource path", + "type": "string" + }, + "items": { + "description": "Compare: preselect exactly these fork changes, each as '<kind>:<path>' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to use the page's default selection; pass items only when the user asked to deploy specific ones.", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "new_tab": { + "description": "Open in a NEW preview tab instead of updating the tab already showing this page. Only set true when the user explicitly asks for a new or separate tab; by default changing filters reuses the existing tab.", + "type": "boolean" + } + }, + "required": [ + "page" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_instructions", + "description": "Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.", + "parameters": { + "type": "object", + "properties": { + "subject": { + "description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.", + "type": "string", + "enum": [ + "script", + "flow", + "resource", + "app", + "datatable", + "pipeline", + "workspace_settings" + ] + }, + "language": { + "description": "The target language. Required when subject is script. For subjects \"datatable\" and \"app\" it selects which SDK reference to return (e.g. \"bun\" for TypeScript, \"python3\" for Python) and defaults to TypeScript if omitted. Use the existing language when modifying, or the requested target language when creating. Other subjects ignore it.", + "type": "string", + "enum": [ + "python3", + "deno", + "go", + "bash", + "powershell", + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "graphql", + "nativets", + "bun", + "php", + "rust", + "ansible", + "csharp", + "nu", + "java", + "ruby", + "rlang", + "duckdb", + "bunnative", + "dbt" + ] + } + }, + "required": [ + "subject" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_hub_scripts", + "description": "Search the Windmill Hub for prebuilt integration scripts, or list one integration's scripts with `integration`.", + "parameters": { + "type": "object", + "properties": { + "query": { + "description": "What you want the script to do, e.g. \"send email\", \"list stripe invoices\". Semantic search, so describe the intent. Omit when browsing with `integration` alone.", + "type": "string" + }, + "integration": { + "description": "Integration slug (e.g. \"stripe\") from a previous result's `integration` or `suggested_integrations`, or the vendor the user named. Alone, lists that integration's scripts with descriptions, including ones that do not match your task but show how the integration is used. With `query`, narrows the search to it; pass it whenever the user names the vendor, since ranking alone can surface another integration's scripts that merely mention it.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_hub_integration", + "description": "Read how one integration works before writing code against it: the resource type it takes, its auth fields, and its most-used scripts as examples. For an integration a search marked `documented` it also returns provider knowledge checked against the live API: pagination, enums, error codes and gotchas.", + "parameters": { + "type": "object", + "properties": { + "integration": { + "description": "Integration slug, e.g. \"stripe\". Take it from a search_hub_scripts result's `integration`, or guess the vendor name: a wrong guess comes back with the closest real slugs.", + "type": "string" + } + }, + "required": [ + "integration" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_npm_packages", + "description": "Search for npm packages and their documentation", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The query to search for" + } + }, + "required": [ + "query" + ] + }, + "read_only": true + }, + { + "name": "search_docs", + "description": "Full-text search across the entire Windmill documentation. Provide one or more keywords; returns the most relevant docs pages, each with its Source URL and short matching snippets. Use this FIRST to find relevant pages by their content (a flag, function, error message, config key or concept). If the snippets answer the question, answer directly; otherwise call read_docs_page with a returned Source URL to read the full page or a section.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Keywords to search for in the documentation body, e.g. \"chromium worker tag\" or \"retry exponential backoff\". Fewer, more distinctive words match better." + } + }, + "required": [ + "query" + ] + }, + "read_only": true + }, + { + "name": "read_docs_page", + "description": "Fetch the raw markdown of a single Windmill documentation page. Provide the `url` of a page found via search_docs (its Source URL). If the page is large, this returns its list of section headings instead of the full content; call again with the `section` argument set to one of those headings to read that section.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The docs page to read, as a Source URL returned by search_docs (e.g. https://www.windmill.dev/docs/core_concepts/jobs). A bare path (e.g. /docs/core_concepts/jobs) is also accepted." + }, + "section": { + "type": "string", + "description": "Optional. A heading title from the page outline to read just that section instead of the full page." + } + }, + "required": [ + "url" + ] + }, + "read_only": true + }, + { + "name": "askUserQuestion", + "description": "Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.", + "parameters": { + "type": "object", + "properties": { + "question": { + "description": "The concise question to show to the user before continuing.", + "type": "string", + "minLength": 1 + }, + "choices": { + "description": "Two to ten proposed answer strings.", + "minItems": 2, + "maxItems": 10, + "type": "array", + "items": { + "description": "Proposed answer text shown to the user and returned as-is.", + "type": "string", + "minLength": 1 + } + }, + "multiSelect": { + "description": "When true, the user may select several answers; use only when the choices can genuinely co-apply (not mutually exclusive). Defaults to single-select.", + "type": "boolean" + } + }, + "required": [ + "question", + "choices" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "update_user_instructions", + "description": "Modify your own persistent personal instructions for the Global assistant. Use when the user asks you to remember a preference, always/never do something, or change/stop a behavior. Edits only the user-level instructions (the USER INSTRUCTIONS block, not workspace-level); changes persist in this browser and take effect on your next message.", + "parameters": { + "type": "object", + "properties": { + "operation": { + "description": "What to do with your personal Global instructions. 'append' adds a new instruction to the end (use for \"remember this\" / \"always do X\"). 'replace' performs an exact find-and-replace to edit or remove existing text (use for \"change X\" / \"stop doing Y\"); set new_string to \"\" to remove.", + "type": "string", + "enum": [ + "append", + "replace" + ] + }, + "text": { + "description": "Required when operation is 'append': the instruction to add. Ignored for 'replace'.", + "type": "string", + "minLength": 1 + }, + "old_string": { + "description": "Required when operation is 'replace': exact text to find in your current personal instructions. Ignored for 'append'.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Required when operation is 'replace': replacement text. Use an empty string to delete the matched text. Ignored for 'append'.", + "type": "string" + }, + "replace_all": { + "description": "For operation 'replace': when true, replace every exact match; when false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "operation", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_workspace_items", + "description": "List workspace items and drafts. Returns metadata only, up to limit items per item type per page (default 50); pass page to continue past a full page.", + "parameters": { + "type": "object", + "properties": { + "types": { + "description": "Optional item types to list. Defaults to scripts and flows. Pass schedule or trigger explicitly when needed (listing triggers spans 9 kinds and is heavier). \"folder\" lists folders with their owners and extraPerms; \"group\" lists groups with their extraPerms (read one for its members). path_prefix does not apply to these two.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app", + "folder", + "group" + ] + } + }, + "query": { + "description": "Optional case-insensitive path or summary search string.", + "type": "string" + }, + "path_prefix": { + "description": "Optional path prefix filter, such as f/ or u/user/.", + "type": "string" + }, + "limit": { + "description": "Maximum items per item type per page (for triggers, per trigger kind). Defaults to 50 and is capped at 100.", + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "page": { + "description": "Page number, starting at 1. Each item type pages independently: request the next page while any type still returns a full page. Drafts appear on page 1 only, capped at limit per type.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_pipelines", + "description": "List the data pipelines of the workspace: each folder holding at least one pipeline script, with its number of scripts. A pipeline is named by its folder, not by an item path.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "read_workspace_item", + "description": "Read one workspace item or draft. Prefers your draft when one exists; pass version: \"deployed\" to read the deployed state instead. A deployed item carries extraPerms (a draft does not; pass version: \"deployed\" for it): who it is shared with beyond its owner, as u/<user> or g/<group> → true (write) or false (read-only). A folder returns its owners and extraPerms; a group returns its members and extraPerms (who may manage it). For a folder or group, path is its name. A g/<group> entry grants every member of that group (g/all is everyone), and workspace admins can read and write every folder and item whatever these lists say.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app", + "folder", + "group" + ] + }, + "path": { + "description": "Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script. For a folder or group, its name.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Identifies which trigger service to call.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "version": { + "description": "Pass \"deployed\" to read the deployed workspace state even when a draft exists (e.g. to learn the deployed input schema before running the deployed version). Default reads your draft when one exists.", + "type": "string", + "enum": [ + "deployed" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_folder", + "description": "Create a new shared folder (addressable as f/<name>/) in the workspace. The current user is added as an owner.", + "parameters": { + "type": "object", + "properties": { + "name": { + "description": "Folder name — letters, digits, underscores or hyphens only. The folder becomes addressable as `f/<name>/<item>`.", + "type": "string" + }, + "summary": { + "description": "Optional human-readable description of the folder.", + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_script", + "description": "Create or overwrite a draft script.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script, e.g. f/folder/name or u/user/name.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary.", + "type": "string" + }, + "language": { + "description": "Script language.", + "type": "string", + "enum": [ + "python3", + "deno", + "go", + "bash", + "powershell", + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "graphql", + "nativets", + "bun", + "php", + "rust", + "ansible", + "csharp", + "nu", + "java", + "ruby", + "rlang", + "duckdb", + "bunnative", + "dbt" + ] + }, + "content": { + "description": "Full script source code.", + "type": "string" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "language", + "content" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_flow", + "description": "Create or overwrite a draft flow.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow, e.g. f/folder/name or u/user/name.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary.", + "type": "string" + }, + "description": { + "description": "Longer human-readable description of what the flow does. Top-level flow metadata, separate from the modules — not part of the compact value patched by patch_flow_json.", + "type": "string" + }, + "modules": { + "description": "JSON string containing the complete flow modules array.", + "type": "string" + }, + "schema": { + "description": "JSON string containing the flow input schema.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "preprocessor_module": { + "description": "JSON string containing the optional preprocessor module.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure_module": { + "description": "JSON string containing the optional failure module.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "groups": { + "description": "JSON string, array of semantic flow groups (call get_instructions subject:\"flow\" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear groups.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notes": { + "description": "JSON string, array of free-floating sticky notes (type must be \"free\"; call get_instructions subject:\"flow\" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear notes.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "modules" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_schedule", + "description": "Create or overwrite a draft schedule.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The unique Windmill path for this schedule. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`.", + "type": "string" + }, + "schedule": { + "description": "Cron expression with 6 fields (seconds, minutes, hours, day of month, month, day of week). Example '0 0 12 * * *' for daily at noon", + "type": "string" + }, + "timezone": { + "description": "IANA timezone for the schedule (e.g., 'UTC', 'Europe/Paris', 'America/New_York')", + "type": "string" + }, + "script_path": { + "description": "Path to the script or flow to execute when triggered", + "type": "string" + }, + "is_flow": { + "description": "True if script_path points to a flow, false if it points to a script", + "type": "boolean" + }, + "args": { + "anyOf": [ + { + "description": "The arguments to pass to the script or flow", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "enabled": { + "description": "Whether the schedule is currently active and will trigger jobs", + "type": "boolean" + }, + "summary": { + "anyOf": [ + { + "description": "Short summary describing the purpose of this schedule", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "description": "Detailed description of what this schedule does", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_failure": { + "anyOf": [ + { + "description": "Path to a script or flow to run when the scheduled job fails", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_recovery": { + "anyOf": [ + { + "description": "Path to a script or flow to run when the schedule recovers after failures", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_success": { + "anyOf": [ + { + "description": "Path to a script or flow to run after each successful execution", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "advanced": { + "description": "Less common schedule options (on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery_times, on_recovery_extra_args, on_success_extra_args, ws_error_handler_muted, retry, no_flow_overlap, tag, paused_until, cron_version, dynamic_skip, permissioned_as, preserve_permissioned_as, labels). Call get_schedule_schema for their exact shape.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "schedule", + "timezone", + "script_path", + "is_flow", + "args" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_trigger", + "description": "Create or overwrite a draft trigger.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "description": "Trigger kind. Determines which fields are valid in config.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "config": { + "description": "Full trigger configuration: path, script_path, is_flow plus the kind-specific fields. Call get_trigger_schema with the same kind first to get its exact fields.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "kind", + "config" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "get_trigger_schema", + "description": "Get the configuration schema for one trigger kind — its config fields differ per kind.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "description": "Trigger kind whose configuration schema to return.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_schedule_schema", + "description": "Get the shape of a schedule's `advanced` object: retry, pausing, tags, and error-handler tuning.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "edit_script", + "description": "Find/replace exact text in a script and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script to edit.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find in the script source.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "patch_flow_json", + "description": "Find/replace exact text in compact flow JSON and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow to edit.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find in the flow value, serialized as compact JSON (no indent).", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement JSON text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_script", + "description": "Execute a preview-style test run of a script by path, preferring draft content when it exists. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_script", + "description": "Run a DEPLOYED script for real, under the user's own permissions. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user's to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the deployed script to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_flow", + "description": "Execute a preview-style test run of a flow by path, preferring draft content when it exists. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "memory_id": { + "description": "Chat-mode flows only. A UUID naming the conversation this turn belongs to, whose memory the agent steps read: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.", + "type": "string" + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_flow", + "description": "Run a DEPLOYED flow for real, under the user's own permissions. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user's to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the deployed flow to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_step", + "description": "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. An AI decision step takes `state`, what its questions are asked about; its provider and questions are the step's own. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow containing the step to test.", + "type": "string" + }, + "stepId": { + "description": "The id of the step/module to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "stepId" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_runs", + "description": "List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_run with a returned id to see what a run was called with, returned and logged.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Filter to runs of this exact script or flow path.", + "type": "string" + }, + "created_by": { + "description": "Filter by the username that started the run.", + "type": "string" + }, + "label": { + "description": "Filter by job label.", + "type": "string" + }, + "success": { + "description": "Only completed runs with this outcome (true = succeeded, false = failed).", + "type": "boolean" + }, + "running": { + "description": "Only currently running runs.", + "type": "boolean" + }, + "limit": { + "description": "Max number of runs to return, most recent first. Defaults to 30.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_workers", + "description": "List the workers connected to this Windmill instance (those that pinged in the last 5 minutes), with their worker group, custom tags, seconds since their last ping, and jobs executed. Pair with list_runs to diagnose a stuck queue: runs queued on a tag no listed worker picks up will never start. Three blind spots to report rather than reason past: an empty result states whether no worker is connected or whether workers may be hidden from you, so relay the one it gives instead of picking; a missing custom_tags can mean tags are hidden from you, not unset; and only the 100 most recently pinging workers are listed, so on a bigger instance a tag none of them carries may still be served.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_run", + "description": "Inspect one run: its status, arguments, result and logs, plus — for a flow — the per-step execution tree with each step's status and truncated result (subflow steps, loop iterations, branches and retries included). Works on running jobs too. Pass step to fetch one step's result in full (up to 12k chars).", + "parameters": { + "type": "object", + "properties": { + "id": { + "description": "The UUID of the run (job) to inspect.", + "type": "string" + }, + "step": { + "description": "Step to drill into for its result (returned in full up to 12k chars), addressed by the step ids shown in the tree: \"b\" for a top-level step, \"b/c\" for a step inside a subflow, \"b[12]\" for iteration 12 of a loop or attempt 12 of a retried step (1-based), composable as \"b[12]/c\". Omit to get the run itself.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "cancel_job", + "description": "Cancel a running or queued job by its id (e.g. a background test run you started that is no longer needed).", + "parameters": { + "type": "object", + "properties": { + "id": { + "description": "The UUID of the job to cancel.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "deploy_workspace_item", + "description": "Deploy a draft to the workspace. Mutates the workspace.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to deploy.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "deployment_message": { + "description": "Optional deployment message recorded with the change.", + "type": "string" + }, + "force": { + "description": "Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "rebase_draft", + "description": "Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to rebase onto the latest deployed version.", + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "diff", + "description": "Diff workspace changes. Read-only. Default: drafts vs deployed versions (index without type/path, one item's unified diff with them; file=<name> for one file inside an app). against='parent_workspace': deployed fork vs its parent workspace. search=<text> greps changed lines across all diffs.", + "parameters": { + "type": "object", + "properties": { + "against": { + "description": "What to compare against. 'deployed' (default): the current draft vs the deployed version. 'parent_workspace': the deployed fork vs its parent workspace (only in a fork; local drafts are flagged but not part of that comparison).", + "type": "string", + "enum": [ + "deployed", + "parent_workspace" + ] + }, + "type": { + "description": "With path: the item to diff. Omit both type and path for the workspace index.", + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the item to diff (draft vs deployed). Omit for the index of every draft in the workspace.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "file": { + "description": "Item mode, multi-file apps only: read one file's diff inside the app (e.g. \"src/App.tsx\"). Omit for the per-file summary plus config changes.", + "type": "string" + }, + "search": { + "description": "Search mode: literal substring (case-insensitive, not a regex) matched against added/removed diff lines across every diff in the comparison. Ignores type/path.", + "type": "string" + }, + "file_glob": { + "description": "Search mode: optional glob filter on item paths and app file paths (e.g. \"*.ts\" matches file names, \"f/dash/**\" matches full paths).", + "type": "string" + }, + "max_matches": { + "description": "Search mode: maximum matching diff lines returned (default 50, hard cap 200).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "types": { + "description": "Index mode: only list drafts of these item types.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + } + }, + "path_prefix": { + "description": "Index mode: only list drafts under this path prefix, such as f/billing/.", + "type": "string" + }, + "offset": { + "description": "Item mode: skip this many patch lines (paginate a large diff).", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Index mode: max items to list (default 50, capped at 100). Item mode: max patch lines to return (default 500).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "delete_workspace_item", + "description": "Delete an item that is already deployed in the workspace. Mutates the workspace. FAILS if the path has no deployed item, so never call it to undo something you created in this chat — that is a draft; use discard_local_draft instead.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the item to delete.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Identifies which trigger service to call.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "discard_local_draft", + "description": "Discard a draft only — the tool to undo an item you created or edited in this chat and have not deployed. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to discard.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_resource", + "description": "Create or overwrite a draft resource.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The path to the resource", + "type": "string" + }, + "value": {}, + "description": { + "description": "The description of the resource", + "type": "string" + }, + "resource_type": { + "description": "The resource_type associated with the resource", + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "ws_specific": { + "type": "boolean" + }, + "preserve_on_behalf_of": { + "description": "ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.", + "type": "boolean" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "value", + "resource_type" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_variable", + "description": "Create or edit a draft variable. Editing is a partial update: pass only the fields you are changing. You can never read the value of a variable, so to edit a secret variable you MUST omit value — any value you pass replaces the stored secret. FAILS if you pass a \"$var:\" self-reference as the value, or un-secret a variable without giving its new value.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The path to the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable. Omit it to leave the value alone — required only when creating a new variable, or when changing a secret variable into a non-secret one. Never invent or guess the value of an existing variable: you cannot read it, and a \"$var:...\" reference is NOT a valid value (a variable cannot reference itself). Omitting it keeps whatever the draft already holds, so a value you set earlier in this conversation stays set; discard_local_draft abandons it.", + "type": "string" + }, + "is_secret": { + "description": "Whether the variable is a secret. Omit it to keep the current secrecy — required only when creating a new variable. Never pass false for an existing secret variable unless the user explicitly asked to reveal it and you also pass its new value.", + "type": "boolean" + }, + "description": { + "description": "The description of the variable. Omit it to keep the current description.", + "type": "string" + }, + "account": { + "description": "The account identifier", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "is_oauth": { + "description": "Whether the variable is an OAuth variable", + "type": "boolean" + }, + "expires_at": { + "description": "The expiration date of the variable", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "ws_specific": { + "type": "boolean" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "search_resource_types", + "description": "Search workspace resource types and schemas.", + "parameters": { + "type": "object", + "properties": { + "query": { + "description": "Natural-language description of the integration or capability you need, e.g. \"stripe\", \"postgres database\", or \"send emails\". Matched semantically against resource type names and descriptions, so describe the intent rather than guessing the exact name.", + "type": "string" + }, + "limit": { + "description": "Max number of resource types to return. Defaults to 5.", + "type": "integer", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_resource_type", + "description": "Create a resource type in the workspace. Mutates the workspace.", + "parameters": { + "type": "object", + "properties": { + "name": { + "description": "Type name with the c_ prefix of custom types, e.g. c_acme_billing (added when missing): 1 to 50 letters, digits, \"_\" or \"-\". Resources and resource-typed parameters refer to it by this name.", + "type": "string" + }, + "description": { + "description": "What a resource of this type connects to.", + "type": "string" + }, + "schema": { + "description": "JSON Schema of a resource value of this type: { \"type\": \"object\", \"properties\": {...}, \"required\": [...] }, with every connection field, secrets included (a resource fills those with \"$var:\" references).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "name", + "description", + "schema" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "get_db_schema", + "description": "Fetch the schema (tables and columns) of a database resource by its path. Supports postgresql, mysql, ms_sql_server, snowflake and bigquery resources.", + "parameters": { + "type": "object", + "properties": { + "resourcePath": { + "type": "string", + "description": "The path of the database resource" + } + }, + "required": [ + "resourcePath" + ] + }, + "read_only": true + }, + { + "name": "read_flow_module_code", + "description": "Read inline script code from one flow module.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow.", + "type": "string" + }, + "module_id": { + "description": "Module id whose inline rawscript content to read. Must reference a module whose value.type is \"rawscript\".", + "type": "string" + } + }, + "required": [ + "path", + "module_id" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "set_flow_module_code", + "description": "Overwrite inline script code in one flow module and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow.", + "type": "string" + }, + "module_id": { + "description": "Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is \"rawscript\". Use patch_flow_json for structural changes.", + "type": "string" + }, + "code": { + "description": "New script source. Replaces the module's value.content entirely.", + "type": "string" + } + }, + "required": [ + "path", + "module_id", + "code" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "init_app", + "description": "Initialize a draft raw app from a framework template.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path for the new app, e.g. f/folder/my_app or u/username/my_app. Errors if an app already exists at this path or a draft is already in flight.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary of the app.", + "type": "string" + }, + "framework": { + "description": "Frontend framework template: the one the user named or an app they point at uses, otherwise react19. Do not ask.", + "type": "string", + "enum": [ + "react19", + "react18", + "svelte5" + ] + }, + "data": { + "description": "Data setup of an app that stores data, as the user gave or chose it. Omit for an app that stores nothing.", + "type": "object", + "properties": { + "datatable": { + "description": "Data table the app stores its data in.", + "type": "string" + }, + "schema": { + "description": "Schema the app's new tables go in. Omit for public.", + "type": "string" + }, + "tables": { + "description": "Tables the app queries, reused or about to be created, each as `<datatable>/<table>` (public) or `<datatable>/<schema>:<table>`.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "datatable" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "framework" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "read_app_file", + "description": "Read one raw app frontend file or inline backend runnable. Large files are truncated to a head slice; pass offset/limit to page through the rest.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app, e.g. f/folder/name.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path like /index.tsx, or backend inline runnable path like backend/<key>/main.ts (or main.py).", + "type": "string" + }, + "offset": { + "description": "1-based line number to start reading from. Use to page through a large file.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Maximum number of lines to return. Large files are truncated by default; pass offset/limit to read a specific line range.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "file_path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_app", + "description": "Grep across all of a raw app's frontend files and inline backend runnables in one call. Returns matching file:line rows (capped), not file bodies — use it to locate a symbol or string before read_app_file instead of reading whole files one by one.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app, e.g. f/folder/name.", + "type": "string" + }, + "query": { + "description": "Literal substring to find across all app files (case-insensitive) — not a regex, so spaces and operators match verbatim. Returns matching file:line rows, not file bodies. To find call sites and skip similarly-named helpers, include the call paren (e.g. \"formatCurrency(\" matches calls but not \"formatCurrencyPrecise\"). Then read_app_file to inspect the ranges.", + "type": "string" + }, + "file_glob": { + "description": "Optional path filter. A pattern without \"/\" matches the file name anywhere (e.g. \"*.tsx\"); a pattern with \"/\" matches the full path (e.g. \"/components/*\", \"backend/**\"). Supports * (any chars except /), ** (any chars), and ?.", + "type": "string" + }, + "max_matches": { + "description": "Maximum number of matching lines to return (default 100, hard cap 200); each is shown with a few lines of surrounding context, so the output has more rows than this.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "query" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "write_app_file", + "description": "Create or overwrite a frontend file in a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path (must start with /). Use write_app_runnable to set inline backend script content.", + "type": "string" + }, + "content": { + "description": "Full file content.", + "type": "string" + } + }, + "required": [ + "path", + "file_path", + "content" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "delete_app_file", + "description": "Remove a frontend file from a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path to remove from the draft. Use delete_app_runnable for backend runnables.", + "type": "string" + } + }, + "required": [ + "path", + "file_path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "patch_app_file", + "description": "Find/replace exact text in a raw app file and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path like /index.tsx, or backend inline runnable path like backend/<key>/main.ts.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "file_path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_app_runnable", + "description": "Create or overwrite a backend runnable in a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Unique key for the backend runnable (called from frontend as backend.<key>()). Becomes the file id at backend/<key>/main.{ts|py}.", + "type": "string" + }, + "runnable": { + "description": "Backend runnable shape: same as in app mode. Inline runnables carry their script content; path runnables reference an existing workspace/hub item.", + "type": "object", + "properties": { + "name": { + "description": "Short summary/description of what the runnable does.", + "type": "string" + }, + "type": { + "description": "Runnable kind: \"inline\" for custom code stored on the app; \"script\"/\"flow\" for a workspace runnable; \"hubscript\" for a hub script.", + "type": "string", + "enum": [ + "inline", + "script", + "flow", + "hubscript" + ] + }, + "staticInputs": { + "description": "Static inputs that are not overridable by the frontend caller. Useful for workspace/hub scripts to pre-fill arguments.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "inlineScript": { + "description": "Required when type is \"inline\".", + "type": "object", + "properties": { + "language": { + "description": "Inline script language.", + "type": "string", + "enum": [ + "bun", + "python3" + ] + }, + "content": { + "description": "Inline script source. Must export a main function.", + "type": "string" + } + }, + "required": [ + "language", + "content" + ], + "additionalProperties": false + }, + "path": { + "description": "Required when type is \"script\", \"flow\", or \"hubscript\". Workspace path of the runnable.", + "type": "string" + }, + "viewerDataAccess": { + "description": "Inline only. When true, the runnable reads data as the person viewing the app instead of as the app's identity: data table connections that name no role hold the viewer's roles, and object storage permission rules are checked against the viewer, so row-level security and per-user storage paths answer per viewer. Resources and variables stay the app's. Anonymous viewers are refused. Omit to keep the current setting.", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "key", + "runnable" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "delete_app_runnable", + "description": "Remove a backend runnable from a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Key of the backend runnable to remove.", + "type": "string" + } + }, + "required": [ + "path", + "key" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_app_runnable", + "description": "Run one of an app's backend runnables with test arguments, the way the editor preview runs it. An inline runnable executes the draft code; a path runnable executes the deployed script/flow it points at. A deployed app runs under its stored policy instead, so this does not prove the runnable is reachable once deployed.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Key of the backend runnable to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "key" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_datatables", + "description": "List datatables configured in the workspace with schema and table names only. Does not include column definitions. Use this directly for table-list or available-tables summaries. Only call get_datatable_table_schema when column names/types are required.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "List only this datatable. Required with `role`.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_datatable_table_schema", + "description": "Get column definitions for one datatable table. Do not call this for row counts or table-list summaries; list_datatables is enough for those.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable name to inspect, e.g. \"main\".", + "type": "string" + }, + "schema_name": { + "description": "The schema name, e.g. \"public\".", + "type": "string" + }, + "table_name": { + "description": "The table name to inspect.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + } + }, + "required": [ + "datatable_name", + "schema_name", + "table_name" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "exec_datatable_sql", + "description": "Execute a SQL query on a workspace datatable. Use this to explore data, test queries, create/alter tables, or make changes. Creating a table is a normal CREATE TABLE statement — no registration step is needed.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The name of the datatable to query (e.g., \"main\"). Must be one of the datatables configured in the workspace.", + "type": "string" + }, + "sql": { + "description": "The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + }, + "background": { + "description": "Run in the background without waiting. Set true for queries you expect to be slow (large scans, heavy migrations) — you will be notified when it finishes. Leave unset for normal queries, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the query in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a query you expect to finish in ~30–60s and want the result in this same turn. Ignored when background is true.", + "type": "number" + } + }, + "required": [ + "datatable_name", + "sql" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_datatable_migrations", + "description": "List a datatable's migrations in order, each with its version, name and whether it is applied or pending. Pass `version` to read one migration's SQL.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "version": { + "description": "Return the SQL of this one migration instead of the list.", + "type": "number" + } + }, + "required": [ + "datatable_name" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_datatable_migration", + "description": "Change the schema of a datatable that has migrations enabled: saves the change as a new migration and applies it. One that fails to apply stays saved as pending: call this again with its `version` to fix it.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "name": { + "description": "Short name of the change, e.g. \"add_refunds_table\". Letters, digits, \"_\" and \"-\" only.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$" + }, + "code_up": { + "description": "The SQL that makes the change. Wrap the statements in `BEGIN; ... END;` so they apply atomically, unless one of them cannot run in a transaction.", + "type": "string" + }, + "code_down": { + "description": "The SQL that undoes `code_up`. Without it the migration cannot be rolled back, so provide it whenever the change is reversible.", + "type": "string" + }, + "role": { + "description": "The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + }, + "schemas": { + "description": "Every schema the migration changes or depends on (including schemas of referenced tables, types and functions), with every name in `code_up` schema-qualified. A migration then waits only on earlier migrations touching those schemas. Omit only for a change that spans the whole database (an extension, a grant across schemas): every later migration then waits for it.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_$-]{1,63}$" + } + }, + "version": { + "description": "Rewrite this migration instead of adding one: only for a pending migration, typically the one that just failed to apply.", + "type": "number" + }, + "apply": { + "description": "Set false to save the migration as pending without running it. Defaults to true.", + "type": "boolean" + } + }, + "required": [ + "datatable_name", + "name", + "code_up" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_datatable_migrations", + "description": "Apply a datatable's pending migrations, oldest first. It stops at the first one that fails.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + } + }, + "required": [ + "datatable_name" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "rollback_datatable_migration", + "description": "Undo one applied migration of a datatable by running its down SQL. The migration stays in the list as pending.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "version": { + "description": "The applied migration to undo, normally the most recent one, as listed.", + "type": "number" + } + }, + "required": [ + "datatable_name", + "version" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_data_metrics", + "description": "List the measures and dimensions declared on DuckLake tables (from `// measure` / `// dimension` annotations in deployed scripts). Call this before writing any aggregate query over a DuckLake table: a declared measure is the canonical definition of that number, and reproducing it yourself silently disagrees with it (a `revenue` measure typically excludes refunds or test rows). Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates share one GROUP BY. Only declarations whose producing script you can read are returned, so what comes back is never proof of what exists: if the number you need is not here you may still write your own aggregate, but say that you found no declared measure for it rather than implying none exists.", + "parameters": { + "type": "object", + "properties": { + "table": { + "description": "Only declarations on this DuckLake table, as `<lake>/<table>` or `<lake>/<schema>.<table>`, with or without the `ducklake://` scheme. A name with no lake matches nothing and comes back empty.", + "type": "string" + }, + "path_prefix": { + "description": "Only declarations made by scripts under this path, e.g. `f/analytics`.", + "type": "string" + }, + "limit": { + "description": "Max number of declarations to return. Defaults to 200.", + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_ducklakes", + "description": "List the DuckLake catalogs configured in this workspace, by name. Call this before building or deploying a data pipeline that materializes DuckLake tables or reads/writes S3 assets: if it returns none, the workspace has no object storage + DuckLake configured and the pipeline cannot run until a workspace admin sets it up. Returns names only.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "search_files", + "description": "Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "description": "JavaScript regular expression to search for.", + "type": "string" + }, + "file": { + "description": "Optional file to restrict the search to: its file id when one is listed, otherwise its exact filename. Omit to search across all attached files.", + "type": "string" + }, + "ignore_case": { + "description": "Case-insensitive matching. Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "pattern" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "read_file", + "description": "Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.", + "parameters": { + "type": "object", + "properties": { + "file": { + "description": "File to read: its file id when one is listed, otherwise its exact filename.", + "type": "string" + }, + "start_line": { + "description": "1-based first line to read. Defaults to 1.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end_line": { + "description": "1-based last line to read. The window is capped at 200 lines.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "file" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_workspace_settings", + "description": "Read the workspace settings as YAML, in the layout of the settings.yaml file the CLI syncs. Secrets read as `__SENSITIVE_AND_UNCHANGED__`.", + "parameters": { + "type": "object", + "properties": { + "keys": { + "description": "Only these top-level keys. Omit for the whole document.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "set_workspace_settings", + "description": "Change workspace settings. Keys the yaml omits are left as they are. Read get_instructions subject \"workspace_settings\" before your first change.", + "parameters": { + "type": "object", + "properties": { + "yaml": { + "description": "A YAML mapping of only the top-level keys to change, each given whole (a nested object replaces the stored one). A key set to null clears it.", + "type": "string" + } + }, + "required": [ + "yaml" + ], + "additionalProperties": false + }, + "read_only": false + } +] diff --git a/backend/windmill-api/src/mcp/session/guidance.rs b/backend/windmill-api/src/mcp/session/guidance.rs new file mode 100644 index 0000000000..385aaa5c2a --- /dev/null +++ b/backend/windmill-api/src/mcp/session/guidance.rs @@ -0,0 +1,142 @@ +//! `get_instructions` and `read_skill`: guidance the model pulls on demand. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde_json::Value; + +use super::{opt_str_arg, str_arg, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &["get_instructions", "read_skill"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "get_instructions" => get_instructions(ctx, args), + "read_skill" => read_skill(ctx, args).await, + _ => unreachable!("{name} is not a guidance tool"), + } +} + +/// Each subject's text per language, as the session renders it; `""` is the text for +/// a call that names no language. +#[derive(Deserialize)] +struct Instructions { + index: HashMap<String, HashMap<String, String>>, + texts: HashMap<String, String>, +} + +static INSTRUCTIONS: LazyLock<Instructions> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/instructions.json")) + .expect("gen/instructions.json is valid") +}); + +fn get_instructions(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let subject = str_arg(args, "subject")?; + if subject == "workspace_settings" && !ctx.auth.is_admin { + return Ok("Only a workspace admin can read or change the workspace settings. Tell the user plainly that you cannot, and suggest asking a workspace admin.".to_string()); + } + let by_language = INSTRUCTIONS + .index + .get(subject) + .ok_or_else(|| format!("Unknown subject `{subject}`"))?; + let language = opt_str_arg(args, "language").unwrap_or(""); + let id = by_language + .get(language) + .ok_or_else(|| format!("Unknown language `{language}`"))?; + Ok(INSTRUCTIONS.texts[id].clone()) +} + +const SKILL_RESOURCE_TYPE: &str = "ai_skill"; +const SKILLS_PAGE_SIZE: usize = 100; +/// Guard against a paging bug looping forever, not a product cap. +const MAX_SKILLS_PAGES: usize = 100; +const MAX_SKILL_DESCRIPTION_CHARS: usize = 1024; +const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024; +const TRUNCATED: &str = "… [truncated]"; + +pub(super) struct Skill { + pub path: String, + pub description: String, +} + +/// Every skill the caller can read. The session lists only the ones its user left +/// switched on, a preference kept in the browser; here they are all in play. +pub(super) async fn list_skills(ctx: &Ctx<'_>) -> Result<Vec<Skill>, String> { + let mut skills = Vec::new(); + for page in 1..=MAX_SKILLS_PAGES { + let rows = ctx + .get(&ctx.ws(&format!( + "resources/list?resource_type={SKILL_RESOURCE_TYPE}&page={page}&per_page={SKILLS_PAGE_SIZE}" + ))) + .await?; + let rows = rows.as_array().map(Vec::as_slice).unwrap_or_default(); + for row in rows { + let Some(path) = row.get("path").and_then(Value::as_str) else { + continue; + }; + let description = row + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + let description = if description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS { + let cut: String = description + .chars() + .take(MAX_SKILL_DESCRIPTION_CHARS) + .collect(); + format!("{cut}{TRUNCATED}") + } else { + description.to_string() + }; + skills.push(Skill { path: path.to_string(), description }); + } + if rows.len() < SKILLS_PAGE_SIZE { + break; + } + } + Ok(skills) +} + +async fn read_skill(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + // Checked against the listing: the path is the model's, and without this the tool + // reads any resource holding a string `content`. + let listed = match list_skills(ctx).await { + Ok(skills) => skills.iter().any(|skill| skill.path == path), + Err(e) => return Ok(read_failed(path, &e)), + }; + if !listed { + return Ok(format!( + "\"{path}\" is not one of the skills available here. Only the paths listed under \"Skills\" in the instructions can be read." + )); + } + let value = match ctx + .get(&ctx.ws(&format!("resources/get_value/{path}"))) + .await + { + Ok(value) => value, + Err(e) => return Ok(read_failed(path, &e)), + }; + let Some(body) = value.get("content").and_then(Value::as_str) else { + return Ok(read_failed( + path, + &format!("resource {path} has no string \"content\" — is it an {SKILL_RESOURCE_TYPE}?"), + )); + }; + let body = if body.len() > MAX_SKILL_INSTRUCTIONS_BYTES { + let mut end = MAX_SKILL_INSTRUCTIONS_BYTES; + while !body.is_char_boundary(end) { + end -= 1; + } + format!("{}{TRUNCATED}", &body[..end]) + } else { + body.to_string() + }; + Ok(format!("Skill: {path}\n\nInstructions:\n{body}")) +} + +fn read_failed(path: &str, message: &str) -> String { + format!( + "Failed to read skill \"{path}\": {message}. Check the path against the Skills list in the instructions." + ) +} diff --git a/backend/windmill-api/src/mcp/session/instructions.rs b/backend/windmill-api/src/mcp/session/instructions.rs new file mode 100644 index 0000000000..f5c15c6e17 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/instructions.rs @@ -0,0 +1,152 @@ +//! The instructions a client receives on connecting: the session's system prompt as +//! rendered for an external client (`gen/prompt.json`), filled in for the caller. What +//! differs per workspace is cut out of it, so a connection spanning several workspaces +//! gets the rest up front and each workspace's part from `get_workspace_context`. + +use std::sync::LazyLock; + +use serde::Deserialize; +use windmill_mcp::server::McpAuth; + +use super::{guidance::list_skills, Ctx}; + +/// The prompt with a `{{folder_guidance}}` slot, the guidance once per folder situation, +/// and the sections appended when the workspace has something to put there. +#[derive(Deserialize)] +struct PromptParts { + base: String, + folder_guidance: FolderGuidance, + skills_header: String, + skill_line: String, + workspace_instructions: String, +} + +#[derive(Deserialize)] +struct FolderGuidance { + admin: String, + admin_no_folders: String, + member: String, + member_no_folders: String, + member_readonly: String, + member_no_folders_readonly: String, +} + +static PARTS: LazyLock<PromptParts> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/prompt.json")).expect("gen/prompt.json is valid") +}); + +/// Folders named in the prompt before the rest are counted rather than listed, as the +/// session does, so a folder-heavy workspace cannot dominate the instructions. +const MAX_LISTED_FOLDERS: usize = 40; + +fn folder_list(names: &[&str]) -> String { + let shown = names + .iter() + .take(MAX_LISTED_FOLDERS) + .map(|n| format!("`f/{n}`")) + .collect::<Vec<_>>() + .join(", "); + if names.len() > MAX_LISTED_FOLDERS { + format!("{shown} (+{} more)", names.len() - MAX_LISTED_FOLDERS) + } else { + shown + } +} + +fn folder_guidance(auth: &crate::db::ApiAuthed) -> String { + let writable: Vec<&str> = auth + .folders() + .iter() + .filter(|(_, write, _)| *write) + .map(|(name, _, _)| name.as_str()) + .collect(); + let readonly: Vec<&str> = auth + .folders() + .iter() + .filter(|(_, write, _)| !*write) + .map(|(name, _, _)| name.as_str()) + .collect(); + + let guidance = &PARTS.folder_guidance; + let template = match (auth.is_admin(), writable.is_empty(), readonly.is_empty()) { + (true, false, _) => &guidance.admin, + (true, true, _) => &guidance.admin_no_folders, + (false, false, true) => &guidance.member, + (false, false, false) => &guidance.member_readonly, + (false, true, true) => &guidance.member_no_folders, + (false, true, false) => &guidance.member_no_folders_readonly, + }; + template + .replace("{{username}}", auth.username()) + .replace("{{writable_folders}}", &folder_list(&writable)) + .replace("{{readonly_folders}}", &folder_list(&readonly)) +} + +/// The workspace's skills and its admin-written instructions. +async fn appendix(ctx: &Ctx<'_>) -> String { + let mut text = String::new(); + // Either lookup failing leaves its section out: a client that cannot connect at all + // is worse than one that connects without the skills list. + if let Ok(skills) = list_skills(ctx).await { + if !skills.is_empty() { + text.push_str(&PARTS.skills_header); + let lines = skills + .iter() + .map(|skill| { + PARTS + .skill_line + .replace("{{skill_path}}", &skill.path) + .replace("{{skill_description}}", &skill.description) + }) + .collect::<Vec<_>>() + .join("\n"); + text.push_str(&lines); + } + } + if let Ok(info) = ctx.get(&ctx.ws("workspaces/get_copilot_info")).await { + let workspace = info + .pointer("/custom_prompts/global") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + if let Some(workspace) = workspace { + text.push_str( + &PARTS + .workspace_instructions + .replace("{{workspace_instructions}}", workspace), + ); + } + } + text +} + +/// The whole prompt, for a connection that reaches one workspace. +pub(in crate::mcp) async fn session_instructions(ctx: &Ctx<'_>) -> String { + let mut prompt = PARTS + .base + .replace("{{folder_guidance}}", &folder_guidance(ctx.auth)) + .replace("{{username}}", ctx.auth.username()); + prompt.push_str(&appendix(ctx).await); + prompt +} + +/// The prompt without anything specific to a workspace, for a connection that reaches +/// several: usernames are per workspace, so paths read `u/<username>/...`. +pub(in crate::mcp) fn generic_session_instructions() -> String { + PARTS + .base + .replace("{{folder_guidance}}", "") + .replace("{{username}}", "<username>") +} + +/// What `generic_session_instructions` leaves out, for one workspace. +pub(in crate::mcp) async fn workspace_context(ctx: &Ctx<'_>) -> String { + let username = ctx.auth.username(); + format!( + "Workspace `{}`. Your username here is \"{username}\", so your personal paths are \ + `u/{username}/<name>`.\n{}{}", + ctx.w_id, + folder_guidance(ctx.auth).trim_end(), + appendix(ctx).await + ) +} diff --git a/backend/windmill-api/src/mcp/session/items.rs b/backend/windmill-api/src/mcp/session/items.rs new file mode 100644 index 0000000000..4f221180d7 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/items.rs @@ -0,0 +1,720 @@ +//! Draft writes for the item kinds that are plain records: schedules, triggers, resources, variables. + +use std::sync::LazyLock; + +use serde_json::{json, Map, Value}; + +use super::workspace::{finish_draft_write, persist_draft, write_base, Persist}; +use super::{non_null, put, tool_parameters, Ctx, ToolResult, SCHEMAS}; + +pub(super) const TOOLS: &[&str] = &[ + "write_schedule", + "write_trigger", + "write_resource", + "write_variable", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "write_schedule" => write_schedule(ctx, args).await, + "write_trigger" => write_trigger(ctx, args).await, + "write_resource" => write_resource(ctx, args).await, + "write_variable" => write_variable(ctx, args).await, + _ => unreachable!("{name} is not an items tool"), + } +} + +// ============================================================================ +// Argument parsing. The session parses arguments with zod schemas; the same schemas +// reach this server as JSON Schema (`gen/tools.json`, `gen/schemas.json`), and `parse` +// applies them with zod's semantics rather than JSON Schema's: an undeclared key is +// dropped instead of refused, a `default` fills a missing key, a union yields the +// first branch that accepts the value. +// ============================================================================ + +fn tool_schema(name: &str) -> Value { + tool_parameters(name) + .map(|parameters| Value::Object(parameters.clone())) + .unwrap_or_default() +} + +/// A schedule's whole schema: the fields `write_schedule` lists plus the ones it takes +/// through `advanced`, which the session spreads to the top level before parsing. +static SCHEDULE_SCHEMA: LazyLock<Value> = LazyLock::new(|| { + let mut schema = tool_schema("write_schedule"); + let advanced: Value = serde_json::from_str(&SCHEMAS.schedule).unwrap_or_default(); + if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { + properties.remove("advanced"); + if let Some(advanced) = advanced.get("properties").and_then(Value::as_object) { + properties.extend(advanced.clone()); + } + } + schema +}); + +fn received(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_string() + } else { + format!("{path}.{key}") + } +} + +/// `value` as `schema` accepts it, `None` standing for a missing one. Each violation is +/// pushed to `issues` as `<dotted path>: <message>`. +fn parse( + schema: &Value, + value: Option<&Value>, + path: &str, + issues: &mut Vec<String>, +) -> Option<Value> { + let mut issue = |message: String| { + let at = if path.is_empty() { "<root>" } else { path }; + issues.push(format!("{at}: {message}")); + None + }; + if value.is_none() { + if let Some(default) = schema.get("default") { + return Some(default.clone()); + } + } + if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) { + let is_null = |branch: &&Value| branch.get("type").and_then(Value::as_str) == Some("null"); + if value.is_some_and(Value::is_null) && branches.iter().any(|b| is_null(&b)) { + return Some(Value::Null); + } + let branches: Vec<&Value> = branches.iter().filter(|b| !is_null(b)).collect(); + // A nullable field reports what its one real type refuses. + if let [only] = branches[..] { + return parse(only, value, path, issues); + } + for branch in branches { + let mut refused = Vec::new(); + let parsed = parse(branch, value, path, &mut refused); + if refused.is_empty() { + return parsed; + } + } + return issue("Invalid input".to_string()); + } + if let Some(options) = schema.get("enum").and_then(Value::as_array) { + if value.is_some_and(|v| options.contains(v)) { + return value.cloned(); + } + let options: Vec<String> = options.iter().map(Value::to_string).collect(); + return issue(format!( + "Invalid option: expected one of {}", + options.join("|") + )); + } + let Some(kind) = schema.get("type").and_then(Value::as_str) else { + return value.cloned(); + }; + match (kind, value) { + ("boolean", Some(Value::Bool(_))) | ("null", Some(Value::Null)) => value.cloned(), + ("string", Some(Value::String(text))) => { + let matches = schema + .get("pattern") + .and_then(Value::as_str) + .and_then(|pattern| regex::Regex::new(pattern).ok()) + .is_none_or(|pattern| pattern.is_match(text)); + if matches { + value.cloned() + } else if schema.get("format").and_then(Value::as_str) == Some("date-time") { + issue("Invalid ISO datetime".to_string()) + } else { + issue("Invalid string".to_string()) + } + } + ("number" | "integer", Some(Value::Number(number))) => { + let n = number.as_f64().unwrap_or_default(); + let bound = |key: &str| schema.get(key).and_then(Value::as_f64); + if kind == "integer" && n.fract() != 0.0 { + issue("Invalid input: expected int, received number".to_string()) + } else if let Some(min) = bound("minimum").filter(|min| n < *min) { + issue(format!("Too small: expected number to be >={min}")) + } else if let Some(max) = bound("maximum").filter(|max| n > *max) { + issue(format!("Too big: expected number to be <={max}")) + } else { + value.cloned() + } + } + ("array", Some(Value::Array(items))) => { + let any = json!({}); + let item_schema = schema.get("items").unwrap_or(&any); + Some(Value::Array( + items + .iter() + .enumerate() + .filter_map(|(i, item)| { + parse(item_schema, Some(item), &join(path, &i.to_string()), issues) + }) + .collect(), + )) + } + ("object", Some(Value::Object(fields))) => { + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return value.cloned(); + }; + let required = schema.get("required").and_then(Value::as_array); + let mut out = Map::new(); + for (key, property) in properties { + let field = fields.get(key); + let optional = !required.is_some_and(|r| r.iter().any(|k| k == key)); + if field.is_none() && optional && property.get("default").is_none() { + continue; + } + if let Some(parsed) = parse(property, field, &join(path, key), issues) { + out.insert(key.clone(), parsed); + } + } + if schema.get("additionalProperties") != Some(&Value::Bool(false)) { + for (key, field) in fields { + if !properties.contains_key(key) { + out.insert(key.clone(), field.clone()); + } + } + } + Some(Value::Object(out)) + } + _ => { + let expected = match kind { + "integer" => "number", + "object" if schema.get("properties").is_none() => "record", + other => other, + }; + issue(format!( + "Invalid input: expected {expected}, received {}", + received(value) + )) + } + } +} + +fn parse_args(tool: &str, args: &Value) -> Result<Value, String> { + parse_with(&tool_schema(tool), args) +} + +fn parse_with(schema: &Value, args: &Value) -> Result<Value, String> { + let mut issues = Vec::new(); + let parsed = parse(schema, Some(args), "", &mut issues); + match parsed { + Some(parsed) if issues.is_empty() => Ok(parsed), + _ => Err(format!("Invalid arguments. Issues: {}", issues.join("; "))), + } +} + +/// Key paths `supplied` carries that parsing dropped. Every sub-field of a schedule's +/// `retry` is optional, so a guessed shape parses clean, loses its misspelled keys and +/// would save a policy that does nothing. +fn dropped_keys(supplied: &Value, parsed: Option<&Value>, prefix: &str, out: &mut Vec<String>) { + let Value::Object(supplied) = supplied else { + if parsed.is_none() && !prefix.is_empty() { + out.push(prefix.to_string()); + } + return; + }; + match parsed { + Some(parsed @ (Value::Object(_) | Value::Array(_))) => { + for (key, value) in supplied { + dropped_keys(value, parsed.get(key), &join(prefix, key), out); + } + } + _ => { + if !supplied.is_empty() && !prefix.is_empty() { + out.push(prefix.to_string()); + } + } + } +} + +fn describe_dropped_schedule_options(dropped: &[String]) -> String { + let is_field = |path: &&String| { + let field = path.split('.').next().unwrap_or_default(); + field != "override" && SCHEDULE_SCHEMA["properties"].get(field).is_some() + }; + let (misshaped, unknown): (Vec<&String>, Vec<&String>) = dropped.iter().partition(is_field); + let list = |paths: &[&String]| { + paths + .iter() + .map(|p| p.as_str()) + .collect::<Vec<_>>() + .join(", ") + }; + let mut parts = Vec::new(); + if !misshaped.is_empty() { + parts.push(format!("These schedule options do not match their schema and would have been dropped: {}. Call get_schedule_schema for their exact shape.", list(&misshaped))); + } + if !unknown.is_empty() { + parts.push(format!( + "These are not schedule fields: {}.", + list(&unknown) + )); + } + parts.join(" ") +} + +// ============================================================================ +// Draft values +// ============================================================================ + +fn into_object(value: Option<Value>) -> Map<String, Value> { + match value { + Some(Value::Object(map)) => map, + _ => Map::new(), + } +} + +/// A schedule's or trigger's draft: the base without what the server stamps on a +/// deployed row, then the fields of this write. +fn merge_config(base: Option<Value>, config: Value, path: &str) -> Map<String, Value> { + let mut draft = into_object(base); + for key in ["workspace_id", "edited_by", "edited_at", "email", "error"] { + draft.remove(key); + } + draft.extend(into_object(Some(config))); + draft.insert("path".into(), json!(path)); + draft +} + +fn resource_to_draft_state(resource: &Value) -> Value { + let mut state = Map::new(); + state.insert("path".into(), resource["path"].clone()); + state.insert( + "description".into(), + non_null(resource.get("description")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + state.insert( + "args".into(), + non_null(resource.get("value")) + .cloned() + .unwrap_or_else(|| json!({})), + ); + put( + &mut state, + "labels", + non_null(resource.get("labels")).cloned(), + ); + state.insert( + "wsSpecific".into(), + non_null(resource.get("ws_specific")) + .cloned() + .unwrap_or(json!(false)), + ); + state.insert("resource_type".into(), resource["resource_type"].clone()); + Value::Object(state) +} + +fn resource_draft(args: &Value, base: Option<Value>) -> Value { + let mut draft = into_object(base); + let merged = |arg: &str, key: &str| { + non_null(args.get(arg)) + .or(non_null(draft.get(key))) + .cloned() + }; + let description = merged("description", "description").unwrap_or_else(|| json!("")); + let value = merged("value", "args").unwrap_or_else(|| json!({})); + let labels = merged("labels", "labels"); + let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false)); + let resource_type = merged("resource_type", "resource_type"); + draft.insert("path".into(), args["path"].clone()); + draft.insert("description".into(), description); + draft.insert("args".into(), value); + put(&mut draft, "labels", labels); + draft.insert("wsSpecific".into(), ws_specific); + put(&mut draft, "resource_type", resource_type); + Value::Object(draft) +} + +fn variable_to_draft_state(variable: &Value) -> Value { + let field = |key: &str| non_null(variable.get(key)).cloned(); + // An OAuth variable's value belongs to the refresh flow, and the API hands back a + // freshly refreshed live token for an expired one even undecrypted: carried into a + // draft, it would pin a rotating value to a static one at the next deploy. + let oauth_managed = variable.get("is_oauth").and_then(Value::as_bool) == Some(true) + || field("account").is_some(); + let value = if oauth_managed { None } else { field("value") }; + let mut state = Map::new(); + state.insert("path".into(), variable["path"].clone()); + state.insert( + "variable".into(), + json!({ + "value": value.unwrap_or_else(|| json!("")), + "is_secret": variable["is_secret"], + "description": field("description").unwrap_or_else(|| json!("")), + }), + ); + put(&mut state, "labels", field("labels")); + state.insert( + "wsSpecific".into(), + field("ws_specific").unwrap_or(json!(false)), + ); + for key in ["account", "is_oauth", "expires_at"] { + put(&mut state, key, field(key)); + } + Value::Object(state) +} + +/// The draft a `write_variable` call leaves. A variable's value is never read back, so +/// an omitted `value` keeps whatever the base holds: `""` for "nothing staged", or the +/// `$encrypted:` marker the draft store turned a staged secret into. +fn variable_draft(args: &Value, base: Option<Value>) -> Result<Value, String> { + let path = args["path"].as_str().unwrap_or_default(); + let arg = |key: &str| non_null(args.get(key)).cloned(); + let stored = |key: &str| { + base.as_ref() + .and_then(|b| b.get("variable")) + .and_then(|v| non_null(v.get(key)).cloned()) + }; + let value = arg("value"); + if base.is_none() && (value.is_none() || arg("is_secret").is_none()) { + return Err(format!("Variable \"{path}\" does not exist yet, so creating it requires both value and is_secret.")); + } + let was_secret = stored("is_secret").and_then(|s| s.as_bool()); + let is_secret = arg("is_secret") + .and_then(|s| s.as_bool()) + .or(was_secret) + .unwrap_or(false); + let empty = json!(""); + // "" is how a secret draft says "nothing staged", so it cannot also mean "set the + // secret to empty". + if is_secret && value.as_ref() == Some(&empty) { + return Err(format!("An empty string is not a valid value for secret variable \"{path}\". Omit value to keep the stored secret, or pass the real new one.")); + } + let next_value = value.clone().or_else(|| stored("value")); + if is_secret && was_secret == Some(false) && next_value.as_ref() == Some(&empty) { + return Err(format!("Cannot make variable \"{path}\" secret without a value: it currently holds an empty one, so there would be nothing to encrypt. Pass the value it should hold.")); + } + // The stored value of a secret cannot be read, and an `$encrypted:` marker is only + // decrypted at deploy while the target stays secret. + if !is_secret && was_secret == Some(true) && value.is_none() { + return Err(format!("Cannot turn secret variable \"{path}\" into a non-secret one without a value: its stored value cannot be read, so it would be replaced by an empty one. Pass the new plaintext value, or leave is_secret unset to keep it secret.")); + } + let description = arg("description").or_else(|| stored("description")); + + let mut draft = into_object(base); + let merged = |arg_key: &str, key: &str| { + non_null(args.get(arg_key)) + .or(non_null(draft.get(key))) + .cloned() + }; + let labels = merged("labels", "labels"); + let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false)); + let extras = ["account", "is_oauth", "expires_at"].map(|key| (key, merged(key, key))); + draft.insert("path".into(), json!(path)); + draft.insert( + "variable".into(), + json!({ + "value": next_value.unwrap_or(empty), + "is_secret": is_secret, + "description": description.unwrap_or_else(|| json!("")), + }), + ); + put(&mut draft, "labels", labels); + draft.insert("wsSpecific".into(), ws_specific); + for (key, value) in extras { + put(&mut draft, key, value); + } + Ok(Value::Object(draft)) +} + +// ============================================================================ +// Tools +// ============================================================================ + +/// The shared write: build on the caller's draft, else on the deployed item (shaped +/// into a draft value by `from_deployed`), else from nothing. +async fn write_draft( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, + force: bool, + from_deployed: impl FnOnce(Value) -> Value, + build: impl FnOnce(Option<Value>) -> Result<Value, String>, +) -> ToolResult { + let base = write_base(ctx, item_type, path, trigger_kind).await?; + let existed = base.base.is_some(); + let from_draft = base.from_draft; + let draft = build( + base.base + .map(|b| if from_draft { b } else { from_deployed(b) }), + )?; + let persisted = persist_draft( + ctx, + item_type, + path, + trigger_kind, + &draft, + base.last_sync.as_deref(), + force, + ) + .await?; + Ok(match persisted { + Persist::Saved(item) => finish_draft_write(ctx, &item, existed), + Persist::Failed(text) => text, + }) +} + +fn take_override(parsed: &mut Value) -> bool { + parsed + .as_object_mut() + .and_then(|o| o.remove("override")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +async fn write_schedule(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + // A named argument outranks the same key inside `advanced`. + let mut merged = into_object(args.get("advanced").cloned()); + for (key, value) in args.as_object().into_iter().flatten() { + if key != "advanced" { + merged.insert(key.clone(), value.clone()); + } + } + let merged = Value::Object(merged); + let mut parsed = parse_with(&SCHEDULE_SCHEMA, &merged)?; + let mut dropped = Vec::new(); + dropped_keys(&merged, Some(&parsed), "", &mut dropped); + if !dropped.is_empty() { + return Err(describe_dropped_schedule_options(&dropped)); + } + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "schedule", + None, + &path, + force, + |deployed| deployed, + |base| Ok(Value::Object(merge_config(base, parsed, &path))), + ) + .await +} + +async fn write_trigger(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_trigger", args)?; + let force = take_override(&mut parsed); + let kind = parsed["kind"].as_str().unwrap_or_default(); + let schema: Value = SCHEMAS + .trigger + .get(kind) + .and_then(|schema| serde_json::from_str(schema).ok()) + .ok_or_else(|| format!("Unknown trigger kind `{kind}`"))?; + // The draft is filed under `kind`, so a config of another kind is refused here + // rather than stored as a draft no editor or deploy can read. + let mut issues = Vec::new(); + let config = parse(&schema, parsed.get("config"), "", &mut issues); + let config = match config { + Some(config) if issues.is_empty() => config, + _ => { + return Err(format!( + "Invalid config for a \"{kind}\" trigger. Call get_trigger_schema with kind \"{kind}\" for its exact fields. Issues: {}", + issues.join("; ") + )) + } + }; + let path = config["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "trigger", + Some(kind), + &path, + force, + |deployed| deployed, + |base| { + let mut draft = merge_config(base, config, &path); + // Optional in the schema, but a NOT NULL column. Defaulted on the merged + // draft so an omitted field keeps the existing trigger's value. + if kind == "email" && non_null(draft.get("workspaced_local_part")).is_none() { + draft.insert("workspaced_local_part".into(), json!(false)); + } + Ok(Value::Object(draft)) + }, + ) + .await +} + +async fn write_resource(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_resource", args)?; + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "resource", + None, + &path, + force, + |deployed| resource_to_draft_state(&deployed), + |base| Ok(resource_draft(&parsed, base)), + ) + .await +} + +async fn write_variable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_variable", args)?; + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + // A variable's value is never interpolated, so its own `$var:` reference is the + // reference syntax echoed back, not a value. + if let Some(value) = parsed.get("value").and_then(Value::as_str) { + if value == format!("$var:{path}") { + return Err(format!("\"{value}\" is not a valid value for variable \"{path}\" — it is a self-reference. Omit value to keep the current one.")); + } + } + write_draft( + ctx, + "variable", + None, + &path, + force, + |deployed| variable_to_draft_state(&deployed), + |base| variable_draft(&parsed, base), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issues_of(schema: &Value, value: Value) -> (Option<Value>, Vec<String>) { + let mut issues = Vec::new(); + let parsed = parse(schema, Some(&value), "", &mut issues); + (parsed, issues) + } + + #[test] + fn trigger_config_parses_like_the_session() { + let kafka: Value = serde_json::from_str(&SCHEMAS.trigger["kafka"]).unwrap(); + let (parsed, issues) = issues_of( + &kafka, + json!({ + "path": "p", "script_path": "s", "is_flow": false, "kafka_resource_path": "r", + "group_id": "g", "topics": ["a"], "unknown_field": 1, + "filters": [{"key": "k", "value": 1, "zzz": 2}, {"any_of": [{"a": 1}]}], + }), + ); + assert_eq!(issues, Vec::<String>::new()); + let parsed = parsed.unwrap(); + assert_eq!(parsed.get("unknown_field"), None); + assert_eq!( + parsed["filters"], + json!([{"key": "k", "value": 1}, {"any_of": [{"a": 1}]}]) + ); + assert_eq!(parsed["filter_logic"], "and"); + assert_eq!(parsed["auto_commit"], true); + + let (_, issues) = issues_of( + &kafka, + json!({ + "path": 5, "is_flow": "x", "topics": "a", "filters": [3], "group_id": null, + "auto_offset_reset": "x", "kafka_resource_path": "r", + "retry": {"exponential": {"seconds": 0, "random_factor": 1.5}, "retry_if": {}}, + }), + ); + assert_eq!( + issues, + [ + "path: Invalid input: expected string, received number", + "script_path: Invalid input: expected string, received undefined", + "is_flow: Invalid input: expected boolean, received string", + "group_id: Invalid input: expected string, received null", + "topics: Invalid input: expected array, received string", + "filters.0: Invalid input", + "auto_offset_reset: Invalid option: expected one of \"latest\"|\"earliest\"", + "retry.exponential.seconds: Too small: expected number to be >=1", + "retry.exponential.random_factor: Invalid input: expected int, received number", + "retry.retry_if.expr: Invalid input: expected string, received undefined", + ] + ); + } + + #[test] + fn schedule_reports_what_parsing_would_drop() { + let merged = json!({ + "path": "p", "schedule": "s", "timezone": "UTC", "script_path": "x", + "is_flow": false, "args": null, "bogus": 1, "override": true, + "retry": {"constant": {"attempts": 2, "secs": 3}}, + }); + let parsed = parse_with(&SCHEDULE_SCHEMA, &merged).unwrap(); + let mut dropped = Vec::new(); + dropped_keys(&merged, Some(&parsed), "", &mut dropped); + assert_eq!( + describe_dropped_schedule_options(&dropped), + "These schedule options do not match their schema and would have been dropped: retry.constant.secs. Call get_schedule_schema for their exact shape. These are not schedule fields: bogus." + ); + assert!(parse_with( + &SCHEDULE_SCHEMA, + &json!({"path": "p", "paused_until": "tomorrow"}) + ) + .unwrap_err() + .contains("paused_until: Invalid ISO datetime")); + } + + #[test] + fn variable_value_rules() { + let secret = json!({ + "path": "p", + "variable": {"value": "$encrypted:abc", "is_secret": true, "description": "d"}, + "wsSpecific": false, + }); + let plain_empty = + json!({"path": "p", "variable": {"value": "", "is_secret": false, "description": ""}}); + + assert!(variable_draft(&json!({"path": "p", "value": "v"}), None).is_err()); + let kept = variable_draft( + &json!({"path": "p", "description": "new"}), + Some(secret.clone()), + ) + .unwrap(); + assert_eq!( + kept["variable"], + json!({"value": "$encrypted:abc", "is_secret": true, "description": "new"}) + ); + assert!(variable_draft(&json!({"path": "p", "value": ""}), Some(secret.clone())).is_err()); + assert!(variable_draft( + &json!({"path": "p", "is_secret": false}), + Some(secret.clone()) + ) + .is_err()); + let revealed = variable_draft( + &json!({"path": "p", "is_secret": false, "value": "v"}), + Some(secret), + ) + .unwrap(); + assert_eq!(revealed["variable"]["value"], "v"); + assert!( + variable_draft(&json!({"path": "p", "is_secret": true}), Some(plain_empty)).is_err() + ); + + let oauth = variable_to_draft_state(&json!({ + "path": "p", "value": "live-token", "is_secret": true, "account": 3, + "description": null, "expires_at": null, + })); + assert_eq!( + oauth, + json!({ + "path": "p", + "variable": {"value": "", "is_secret": true, "description": ""}, + "wsSpecific": false, + "account": 3, + }) + ); + } +} diff --git a/backend/windmill-api/src/mcp/session/misc.rs b/backend/windmill-api/src/mcp/session/misc.rs new file mode 100644 index 0000000000..d613b89f92 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/misc.rs @@ -0,0 +1,728 @@ +//! Lookups that touch no draft: hub, docs, npm, resource types, workers, folders. + +use std::collections::HashSet; + +use futures::future::join_all; +use serde_json::{json, Map, Value}; +use windmill_common::utils::HTTP_CLIENT; + +use super::{ + array, enc, is_not_found, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult, SCHEMAS, +}; + +pub(super) const TOOLS: &[&str] = &[ + "search_hub_scripts", + "get_hub_integration", + "search_npm_packages", + "search_docs", + "read_docs_page", + "search_resource_types", + "create_resource_type", + "list_workers", + "create_folder", + "get_trigger_schema", + "get_schedule_schema", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "search_hub_scripts" => search_hub_scripts(ctx, args).await, + "get_hub_integration" => get_hub_integration(ctx, args).await, + "search_npm_packages" => search_npm_packages(args).await, + "search_docs" => search_docs(ctx, args).await, + "read_docs_page" => read_docs_page(ctx, args).await, + "search_resource_types" => search_resource_types(ctx, args).await, + "create_resource_type" => create_resource_type(ctx, args).await, + "list_workers" => list_workers(ctx).await, + "create_folder" => create_folder(ctx, args).await, + "get_trigger_schema" => get_trigger_schema(args), + "get_schedule_schema" => Ok(SCHEMAS.schedule.clone()), + _ => unreachable!("{name} is not a misc tool"), + } +} + +fn get_trigger_schema(args: &Value) -> ToolResult { + let kind = str_arg(args, "kind")?; + SCHEMAS + .trigger + .get(kind) + .cloned() + .ok_or_else(|| format!("Unknown trigger kind `{kind}`")) +} + +/// Copies `keys` from `from` into `to`, each only when truthy. +fn copy_truthy(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) { + for key in keys { + if truthy(from.get(key)) { + to.insert(key.to_string(), from[key].clone()); + } + } +} + +/// Copies `keys` from `from` into `to`, each only when present (`null` included). +fn copy_present(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) { + for key in keys { + if let Some(value) = from.get(key) { + to.insert(key.to_string(), value.clone()); + } + } +} + +fn words(text: &str) -> impl Iterator<Item = String> + '_ { + text.split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|w| !w.is_empty()) + .map(str::to_ascii_lowercase) +} + +// --------------------------------------------------------------------------- +// Hub +// --------------------------------------------------------------------------- + +const MAX_BROWSED_HUB_SCRIPTS: usize = 20; +const MAX_SUGGESTED_INTEGRATIONS: usize = 5; +const MAX_INTEGRATION_EXAMPLES: usize = 5; + +/// An unreachable hub degrades to "no suggestions" rather than a tool error. +async fn hub_integrations(ctx: &Ctx<'_>) -> Vec<Value> { + match ctx.get("/integrations/hub/list?kind=script").await { + Ok(Value::Array(integrations)) => integrations, + _ => Vec::new(), + } +} + +/// A token matches a whole word of the slug or display name, or extends one from four +/// characters on: a bare substring test makes `for` hit sales*for*ce. +fn token_matches_integration(token: &str, integration: &Value) -> bool { + let name = integration.get("name").and_then(Value::as_str); + let display_name = integration.get("display_name").and_then(Value::as_str); + let words: Vec<String> = [name, display_name] + .into_iter() + .flatten() + .flat_map(words) + .collect(); + if words.iter().any(|w| w == token) { + return true; + } + token.len() >= 4 + && words + .iter() + .any(|w| w.len() >= 4 && (w.starts_with(token) || token.starts_with(w.as_str()))) +} + +/// Ranked by how many query words each integration accounts for, so "google drive" +/// puts `gdrive` ahead of every other `g*` integration matching on "google" alone. +fn suggest_hub_integrations(integrations: &[Value], query: &str) -> Vec<String> { + let tokens: Vec<String> = words(query).filter(|t| t.len() >= 2).collect(); + let mut ranked: Vec<(usize, &str)> = integrations + .iter() + .filter_map(|integration| { + let matched = tokens + .iter() + .filter(|t| token_matches_integration(t, integration)) + .count(); + let name = integration.get("name")?.as_str()?; + (matched > 0).then_some((matched, name)) + }) + .collect(); + ranked.sort_by(|a, b| b.0.cmp(&a.0)); + ranked + .into_iter() + .take(MAX_SUGGESTED_INTEGRATIONS) + .map(|(_, name)| name.to_string()) + .collect() +} + +/// The hub resolves a script by its version id alone; the other segments are descriptive. +fn hub_script_path(script: &Value) -> String { + let summary = script + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + format!( + "hub/{}/{}/{}", + script.get("version_id").unwrap_or(&Value::Null), + script + .get("app") + .and_then(Value::as_str) + .unwrap_or_default(), + summary.split_whitespace().collect::<Vec<_>>().join("_") + ) +} + +async fn search_hub_scripts(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "query").filter(|s| !s.is_empty()); + let app = opt_str_arg(args, "integration").filter(|s| !s.is_empty()); + if query.is_none() && app.is_none() { + return Ok( + "Pass query (what the script should do), integration (a slug to list), or both." + .to_string(), + ); + } + let app_param = app + .map(|app| format!("&app={}", enc(app))) + .unwrap_or_default(); + // Listing an integration goes through the top-scripts endpoint: it applies no + // similarity floor, so near-misses worth reading as examples survive. + let scripts = match query { + Some(query) => { + ctx.get(&format!( + "/embeddings/query_hub_scripts?text={}&kind=script{app_param}", + enc(query) + )) + .await? + } + None => ctx + .get(&format!( + "/scripts/hub/top?limit={MAX_BROWSED_HUB_SCRIPTS}&kind=script{app_param}" + )) + .await? + .get("asks") + .cloned() + .unwrap_or_default(), + }; + let scripts = array(Some(&scripts)); + let integrations = hub_integrations(ctx).await; + + if scripts.is_empty() { + let subject = [app, query] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join(" "); + return Ok(json!({ + "results": [], + "suggested_integrations": suggest_hub_integrations(&integrations, &subject) + }) + .to_string()); + } + + // Lowercased because a hub slug is case-sensitive and both casings can exist. + let documented: HashSet<String> = integrations + .iter() + .filter(|i| i.get("documented") == Some(&Value::Bool(true))) + .filter_map(|i| Some(i.get("name")?.as_str()?.to_lowercase())) + .collect(); + let results: Vec<Value> = scripts + .iter() + .map(|script| { + let mut result = Map::new(); + result.insert("path".to_string(), hub_script_path(script).into()); + copy_present(&mut result, script, &["summary"]); + let app = script.get("app").cloned().unwrap_or_default(); + let is_documented = + documented.contains(&app.as_str().unwrap_or_default().to_lowercase()); + result.insert("integration".to_string(), app); + if is_documented { + result.insert("documented".to_string(), true.into()); + } + copy_truthy(&mut result, script, &["description"]); + Value::Object(result) + }) + .collect(); + Ok(json!({ "results": results }).to_string()) +} + +/// How the authored notes were checked is provenance for a human reviewing the hub; +/// the verdict and the fields it flags as shaky are what a caller can act on. +fn without_provenance(meta: &Value) -> Value { + let Value::Object(meta) = meta else { + return meta.clone(); + }; + let mut rest = meta.clone(); + let validation = rest.shift_remove("validation"); + let kept: Map<String, Value> = validation + .as_ref() + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter(|(key, _)| *key == "status" || *key == "low_confidence_fields") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !kept.is_empty() { + rest.insert("validation".to_string(), Value::Object(kept)); + } + Value::Object(rest) +} + +async fn get_hub_integration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let integration = str_arg(args, "integration")?; + let doc = match ctx + .get(&format!("/integrations/hub/{}/meta", enc(integration))) + .await + { + Ok(doc) => doc, + Err(e) => { + // Only a 404 means the integration is absent. Reporting a timeout the same + // way would teach the model that a real integration does not exist. + let error = if is_not_found(&e) { + format!("No hub metadata for \"{integration}\".") + } else { + format!("Could not reach the hub for \"{integration}\"; it may still exist. Read its scripts instead, or try again.") + }; + let integrations = hub_integrations(ctx).await; + return Ok(json!({ + "error": error, + "suggested_integrations": suggest_hub_integrations(&integrations, integration) + }) + .to_string()); + } + }; + + // The hub owns this response's shape and can be older than this server, so every + // section is optional: a hub that sends less returns less. + let mut out = Map::new(); + if let Some(app) = doc.get("app") { + out.insert("integration".to_string(), app.clone()); + } + copy_present(&mut out, &doc, &["display_name"]); + copy_truthy(&mut out, &doc, &["description", "docs_url"]); + if truthy(doc.get("meta")) { + out.insert( + "verified_provider_notes".to_string(), + without_provenance(&doc["meta"]), + ); + } + let derived = doc.get("derived").filter(|d| truthy(Some(d))); + if let Some(derived) = derived { + let mut observed = Map::new(); + let hosts: Vec<Value> = array(derived.get("api_hosts")) + .iter() + .filter_map(|h| h.get("host").cloned()) + .collect(); + observed.insert("api_hosts".to_string(), hosts.into()); + copy_present(&mut observed, derived, &["style"]); + let languages: Vec<&String> = derived + .get("languages") + .and_then(Value::as_object) + .map(|l| l.keys().collect()) + .unwrap_or_default(); + observed.insert("languages".to_string(), json!(languages)); + copy_present(&mut observed, derived, &["script_counts"]); + out.insert("observed_from_scripts".to_string(), observed.into()); + } + if doc.get("curated") == Some(&Value::Bool(true)) { + out.insert( + "scripts_note".to_string(), + "These scripts were pruned to idiomatic actions rather than generated one per API endpoint, so they are worth following as style examples.".into(), + ); + } + let resource_types: Vec<Value> = array(doc.get("resource_types")) + .iter() + .map(|rt| { + let mut entry = Map::new(); + copy_present(&mut entry, rt, &["name"]); + copy_truthy(&mut entry, rt, &["description"]); + // The hub keeps a schema in a text column and hands it back as a JSON string. + match rt.get("schema") { + Some(Value::String(schema)) => { + let parsed = serde_json::from_str(schema) + .unwrap_or_else(|_| Value::String(schema.clone())); + entry.insert("schema".to_string(), parsed); + } + Some(schema) => { + entry.insert("schema".to_string(), schema.clone()); + } + None => {} + } + Value::Object(entry) + }) + .collect(); + out.insert("resource_types".to_string(), resource_types.into()); + let examples: Vec<Value> = array(derived.and_then(|d| d.get("top_scripts"))) + .iter() + .take(MAX_INTEGRATION_EXAMPLES) + .map(|script| { + let mut entry = Map::new(); + copy_present(&mut entry, script, &["path", "summary"]); + copy_truthy(&mut entry, script, &["description", "language"]); + Value::Object(entry) + }) + .collect(); + out.insert("example_scripts".to_string(), examples.into()); + Ok(Value::Object(out).to_string()) +} + +// --------------------------------------------------------------------------- +// npm +// --------------------------------------------------------------------------- + +const NPM_SCORE_THRESHOLD: f64 = 1000.0; +/// The session caps a package's readme and types at 1% of its model's context window +/// each. The caller's model is unknown here, so this is 1% of the window the session +/// assumes for an unknown model (128000). +const NPM_TEXT_LIMIT: usize = 1280; + +fn take_chars(text: &str, limit: usize) -> String { + text.chars().take(limit).collect() +} + +async fn fetch(url: &str) -> Result<reqwest::Response, reqwest::Error> { + HTTP_CLIENT.get(url).send().await +} + +/// The package's declared entry declaration file. The session walks every `.d.ts` of the +/// package in the browser and keeps the same number of characters of the result. +async fn npm_package_types(name: &str, version: &str) -> Option<String> { + let base = format!("https://unpkg.com/{name}@{version}"); + let manifest: Value = fetch(&format!("{base}/package.json")) + .await + .ok()? + .error_for_status() + .ok()? + .json() + .await + .ok()?; + let entry = ["types", "typings"] + .iter() + .find_map(|key| manifest.get(key)?.as_str()) + .unwrap_or("index.d.ts") + .trim_start_matches("./"); + let types = fetch(&format!("{base}/{entry}")) + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + Some(format!("// /node_modules/{name}/{entry}\n{types}")) +} + +async fn npm_search(query: &str) -> Result<Vec<Value>, String> { + let data: Value = fetch(&format!( + "https://registry.npmjs.org/-/v1/search?text={}&size=2", + enc(query) + )) + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + let packages = data + .get("objects") + .and_then(Value::as_array) + .ok_or("no `objects` in the registry answer")? + .iter() + .filter(|o| { + o.get("searchScore").and_then(Value::as_f64).unwrap_or(0.0) >= NPM_SCORE_THRESHOLD + }) + .filter_map(|o| { + let package = o.get("package")?; + Some(( + package.get("name")?.as_str()?, + package.get("version").and_then(Value::as_str), + )) + }); + Ok(join_all(packages.map(|(name, version)| async move { + let documentation = match fetch(&format!("https://unpkg.com/{name}/readme.md")).await { + Ok(response) => response.text().await.unwrap_or_default(), + Err(_) => String::new(), + }; + let types = npm_package_types(name, version.unwrap_or("latest")) + .await + .unwrap_or_default(); + json!({ + "package": name, + "documentation": take_chars(&documentation, NPM_TEXT_LIMIT), + "types": take_chars(&types, NPM_TEXT_LIMIT), + }) + })) + .await) +} + +async fn search_npm_packages(args: &Value) -> ToolResult { + let query = str_arg(args, "query")?; + Ok(match npm_search(query).await { + Ok(results) => Value::Array(results).to_string(), + Err(e) => { + tracing::warn!("npm package search failed: {e}"); + "Error searching external integration resources".to_string() + } + }) +} + +// --------------------------------------------------------------------------- +// Docs +// --------------------------------------------------------------------------- + +/// `text` of a `/docs/*` answer, or the session's wording for a failed lookup. +fn docs_text(result: Result<Value, String>, action: &str) -> String { + match result { + Ok(response) => response + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Err(e) => format!("Failed to {action}: {e}, pursuing with the user request..."), + } +} + +async fn search_docs(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "query").unwrap_or_default().trim(); + if query.is_empty() { + return Ok( + "No search query was provided. Provide a `query` of one or more keywords.".to_string(), + ); + } + let result = ctx.get(&format!("/docs/search?query={}", enc(query))).await; + Ok(docs_text(result, "search documentation")) +} + +async fn read_docs_page(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let url = opt_str_arg(args, "url").unwrap_or_default(); + if url.trim().is_empty() { + return Ok("No documentation page URL was provided. Provide a `url` — e.g. a `Source` URL returned by search_docs.".to_string()); + } + let section = opt_str_arg(args, "section") + .filter(|s| !s.trim().is_empty()) + .map(|s| format!("§ion={}", enc(s))) + .unwrap_or_default(); + let result = ctx + .get(&format!("/docs/page?url={}{section}", enc(url))) + .await; + Ok(docs_text(result, "read documentation page")) +} + +// --------------------------------------------------------------------------- +// Resource types +// --------------------------------------------------------------------------- + +/// The hub publishes hundreds of resource types and an instance seeds only a few of its +/// own, so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT: usize = 20; + +const HUB_SYNC_INSTRUCTIONS: &str = "Tell the user: no resource of a missing type can be created until a superadmin syncs resource types from the hub, with \"Sync resource types with Hub\" in the Add resource dialog of the Resources page."; + +async fn search_resource_types(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = str_arg(args, "query")?; + let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(5); + let search_route = ctx.ws(&format!( + "embeddings/query_resource_types?text={}&limit={limit}", + enc(query) + )); + let names_route = ctx.ws("resources/type/listnames"); + let (results, names) = + futures::future::join(ctx.get(&search_route), ctx.get(&names_route)).await; + let results = results?; + let results = array(Some(&results)); + let count = names + .map(|names| { + array(Some(&names)) + .iter() + .filter_map(Value::as_str) + .collect::<HashSet<_>>() + .len() + }) + .unwrap_or(0); + let listing: Vec<Value> = results + .iter() + .map(|rt| { + let mut entry = Map::new(); + copy_present(&mut entry, rt, &["name", "schema"]); + Value::Object(entry) + }) + .collect(); + let listing = pretty(&Value::Array(listing)); + // The similarity search still returns the nearest of the few types there are, so the + // note cannot wait for an empty result; it replaces the "no dedicated type" one, which + // would have the model create a duplicate of a type a sync brings. + if count > 0 && count < UNSYNCED_RESOURCE_TYPE_COUNT { + return Ok(format!("{listing}\n\nOnly {count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. {HUB_SYNC_INSTRUCTIONS}")); + } + // The search is semantic and always returns its closest types, so a miss reads + // like a hit unless the absence is spelled out. + let words: Vec<String> = words(query).filter(|w| w.len() >= 3).collect(); + let named = results.iter().any(|rt| { + let name = rt + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + words.iter().any(|w| name.contains(w.as_str())) + }); + Ok(if named || words.is_empty() { + listing + } else { + format!("{listing}\n\nNo resource type name contains \"{query}\": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.") + }) +} + +/// The prefix the Add resource type form adds: a hub type of the same name, synced later, +/// would otherwise clash with this one. +fn custom_resource_type_name(name: &str) -> String { + if name.starts_with("c_") { + name.to_string() + } else { + format!("c_{name}") + } +} + +async fn create_resource_type(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = custom_resource_type_name(str_arg(args, "name")?); + let description = str_arg(args, "description")?; + let schema = args + .get("schema") + .filter(|s| s.is_object()) + .ok_or("Missing required object argument `schema`")?; + ctx.post( + &ctx.ws("resources/type/create"), + json!({ "name": name, "description": description, "schema": schema }), + ) + .await?; + Ok(json!({ + "success": true, + "message": format!("Created resource type {name}. Refer to it by this name.") + }) + .to_string()) +} + +// --------------------------------------------------------------------------- +// Workers +// --------------------------------------------------------------------------- + +const WORKER_PAGE_SIZE: usize = 100; +// `GET /workers/list` hides workers from a caller without the devops role by returning +// an empty list, not an error, when HIDE_WORKERS_FOR_NON_ADMINS is set. The flag is not +// visible here, so absence is only provable for a devops or superadmin caller. +const NO_WORKERS_VISIBLE_MESSAGE: &str = "No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none."; +const NO_WORKERS_CONNECTED_MESSAGE: &str = "No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts."; + +async fn list_workers(ctx: &Ctx<'_>) -> ToolResult { + let pings = ctx + .get(&format!("/workers/list?per_page={WORKER_PAGE_SIZE}")) + .await?; + let pings = array(Some(&pings)); + if pings.is_empty() { + let me = ctx.get("/users/whoami").await.unwrap_or_default(); + let sees_all = truthy(me.get("super_admin")) || truthy(me.get("devops")); + return Ok(if sees_all { + NO_WORKERS_CONNECTED_MESSAGE + } else { + NO_WORKERS_VISIBLE_MESSAGE + } + .to_string()); + } + let workers: Vec<Value> = pings + .iter() + .map(|ping| { + let mut worker = Map::new(); + copy_present( + &mut worker, + ping, + &[ + "worker", + "worker_group", + "custom_tags", + "last_ping", + "jobs_executed", + ], + ); + Value::Object(worker) + }) + .collect(); + let mut out = Map::new(); + let listed = workers.len(); + out.insert("workers".to_string(), workers.into()); + if listed == WORKER_PAGE_SIZE { + out.insert( + "note".to_string(), + format!("Only the first {WORKER_PAGE_SIZE} workers are listed; more may be connected.") + .into(), + ); + } + Ok(pretty(&Value::Object(out))) +} + +// --------------------------------------------------------------------------- +// Folders +// --------------------------------------------------------------------------- + +async fn create_folder(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = str_arg(args, "name")?; + // Same rule as the API's own check, so an invalid name gets this wording. + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Ok(json!({ + "success": false, + "error": "Folder name can only contain alphanumeric characters, underscores, and hyphens." + }) + .to_string()); + } + let mut body = Map::new(); + body.insert("name".to_string(), name.into()); + copy_present(&mut body, args, &["summary"]); + Ok( + match ctx.post(&ctx.ws("folders/create"), body.into()).await { + Ok(_) => json!({ + "success": true, + "message": format!("Created folder `f/{name}`. You can now write items to `f/{name}/<name>`.") + }), + Err(error) => json!({ "success": false, "error": error }), + } + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggestions_rank_by_matched_words() { + let integrations = vec![ + json!({ "name": "gcal", "display_name": "Google Calendar" }), + json!({ "name": "gdrive", "display_name": "Google Drive" }), + json!({ "name": "salesforce", "display_name": null }), + json!({ "name": "s3", "display_name": "S3" }), + ]; + assert_eq!( + suggest_hub_integrations(&integrations, "google drive"), + ["gdrive", "gcal"] + ); + // A short token must equal a word; a long one may extend it. + assert!(suggest_hub_integrations(&integrations, "for").is_empty()); + assert_eq!( + suggest_hub_integrations(&integrations, "sales"), + ["salesforce"] + ); + assert_eq!(suggest_hub_integrations(&integrations, "s3 bucket"), ["s3"]); + } + + #[test] + fn provenance_keeps_only_the_verdict() { + let meta = json!({ + "pagination": "cursor", + "validation": { "status": "verified", "method": "live", "low_confidence_fields": ["a"] } + }); + assert_eq!( + without_provenance(&meta), + json!({ + "pagination": "cursor", + "validation": { "status": "verified", "low_confidence_fields": ["a"] } + }) + ); + assert_eq!( + without_provenance(&json!({ "a": 1, "validation": { "method": "live" } })), + json!({ "a": 1 }) + ); + } + + #[test] + fn custom_resource_types_carry_the_c_prefix_once() { + assert_eq!(custom_resource_type_name("acme"), "c_acme"); + assert_eq!(custom_resource_type_name("c_acme"), "c_acme"); + } + + #[test] + fn hub_path_from_summary() { + let script = json!({ "version_id": 28479, "app": "stripe", "summary": "Cancel Or Reverse a Payout" }); + assert_eq!( + hub_script_path(&script), + "hub/28479/stripe/cancel_or_reverse_a_payout" + ); + } +} diff --git a/backend/windmill-api/src/mcp/session/mod.rs b/backend/windmill-api/src/mcp/session/mod.rs new file mode 100644 index 0000000000..607c6be5f8 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/mod.rs @@ -0,0 +1,609 @@ +//! The Windmill MCP server's toolset (`/api/mcp`, tokens with `mcp:all`): an AI session's toolset, for a client +//! outside the Windmill UI. +//! +//! Everything a model reads here — tool names, descriptions, input schemas, the +//! instructions, the `get_instructions` texts — is generated from the session's own +//! definitions into `gen/` (`npm run gen:mcp-session` in `frontend/`). What a tool +//! *does* is written here, mirroring its session counterpart in +//! `frontend/src/lib/components/copilot/chat/global/core.ts`: a change to one side's +//! behaviour belongs on the other. +//! +//! A tool acts through the API as the caller, the way the session's does, so every +//! permission check is the API's own. It calls the server's router in-process rather +//! than over a socket. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde_json::{Map, Value}; +use windmill_mcp::server::{ + BackendResult, CallToolResult, ContentBlock, ErrorData, MetaObject, SessionAccess, Tool, + ToolAnnotations, +}; + +use crate::db::ApiAuthed; + +use super::utils::{mint_caller_jwt, send_as_caller}; + +/// Each running server's fully layered router, by its `base_internal_url`. Keyed rather +/// than global because the backend tests run one server per test in a single process, +/// each on its own database: a tool must reach the server it was called on. +static IN_PROCESS_ROUTERS: LazyLock<std::sync::RwLock<HashMap<String, axum::Router>>> = + LazyLock::new(Default::default); + +/// Let session tools of the server at `base_internal_url` call its API in-process. +/// Without a registration they fall back to HTTP. +pub(crate) fn register_router(base_internal_url: &str, router: Option<axum::Router>) { + let mut routers = IN_PROCESS_ROUTERS.write().unwrap(); + match router { + Some(router) => routers.insert(base_internal_url.to_string(), router), + None => routers.remove(base_internal_url), + }; +} + +mod apps; +mod data; +mod deploy; +mod flows; +mod guidance; +mod instructions; +mod items; +mod misc; +mod runs; +mod settings; +mod workspace; + +pub(super) use instructions::{ + generic_session_instructions, session_instructions, workspace_context, +}; + +#[derive(Deserialize)] +struct ToolDef { + name: String, + description: String, + parameters: Map<String, Value>, + read_only: bool, +} + +static TOOL_DEFS: LazyLock<Vec<ToolDef>> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/tools.json")).expect("gen/tools.json is valid") +}); + +/// A tool's input schema, as generated from the session's zod schema. +pub(super) fn tool_parameters(name: &str) -> Option<&'static Map<String, Value>> { + TOOL_DEFS + .iter() + .find(|def| def.name == name) + .map(|def| &def.parameters) +} + +/// The JSON Schema text of each trigger kind's config, and of a schedule's `advanced` bag, +/// as the session renders them from its request schemas. +#[derive(Deserialize)] +pub(super) struct Schemas { + pub trigger: HashMap<String, String>, + pub schedule: String, +} + +pub(super) static SCHEMAS: LazyLock<Schemas> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/schemas.json")).expect("gen/schemas.json is valid") +}); + +/// Session tools that need the Windmill UI and have no meaning for an external client. +/// Every generated tool is either here or implemented by a group below. +#[cfg(test)] +const EDITOR_ONLY: &[&str] = &[ + // The side panel, the question card and the browser-held user instructions. + "open_page", + "askUserQuestion", + "update_user_instructions", + // Files attached to the chat. + "search_files", + "read_file", +]; + +/// Tools whose effect reaches the deployed workspace and cannot be undone by discarding +/// a draft. Stands in for the session's confirmation card. +const DESTRUCTIVE: &[&str] = &[ + "deploy_workspace_item", + "delete_workspace_item", + "create_resource_type", + "set_workspace_settings", +]; + +/// Tools the session counts as reads that still start a job. A read-only token runs +/// nothing on the tools server, so it runs nothing here either. +const RUNS_A_JOB: &[&str] = &["get_db_schema"]; + +/// Tools that run a deployed script or flow, the one write a read-and-run token keeps. +const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow"]; + +fn allowed(def: &ToolDef, access: SessionAccess) -> bool { + let read = def.read_only && !RUNS_A_JOB.contains(&def.name.as_str()); + match access { + SessionAccess::Full => true, + SessionAccess::ReadAndRun => read || RUNS_DEPLOYED.contains(&def.name.as_str()), + SessionAccess::ReadOnly => read, + } +} + +/// Each group's tools and its dispatcher, tried in order. +macro_rules! groups { + ($($group:ident),*) => { + fn implemented() -> impl Iterator<Item = &'static str> { + std::iter::empty()$(.chain($group::TOOLS.iter().copied()))* + } + + async fn dispatch(ctx: &Ctx<'_>, name: &str, args: &Value) -> Option<ToolResult> { + $(if $group::TOOLS.contains(&name) { + return Some($group::call(ctx, name, args).await); + })* + None + } + }; +} +groups!(guidance, workspace, items, misc, flows, apps, runs, deploy, data, settings); + +/// Claude clients keep an MCP result in context only up to a token budget well under the +/// authoring references `get_instructions` returns, unless the tool raises it. Above the +/// runner's own cap on a result, so that cap is the one that applies. +fn result_size_meta() -> MetaObject { + let mut meta = Map::new(); + meta.insert("anthropic/maxResultSizeChars".to_string(), 100_000.into()); + MetaObject(meta) +} + +pub(super) fn session_tools(access: SessionAccess) -> Vec<Tool> { + TOOL_DEFS + .iter() + .filter(|def| implemented().any(|name| name == def.name)) + .filter(|def| allowed(def, access)) + .map(|def| { + Tool::new( + def.name.clone(), + def.description.clone(), + def.parameters.clone(), + ) + .with_annotations( + ToolAnnotations::new() + .read_only(def.read_only) + .destructive(DESTRUCTIVE.contains(&def.name.as_str())), + ) + .with_meta(result_size_meta()) + }) + .collect() +} + +/// What a tool hands back: the text the model reads, or the message of a failed call. +pub(super) type ToolResult = Result<String, String>; + +/// The caller, and the API reached as them. +pub(super) struct Ctx<'a> { + pub auth: &'a ApiAuthed, + pub w_id: &'a str, + pub base_internal_url: &'a str, + pub access: SessionAccess, + /// The scheme and host the MCP client reached, when known. + pub origin: Option<&'a str>, + /// Minted once per tool call: every JWT the API sees is cached by the auth layer, and a + /// tool can make dozens of calls, which would evict other users' cached sessions. + pub jwt: tokio::sync::OnceCell<String>, +} + +impl Ctx<'_> { + /// Call the API as the caller. `path` follows `/api`; a non-2xx answer is the error. + pub async fn request(&self, method: &str, path: &str, body: Option<Value>) -> ToolResult { + let url = guarded_url(self.base_internal_url, self.w_id, method, path, self.access)?; + // No route scope: this server only admits `mcp:all`, several routes (hub, embeddings) + // map to none, and `guarded_url` is what keeps a call on its route. The tag fence + // is not a route scope and stays: it limits which jobs the caller runs and reads. + let tag_scopes: Vec<String> = self + .auth + .scopes + .iter() + .flatten() + .filter(|s| s.starts_with("if_jobs:filter_tags:")) + .cloned() + .collect(); + let scopes = (!tag_scopes.is_empty()).then_some(tag_scopes); + let router = IN_PROCESS_ROUTERS + .read() + .unwrap() + .get(self.base_internal_url) + .cloned(); + let (status, text) = match router { + Some(router) => self.call_router(router, method, &url, body, scopes).await?, + None => { + let response = send_as_caller(method, &url, self.w_id, self.auth, body, scopes) + .await + .map_err(|e| e.message.to_string())?; + let status = response.status(); + (status, response.text().await.map_err(|e| e.to_string())?) + } + }; + if status.is_success() { + Ok(text) + } else { + Err(format!("{}: {}", status.as_u16(), text)) + } + } + + /// `request` through the server's own router: the same layers and handlers as over + /// HTTP, without a socket. + async fn call_router( + &self, + router: axum::Router, + method: &str, + url: &str, + body: Option<Value>, + scopes: Option<Vec<String>>, + ) -> Result<(http::StatusCode, String), String> { + use tower::ServiceExt; + let parsed = reqwest::Url::parse(url).map_err(|e| e.to_string())?; + // The router does not see the base URL's own path prefix. + let at = reqwest::Url::parse(self.base_internal_url) + .map_or(0, |base| base.path().trim_end_matches('/').len()); + let uri = match parsed.query() { + Some(q) => format!("{}?{q}", &parsed.path()[at..]), + None => parsed.path()[at..].to_string(), + }; + let token = self + .jwt + .get_or_try_init(|| mint_caller_jwt(self.w_id, self.auth, scopes)) + .await + .map_err(|e| e.message.to_string())?; + let mut request = http::Request::builder() + .method(method) + .uri(uri) + .header(http::header::AUTHORIZATION, format!("Bearer {token}")); + let body = match body { + Some(body) => { + request = request.header(http::header::CONTENT_TYPE, "application/json"); + axum::body::Body::from(serde_json::to_vec(&body).map_err(|e| e.to_string())?) + } + None => axum::body::Body::empty(), + }; + let request = request.body(body).map_err(|e| e.to_string())?; + // The total timeout of the HTTP client this replaces. + tokio::time::timeout(std::time::Duration::from_secs(30), async { + let response = router.oneshot(request).await.map_err(|e| e.to_string())?; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|e| e.to_string())?; + Ok((status, String::from_utf8_lossy(&bytes).into_owned())) + }) + .await + .map_err(|_| format!("Failed to execute request: {method} {url} timed out"))? + } + + pub async fn get(&self, path: &str) -> Result<Value, String> { + let text = self.request("GET", path, None).await?; + serde_json::from_str(&text).map_err(|e| format!("unexpected response from {path}: {e}")) + } + + /// `POST` a JSON body. Several endpoints answer with plain text (a path, a job id): + /// that comes back as a JSON string. + pub async fn post(&self, path: &str, body: Value) -> Result<Value, String> { + let text = self.request("POST", path, Some(body)).await?; + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) + } + + pub async fn delete(&self, path: &str) -> Result<Value, String> { + let text = self.request("DELETE", path, None).await?; + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) + } + + /// A workspace-scoped path: `ws("scripts/list")` is `/w/<workspace>/scripts/list`. + pub fn ws(&self, rest: &str) -> String { + format!("/w/{}/{}", self.w_id, rest) + } + + /// A Windmill page in this workspace, as a link the user can open: an external client + /// has no preview panel, so a link is how it shows what a tool made or ran. + pub fn page_url(&self, page: &str) -> String { + let (page, hash) = page + .split_once('#') + .map_or((page, None), |(p, h)| (p, Some(h))); + let sep = if page.contains('?') { '&' } else { '?' }; + let hash = hash.map(|h| format!("#{h}")).unwrap_or_default(); + // An instance that never set its base URL holds this placeholder, which no client + // can open; the host the client reached is the better guess then. + let base = windmill_common::BASE_URL.load(); + let base = match self.origin { + Some(origin) if base.as_str() == "http://localhost" => origin, + _ => base.as_str(), + }; + format!("{base}{page}{sep}workspace={}{hash}", enc(self.w_id)) + } +} + +/// Routes outside the workspace that a tool reads. +const INSTANCE_ROUTES: &[&str] = &[ + "/docs/", + "/embeddings/", + "/integrations/hub/", + "/scripts/hub/", + "/users/whoami", + "/workers/list", + "/workspaces/users", +]; + +/// The URL of an API call, refused unless it lands on the route its handler wrote. +/// +/// The JWT behind a call carries the caller's full rights, and paths embed arguments a +/// model chose: a `..` segment, or an id carrying `#`, would otherwise turn a read of +/// one job into a run of any script. A read-only or read-and-run token is held to its rule +/// here too, since that JWT carries neither. +fn guarded_url( + base: &str, + w_id: &str, + method: &str, + path: &str, + access: SessionAccess, +) -> Result<String, String> { + let refused = || format!("Refusing to call `{path}`: not a route of this tool"); + let route = path.split('?').next().unwrap_or_default(); + let in_workspace = route + .strip_prefix("/w/") + .and_then(|rest| rest.strip_prefix(w_id)) + .is_some_and(|rest| rest.starts_with('/')); + let workspace_is_plain = w_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if path.contains('#') + || !workspace_is_plain + || !(in_workspace || INSTANCE_ROUTES.iter().any(|r| route.starts_with(r))) + { + return Err(refused()); + } + let url = format!("{base}/api{path}"); + let parsed = reqwest::Url::parse(&url).map_err(|_| refused())?; + // Parsing resolves dot segments, encoded or not: a path that changed went elsewhere. + if !parsed.path().ends_with(&format!("/api{route}")) { + return Err(refused()); + } + // Runs of deployed items by path only: the preview routes take code, so they are writes. + // A run also stores its secret arguments as expiring variables; the write tools that + // could create other variables are not offered to a read-and-run token. + let run_writes = method == "POST" + && ["jobs/run/p/", "jobs/run/f/"] + .iter() + .any(|run| route.starts_with(&format!("/w/{w_id}/{run}"))) + || method == "POST" && route == format!("/w/{w_id}/variables/create"); + let held_to_reads = match access { + SessionAccess::Full => false, + SessionAccess::ReadAndRun => !run_writes, + SessionAccess::ReadOnly => true, + }; + if held_to_reads { + windmill_api_auth::scopes::check_read_only_for_route(parsed.path(), method) + .map_err(|e| e.to_string())?; + } + Ok(url) +} + +/// A string argument, or the error the model gets for leaving it out. +pub(super) fn str_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> { + args.get(name) + .and_then(Value::as_str) + .ok_or_else(|| format!("Missing required string argument `{name}`")) +} + +pub(super) fn opt_str_arg<'a>(args: &'a Value, name: &str) -> Option<&'a str> { + args.get(name).and_then(Value::as_str) +} + +/// `JSON.stringify(value, null, 2)`, the form the session's tools return objects in. +pub(super) fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_default() +} + +/// Percent-encode one query-string value. +pub(super) fn enc(value: &str) -> String { + urlencoding::encode(value).into_owned() +} + +pub(super) fn bool_arg(args: &Value, name: &str) -> bool { + args.get(name).and_then(Value::as_bool).unwrap_or(false) +} + +/// Whether a `Ctx` error is the API answering 404. +pub(super) fn is_not_found(error: &str) -> bool { + error.starts_with("404: ") +} + +/// JavaScript truthiness, for the fields the session keeps only `if (value)`. +pub(super) fn truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().is_some_and(|n| n != 0.0), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// `value ?? undefined`: the value unless it is missing or null. +pub(super) fn non_null(value: Option<&Value>) -> Option<&Value> { + value.filter(|v| !v.is_null()) +} + +/// Set `key` unless the value is JS `undefined`, which `JSON.stringify` drops. +pub(super) fn put(map: &mut Map<String, Value>, key: &str, value: Option<Value>) { + if let Some(value) = value { + map.insert(key.to_string(), value); + } +} + +/// The elements of an array; none for anything else. +pub(super) fn array(value: Option<&Value>) -> &[Value] { + value + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() +} + +/// A value as a JS template literal prints it. +pub(super) fn js_str(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// `String.length`: UTF-16 code units. +pub(super) fn js_len(text: &str) -> usize { + text.encode_utf16().count() +} + +/// `text.slice(0, max)` in UTF-16 units, never splitting a character. +pub(super) fn js_head(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut units = 0; + for (i, c) in text.char_indices() { + units += c.len_utf16(); + if units > max { + return &text[..i]; + } + } + text +} + +/// `text.slice(-max)` in UTF-16 units, never splitting a character. +pub(super) fn js_tail(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut units = 0; + for (i, c) in text.char_indices().rev() { + units += c.len_utf16(); + if units > max { + return &text[i + c.len_utf8()..]; + } + } + text +} + +/// `*` any characters but `/`, `**` any characters, `?` one character but `/`. A pattern +/// without `/` is matched against the file name alone, so `*.tsx` finds nested files. +pub(super) fn matches_glob(subject: &str, glob: &str) -> bool { + let subject = if glob.contains('/') { + subject + } else { + subject.rsplit('/').next().unwrap_or(subject) + }; + let mut pattern = String::from("^"); + let mut chars = glob.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + pattern.push_str(".*"); + } + '*' => pattern.push_str("[^/]*"), + '?' => pattern.push_str("[^/]"), + c => pattern.push_str(®ex::escape(c.encode_utf8(&mut [0; 4]))), + } + } + pattern.push('$'); + regex::Regex::new(&pattern).is_ok_and(|re| re.is_match(subject)) +} + +pub(super) async fn call_session_tool( + ctx: &Ctx<'_>, + name: &str, + args: Value, +) -> BackendResult<CallToolResult> { + let Some(def) = TOOL_DEFS.iter().find(|def| def.name == name) else { + return Err(ErrorData::invalid_params( + format!("Unknown tool '{name}'"), + None, + )); + }; + if !allowed(def, ctx.access) { + let token = match ctx.access { + SessionAccess::ReadAndRun => "can only read and run deployed scripts and flows", + _ => "is read-only", + }; + return Err(ErrorData::invalid_request( + format!("Tool '{name}' changes or runs something, and this token {token}"), + None, + )); + } + let Some(result) = dispatch(ctx, name, &args).await else { + return Err(ErrorData::invalid_params( + format!("Tool '{name}' is not available on this server"), + None, + )); + }; + windmill_common::feature_usage::log_feature_usage("mcp_workspace", "tool_call", name); + Ok(match result { + Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]), + // Without the status code `Ctx` prefixes an API failure with, as the session reads it. + Err(message) => { + let body = workspace::api_message(&message); + let text = if body.is_empty() { &message } else { body }; + CallToolResult::error(vec![ContentBlock::text(text.to_string())]) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_call_stays_on_the_route_its_handler_wrote() { + use SessionAccess::*; + let url = |method, path, access| guarded_url("http://h", "ws", method, path, access); + assert!(url("GET", "/w/ws/jobs_u/get/0191?no_logs=true", Full).is_ok()); + assert!(url("GET", "/docs/search?query=a%2Fb", ReadOnly).is_ok()); + for path in [ + "/w/ws/jobs_u/get/../../jobs/run_wait_result/p/u/a/b", + "/w/ws/jobs_u/get/%2e%2e/%2E%2E/variables/get_value/u/a/b", + "/w/ws/jobs_u/get/x#", + "/w/other/scripts/list", + "/w/ws", + "/settings/global/x", + "/docs/../users/tokens/list", + ] { + assert!(url("GET", path, Full).is_err(), "{path} should be refused"); + } + assert!(guarded_url("http://h", "a/../b", "GET", "/w/a/../b/scripts/list", Full).is_err()); + // A read-only token neither writes nor runs, whatever the tool. + assert!(url("POST", "/w/ws/drafts/update", ReadOnly).is_err()); + assert!(url("GET", "/w/ws/jobs/run_wait_result/p/u/a/b", ReadOnly).is_err()); + assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadOnly).is_err()); + // A read-and-run token runs deployed items by path, and writes nothing else: a + // preview takes code. + assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/jobs/run/f/u/a/b", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/jobs/run/preview", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/drafts/update", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/variables/create", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/variables/update/u/a/b", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/variables/create", ReadOnly).is_err()); + } + + #[test] + fn every_session_tool_is_implemented_or_editor_only() { + let implemented: Vec<&str> = implemented().collect(); + for def in TOOL_DEFS.iter() { + let name = def.name.as_str(); + assert!( + implemented.contains(&name) != EDITOR_ONLY.contains(&name), + "session tool `{name}` must be implemented here or listed in EDITOR_ONLY, not both" + ); + } + for name in implemented.iter().chain(EDITOR_ONLY) { + assert!( + TOOL_DEFS.iter().any(|def| def.name == *name), + "`{name}` is not a session tool any more" + ); + } + } +} diff --git a/backend/windmill-api/src/mcp/session/runs.rs b/backend/windmill-api/src/mcp/session/runs.rs new file mode 100644 index 0000000000..10f1c7e224 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/runs.rs @@ -0,0 +1,2009 @@ +//! Running scripts and flows, test runs on drafts, and reading runs back. + +use std::collections::HashMap; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use super::workspace::{ + api_message, get_draft, infer_script_schema, load_flow, load_script, path_enc, +}; +use super::{enc, js_head, js_len, js_tail, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "test_run_script", + "run_script", + "test_run_flow", + "run_flow", + "test_run_step", + "list_runs", + "get_run", + "cancel_job", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "test_run_script" => test_run_script(ctx, args).await, + "run_script" => run_deployed(ctx, args, false).await, + "test_run_flow" => test_run_flow(ctx, args).await, + "run_flow" => run_deployed(ctx, args, true).await, + "test_run_step" => test_run_step(ctx, args).await, + "list_runs" => list_runs(ctx, args).await, + "get_run" => get_run(ctx, args).await, + "cancel_job" => cancel_job(ctx, args).await, + _ => unreachable!("{name} is not a runs tool"), + } +} + +const MAX_RESULT_LENGTH: usize = 12000; +const MAX_LOG_LENGTH: usize = 4000; +const DEFAULT_WAIT_MS: f64 = 15000.0; +const MAX_WAIT_MS: f64 = 120000.0; +const MAX_SUBMITTED_ARGS_LENGTH: usize = 4000; + +/// `JSON.stringify(value, null, 1)`, the indent `get_run` renders with. +fn pretty1(value: &Value) -> String { + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter); + value + .serialize(&mut serializer) + .expect("a JSON value serializes"); + String::from_utf8(out).unwrap_or_default() +} + +// ============================================================================ +// Job helpers shared with the other groups. +// ============================================================================ + +fn job_id(response: Value) -> String { + match response { + Value::String(id) => id.trim().to_string(), + other => other.to_string(), + } +} + +/// Queue a script preview and return its job id. `path` files the run under an item. +pub(super) async fn run_script_preview( + ctx: &Ctx<'_>, + path: Option<&str>, + language: &str, + content: &str, + args: Value, +) -> Result<String, String> { + let mut body = Map::new(); + if let Some(path) = path { + body.insert("path".into(), path.into()); + } + body.insert("content".into(), content.into()); + body.insert("language".into(), language.into()); + body.insert("args".into(), args); + ctx.post(&ctx.ws("jobs/run/preview"), Value::Object(body)) + .await + .map(job_id) +} + +/// Queue a flow preview of `value` and return its job id. `memory_id` names the +/// conversation a chat-enabled flow's run belongs to. +pub(super) async fn run_flow_preview( + ctx: &Ctx<'_>, + path: Option<&str>, + value: &Value, + args: Value, + memory_id: Option<&str>, +) -> Result<String, String> { + let mut body = Map::new(); + if let Some(path) = path { + body.insert("path".into(), path.into()); + } + body.insert("value".into(), value.clone()); + body.insert("args".into(), args); + let query = memory_id + .map(|id| format!("?memory_id={}", enc(id))) + .unwrap_or_default(); + ctx.post( + &ctx.ws(&format!("jobs/run/preview_flow{query}")), + Value::Object(body), + ) + .await + .map(job_id) +} + +/// How many seconds a run tool waits before leaving the job in the background, from +/// its `background` and `wait_seconds` arguments: 15 by default, 120 at most. +pub(super) fn wait_budget(tool_args: &Value) -> u64 { + if tool_args.get("background").and_then(Value::as_bool) == Some(true) { + return 0; + } + let ms = tool_args + .get("wait_seconds") + .and_then(Value::as_f64) + .map_or(DEFAULT_WAIT_MS, |seconds| seconds.max(0.0) * 1000.0) + .min(MAX_WAIT_MS); + (ms / 1000.0).ceil() as u64 +} + +/// Poll a job once a second for up to `seconds`. The completed job, fetched once more +/// with its logs when `logs` is set, or `None` when it is still running by then. A +/// failed poll is retried on the next tick. +pub(super) async fn wait_job(ctx: &Ctx<'_>, id: &str, seconds: u64, logs: bool) -> Option<Value> { + let path = |no_logs: bool| { + ctx.ws(&format!( + "jobs_u/get/{}?no_logs={no_logs}&no_code=true", + enc(id) + )) + }; + for _ in 0..seconds { + tokio::time::sleep(Duration::from_secs(1)).await; + if let Ok(job) = ctx.get(&path(true)).await { + if job["type"] == "CompletedJob" { + return Some(match logs { + true => ctx.get(&path(false)).await.unwrap_or(job), + false => job, + }); + } + } + } + None +} + +/// What the model reads for a job that outlived its wait. +pub(super) fn backgrounded_summary(id: &str, label: &str) -> String { + format!( + "Job {id} for \"{label}\" is taking a while and is now running in the background. \ + To inspect it, call get_run with id=\"{id}\" (or list_runs); \ + to stop it, call cancel_job with id=\"{id}\"." + ) +} + +/// A job result as text: a string as it is, anything else as indented JSON. +pub(super) fn format_result(result: Option<&Value>) -> String { + match result { + Some(Value::String(text)) => text.clone(), + other => pretty(other.unwrap_or(&Value::Null)), + } +} + +/// The last 4000 chars of a job's logs, or `None` when it logged nothing. +pub(super) fn format_logs(logs: Option<&str>) -> Option<&str> { + logs.filter(|logs| !logs.trim().is_empty()) + .map(|logs| js_tail(logs, MAX_LOG_LENGTH)) +} + +/// A completed job as the run tools report it: outcome, result head (12k chars) and +/// logs tail. +pub(super) fn format_result_summary(job: &Value) -> String { + let success = truthy(job.get("success")); + let result = format_result(job.get("result")); + format!( + "Result ({})\n\n{}\n\nLogs:\n\n{}", + if success { "SUCCESS" } else { "FAILED" }, + js_head(&result, MAX_RESULT_LENGTH), + format_logs(job.get("logs").and_then(Value::as_str)).unwrap_or("No logs available"), + ) +} + +/// Settle a just-queued job (`started` is the queueing call's answer) into a run +/// tool's result: wait within the tool's budget, then the result summary or the +/// background notice. `action` is `test` or `run`, `label` names the job in the notice. +pub(super) async fn finish_run( + ctx: &Ctx<'_>, + started: Result<String, String>, + label: &str, + is_flow: bool, + action: &str, + tool_args: &Value, +) -> ToolResult { + let id = started.map_err(|e| format!("Failed to execute {action}: {}", api_message(&e)))?; + let run_page = format!("\n\nRun page: {}", ctx.page_url(&format!("/run/{id}"))); + let Some(job) = wait_job(ctx, &id, wait_budget(tool_args), true).await else { + return Ok(format!("{}{run_page}", backgrounded_summary(&id, label))); + }; + let mut summary = format_result_summary(&job); + if is_flow && !truthy(job.get("success")) { + summary.push_str(&format!( + "\n\nFor per-step statuses and results (subflow steps included), call get_run with id=\"{id}\"." + )); + } + summary.push_str(&run_page); + Ok(summary) +} + +// ============================================================================ +// Arguments: conformed to the schema of what is about to run, secrets minted. +// ============================================================================ + +const REFERENCE_PREFIXES: [&str; 3] = ["$var:", "$res:", "$jsonvar:"]; + +fn is_reference(value: &Value) -> bool { + value.as_str().is_some_and(|s| { + REFERENCE_PREFIXES + .iter() + .any(|prefix| s.starts_with(prefix)) + }) +} + +fn is_secret_prop(prop: &Value) -> bool { + truthy(prop.get("password")) +} + +fn is_file_prop(prop: &Value) -> bool { + prop["contentEncoding"] == "base64" || prop["items"]["contentEncoding"] == "base64" +} + +/// Apply `visit` to every value whose declaration matches `is_leaf`, at any depth; +/// `None` removes it. Descends on the value's shape, through every `oneOf` branch. +fn map_leaves( + value: &Value, + prop: Option<&Value>, + is_leaf: fn(&Value) -> bool, + visit: &mut dyn FnMut(&Value, &Value, &[Value]) -> Option<Value>, + path: &mut Vec<Value>, +) -> Value { + let items = prop.and_then(|p| p.get("items")).filter(|v| !v.is_null()); + match value { + Value::Array(list) => Value::Array( + list.iter() + .enumerate() + .map(|(i, item)| { + path.push(json!(i)); + let mapped = map_leaves(item, items.or(prop), is_leaf, visit, path); + path.pop(); + mapped + }) + .collect(), + ), + Value::Object(map) => { + let mut bags: Vec<&Map<String, Value>> = Vec::new(); + if let Some(properties) = prop.and_then(|p| p["properties"].as_object()) { + bags.push(properties); + } + for branch in prop + .and_then(|p| p["oneOf"].as_array()) + .into_iter() + .flatten() + { + if let Some(properties) = branch["properties"].as_object() { + bags.push(properties); + } + } + if bags.is_empty() { + return match items { + Some(items) => map_leaves(value, Some(items), is_leaf, visit, path), + None => value.clone(), + }; + } + let mut result = Map::new(); + for (key, current) in map { + let declared: Vec<&Value> = bags.iter().filter_map(|bag| bag.get(key)).collect(); + path.push(json!(key)); + if let Some(leaf) = declared.iter().find(|d| is_leaf(d)) { + if let Some(mapped) = visit(current, leaf, path) { + result.insert(key.clone(), mapped); + } + } else { + let mut mapped = current.clone(); + for declaration in declared { + mapped = map_leaves(&mapped, Some(declaration), is_leaf, visit, path); + } + result.insert(key.clone(), mapped); + } + path.pop(); + } + Value::Object(result) + } + other => other.clone(), + } +} + +fn map_arg_leaves( + args: &Map<String, Value>, + schema: &Value, + is_leaf: fn(&Value) -> bool, + visit: &mut dyn FnMut(&Value, &Value, &[Value]) -> Option<Value>, +) -> Map<String, Value> { + let root = json!({ "properties": schema.get("properties").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})) }); + match map_leaves( + &Value::Object(args.clone()), + Some(&root), + is_leaf, + visit, + &mut Vec::new(), + ) { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// `$var:` hands the job the variable's text and `$jsonvar:` the parsed value, which a +/// field that cannot hold a string needs. +fn reference_prefix(prop: &Value, value: &Value) -> &'static str { + if value.is_string() && prop["type"] != "object" && prop["type"] != "array" { + "$var:" + } else { + "$jsonvar:" + } +} + +/// Turn every sensitive argument into a reference: a literal is minted into a secret +/// variable that expires on its own, so the job's stored arguments name a secret +/// instead of holding one. +async fn process_secret_args( + ctx: &Ctx<'_>, + args: Map<String, Value>, + schema: &Value, +) -> Result<Map<String, Value>, String> { + if !schema.get("properties").is_some_and(Value::is_object) { + return Ok(args); + } + let username = ctx.auth.username.split('@').next().unwrap_or_default(); + if username.is_empty() { + return Ok(args); + } + + let mut pending: Vec<(String, &'static str, Value)> = Vec::new(); + map_arg_leaves(&args, schema, is_secret_prop, &mut |value, prop, path| { + let holds_secret = !value.is_null() && value != "" && !is_reference(value); + if holds_secret { + pending.push(( + Value::from(path).to_string(), + reference_prefix(prop, value), + value.clone(), + )); + } + Some(value.clone()) + }); + + let mut minted: HashMap<String, String> = HashMap::new(); + for (key, prefix, value) in pending { + let variable = format!( + "u/{username}/secret_arg/{}", + windmill_common::utils::rd_string(12) + ); + let secret = match &value { + Value::String(text) if prefix == "$var:" => text.clone(), + other => other.to_string(), + }; + let expires_at = (chrono::Utc::now() + chrono::Duration::days(7)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + ctx.post( + &ctx.ws("variables/create"), + json!({ + "value": secret, + "is_secret": true, + "path": variable, + "description": "Ephemeral secret variable", + "expires_at": expires_at, + }), + ) + .await?; + minted.insert(key, format!("{prefix}{variable}")); + } + + Ok(map_arg_leaves( + &args, + schema, + is_secret_prop, + &mut |value, prop, path| { + if let Some(reference) = minted.get(&Value::from(path).to_string()) { + return Some(reference.as_str().into()); + } + // A plain variable named for a field that cannot hold a string: its contents + // are meant, read the way the field needs them. + match value.as_str().and_then(|s| s.strip_prefix("$var:")) { + Some(variable) if reference_prefix(prop, value) == "$jsonvar:" => { + Some(format!("$jsonvar:{variable}").into()) + } + _ => Some(value.clone()), + } + }, + )) +} + +fn file_marker(base64: &str) -> String { + let bytes = js_len(base64) * 3 / 4; + if bytes < 1024 * 1024 { + format!( + "<file: {} KB>", + ((bytes as f64 / 1024.0).round() as usize).max(1) + ) + } else { + format!("<file: {:.1} MB>", bytes as f64 / 1024.0 / 1024.0) + } +} + +/// The arguments as the model may read them back: secrets hidden, file bytes replaced +/// by their size. +fn redact_args(args: &Map<String, Value>, schema: &Value) -> Map<String, Value> { + let secrets = map_arg_leaves( + args, + schema, + is_secret_prop, + &mut |value, _, _| match value { + Value::Null => None, + value if value == "" || is_reference(value) => Some(value.clone()), + _ => Some("<hidden>".into()), + }, + ); + let mark = |value: &Value| match value { + Value::String(base64) => file_marker(base64).into(), + other => other.clone(), + }; + map_arg_leaves( + &secrets, + schema, + is_file_prop, + &mut |value, _, _| match value { + Value::Array(files) => Some(files.iter().map(mark).collect()), + other => Some(mark(other)), + }, + ) +} + +/// Keep the arguments the schema declares, naming the rest. A schema without +/// `properties` declares nothing. +fn drop_undeclared_args( + args: Map<String, Value>, + schema: &Value, +) -> (Map<String, Value>, Vec<String>) { + let properties = schema.get("properties").and_then(Value::as_object); + let mut undeclared = Vec::new(); + let kept = args + .into_iter() + .filter(|(key, _)| { + let declared = properties.is_some_and(|p| p.contains_key(key)); + if !declared { + undeclared.push(key.clone()); + } + declared + }) + .collect(); + (kept, undeclared) +} + +/// A field the schema disables is not the caller's to set: the run sends its default. +/// Returns the keys whose proposed value was overwritten. +fn enforce_disabled_defaults(args: &mut Map<String, Value>, schema: &Value) -> Vec<String> { + let mut reset = Vec::new(); + for (key, prop) in schema + .get("properties") + .and_then(Value::as_object) + .into_iter() + .flatten() + { + let (true, Some(default)) = (truthy(prop.get("disabled")), prop.get("default")) else { + continue; + }; + if args.get(key).is_some_and(|proposed| proposed != default) { + reset.push(key.clone()); + } + args.insert(key.clone(), default.clone()); + } + reset +} + +struct Prepared { + args: Map<String, Value>, + /// Lines telling the model how what ran differs from what it proposed. + notes: Vec<String>, +} + +fn plural<'a>(keys: &[String], one: &'a str, many: &'a str) -> &'a str { + if keys.len() > 1 { + many + } else { + one + } +} + +/// The arguments a run carries, from the ones proposed. `schema` is the input schema +/// of the version about to run; `None` when it is unknown, which conforms nothing. +/// `Err` is the message the tool returns when a secret could not be stored. +async fn prepare_args( + ctx: &Ctx<'_>, + path: &str, + proposed: Map<String, Value>, + schema: Option<&Value>, + schema_noun: &str, +) -> Result<Prepared, String> { + let Some(schema) = schema else { + return Ok(Prepared { args: proposed, notes: Vec::new() }); + }; + let (mut args, undeclared) = drop_undeclared_args(proposed.clone(), schema); + let reset = enforce_disabled_defaults(&mut args, schema); + let args = process_secret_args(ctx, args, schema).await.map_err(|e| { + format!( + "Failed to store the sensitive arguments of \"{path}\": {}", + api_message(&e) + ) + })?; + + let mut notes = Vec::new(); + let redacted = redact_args(&args, schema); + if redacted != proposed { + let json = Value::Object(redacted).to_string(); + notes.push(if js_len(&json) > MAX_SUBMITTED_ARGS_LENGTH { + format!( + "Ran with arguments: {}... (truncated)", + js_head(&json, MAX_SUBMITTED_ARGS_LENGTH) + ) + } else { + format!("Ran with arguments: {json}") + }); + } + if !reset.is_empty() { + notes.push(format!( + "The {schema_noun} schema disables {}, so the run used {} rather than the proposed {}. Do not propose {} again.", + reset.join(", "), + plural(&reset, "its default", "their defaults"), + plural(&reset, "value", "values"), + plural(&reset, "it", "them"), + )); + } + if !undeclared.is_empty() { + notes.push(format!( + "The {schema_noun} schema does not declare {}, so {} not sent. Re-read the input schema and use the arguments it declares.", + undeclared.join(", "), + plural(&undeclared, "it was", "they were"), + )); + } + Ok(Prepared { args, notes }) +} + +fn proposed_args(tool_args: &Value) -> Map<String, Value> { + tool_args + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +fn has_properties(schema: Option<&Value>) -> bool { + schema + .and_then(|s| s.get("properties")) + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()) +} + +/// The input schema a test run conforms its arguments to: the stored one where it +/// declares fields and speaks for the entrypoint about to run, else inferred from the +/// code. `None` when this build has no parser for the language. +fn schema_for_test_run( + language: &str, + content: &str, + stored: Option<Value>, + entrypoint: Option<&str>, +) -> Option<Value> { + if entrypoint.is_none() && has_properties(stored.as_ref()) { + return stored; + } + infer_script_schema(language, content, None, entrypoint) +} + +async fn conclude( + ctx: &Ctx<'_>, + tool_args: &Value, + mut notes: Vec<String>, + started: Result<String, String>, + label: &str, + is_flow: bool, + action: &str, +) -> ToolResult { + notes.push(finish_run(ctx, started, label, is_flow, action, tool_args).await?); + Ok(notes.join("\n")) +} + +// ============================================================================ +// Scripts and flows +// ============================================================================ + +async fn test_run_script(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let script = load_script(ctx, path).await?; + let schema = schema_for_test_run(&script.language, &script.content, script.schema, None); + let prepared = + match prepare_args(ctx, path, proposed_args(args), schema.as_ref(), "script").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let started = run_script_preview( + ctx, + Some(path), + &script.language, + &script.content, + Value::Object(prepared.args), + ) + .await; + conclude(ctx, args, prepared.notes, started, path, false, "test").await +} + +/// `run_script` and `run_flow`: the deployed item, whatever draft sits on top of it. +async fn run_deployed(ctx: &Ctx<'_>, args: &Value, is_flow: bool) -> ToolResult { + let path = str_arg(args, "path")?; + let (get, run) = if is_flow { + ("flows/get", "jobs/run/f") + } else { + ("scripts/get/p", "jobs/run/p") + }; + let item = ctx + .get(&ctx.ws(&format!("{get}/{}", path_enc(path)))) + .await?; + let schema = item + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + let prepared = + match prepare_args(ctx, path, proposed_args(args), Some(&schema), "deployed").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + // A chat-enabled flow is refused without a conversation, and this run is one of its own. + let memory_id = if is_flow && truthy(item["value"].get("chat_input_enabled")) { + format!("&memory_id={}", uuid::Uuid::new_v4()) + } else { + String::new() + }; + // The arguments fill the main input schema; a preprocessor would take them for a + // trigger event. + let started = ctx + .post( + &ctx.ws(&format!( + "{run}/{}?skip_preprocessor=true{memory_id}", + path_enc(path) + )), + Value::Object(prepared.args), + ) + .await + .map(job_id); + conclude(ctx, args, prepared.notes, started, path, is_flow, "run").await +} + +/// The flow value a preview runs, from a loaded `{value, schema, groups}`. +fn flow_preview_value(flow: &Value) -> Value { + let mut value = flow.get("value").cloned().unwrap_or_else(|| json!({})); + if let (Some(groups), Some(map)) = (flow.get("groups"), value.as_object_mut()) { + if groups.is_null() { + map.remove("groups"); + } else { + map.insert("groups".into(), groups.clone()); + } + } + value +} + +async fn test_run_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let memory_id = opt_str_arg(args, "memory_id"); + if memory_id.is_some_and(|id| id.len() != 36 || uuid::Uuid::parse_str(id).is_err()) { + return Err("memory_id must be a UUID".to_string()); + } + let flow = load_flow(ctx, path).await?; + let schema = flow + .flow + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "flow").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let value = flow_preview_value(&flow.flow); + // The server refuses a chat-enabled flow's run without a conversation; a test run + // is one of its own unless the caller names another. + let memory_id = memory_id.map(String::from).or_else(|| { + truthy(value.get("chat_input_enabled")).then(|| uuid::Uuid::new_v4().to_string()) + }); + let started = run_flow_preview( + ctx, + Some(path), + &value, + Value::Object(prepared.args), + memory_id.as_deref(), + ) + .await; + conclude(ctx, args, prepared.notes, started, path, true, "test").await +} + +// ============================================================================ +// One step of a flow +// ============================================================================ + +fn modules_of<'a>(value: &'a Value, key: &str) -> impl Iterator<Item = &'a Value> { + value[key].as_array().into_iter().flatten() +} + +/// The modules nested directly under one: loop bodies, branches, and an agent's +/// flow-module tools. +fn child_modules(module: &Value, with_tools: bool) -> Vec<&Value> { + let value = &module["value"]; + let branches = || modules_of(value, "branches").flat_map(|b| modules_of(b, "modules")); + match value["type"].as_str() { + Some("forloopflow" | "whileloopflow") => modules_of(value, "modules").collect(), + Some("branchall") => branches().collect(), + Some("branchone") => branches().chain(modules_of(value, "default")).collect(), + Some("aiagent") if with_tools => modules_of(value, "tools") + .filter(|tool| { + matches!(tool["value"].get("tool_type"), None | Some(Value::Null)) + || tool["value"]["tool_type"] == "flowmodule" + }) + .collect(), + _ => Vec::new(), + } +} + +fn find_in_modules<'a>(modules: Vec<&'a Value>, id: &str) -> Option<&'a Value> { + modules.into_iter().find_map(|module| { + if module["id"] == id { + Some(module) + } else { + find_in_modules(child_modules(module, true), id) + } + }) +} + +fn find_module<'a>(flow: &'a Value, id: &str) -> Option<&'a Value> { + ["failure_module", "preprocessor_module"] + .iter() + .map(|key| &flow[*key]) + .find(|module| module["id"] == id) + .or_else(|| find_in_modules(modules_of(flow, "modules").collect(), id)) +} + +fn available_step_ids(flow: &Value) -> String { + fn collect<'a>(modules: Vec<&'a Value>, ids: &mut Vec<&'a str>) { + for module in modules { + ids.extend(module["id"].as_str()); + collect(child_modules(module, false), ids); + } + } + let mut ids = Vec::new(); + collect(modules_of(flow, "modules").collect(), &mut ids); + ids.extend(flow["preprocessor_module"]["id"].as_str()); + ids.extend(flow["failure_module"]["id"].as_str()); + let mut seen = Vec::new(); + for id in ids { + if !seen.contains(&id) { + seen.push(id); + } + } + seen.join(", ") +} + +/// The agent form's rows in its own order: key, whether an agent always has it, and +/// the value a run cannot tell apart from an absent key. +fn agent_fields() -> [(&'static str, bool, Option<Value>); 16] { + [ + ("provider", true, None), + ("temperature", false, None), + ("max_completion_tokens", false, None), + ("user_message", true, None), + ("system_prompt", true, None), + ("memory", false, Some(json!({ "kind": "off" }))), + ("memory_id", false, Some(json!(""))), + ("previous_messages", false, Some(json!([]))), + ("user_attachments", false, Some(json!([]))), + ("enabled_tools", false, None), + ("max_iterations", false, Some(json!(10))), + ("output_type", false, Some(json!("text"))), + ("output_schema", false, None), + ("streaming", false, Some(json!(true))), + ("model", false, Some(json!(""))), + ("reasoning_effort", false, Some(json!(""))), + ] +} + +/// The inputs a step linked to a saved agent supplies itself; the rest is the agent's. +const AGENT_FLOW_LOCAL_KEYS: [&str; 5] = [ + "user_message", + "user_attachments", + "enabled_tools", + "memory_id", + "previous_messages", +]; +const AGENT_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"]; +/// A linked step's overrides of what its agent saved; an unlinked step has no such inputs. +const AGENT_OVERRIDE_KEYS: [&str; 2] = ["model", "reasoning_effort"]; + +/// Older spellings of two agent inputs, read as the current ones. +fn migrate_agent_transforms(transforms: &mut Map<String, Value>) { + if !transforms.contains_key("user_attachments") { + if let Some(images) = transforms.remove("user_images") { + transforms.insert("user_attachments".into(), images); + } + } + if transforms.contains_key("memory") { + return; + } + let Some(legacy) = transforms.get("messages_context_length").cloned() else { + return; + }; + if legacy.is_null() { + return; + } + match legacy["type"].as_str() { + Some("static") => { + let length = legacy.get("value").filter(|v| !v.is_null()).cloned(); + transforms.insert( + "memory".into(), + json!({ "type": "static", "value": { "kind": "auto", "context_length": length.unwrap_or(json!(0)) } }), + ); + } + Some("javascript") => { + let expr = legacy["expr"].as_str().unwrap_or("undefined"); + transforms.insert( + "memory".into(), + json!({ "type": "javascript", "expr": format!("{{ kind: 'auto', context_length: {expr} }}") }), + ); + } + _ => {} + } + transforms.remove("messages_context_length"); +} + +/// The inputs a test run of an agent step takes, in the agent form's order, and the +/// values the step itself configures for them. A row is one the step sets, an agent +/// always has, or the caller proposed. +fn agent_step_form( + module_value: &Value, + proposed: &Map<String, Value>, +) -> (Vec<String>, Map<String, Value>) { + let transforms = module_value.get("input_transforms"); + let linked = truthy(module_value.get("agent")); + let mut keys = Vec::new(); + let mut args = Map::new(); + for (key, core, implicit) in agent_fields() { + let override_key = AGENT_OVERRIDE_KEYS.contains(&key); + let applies = if linked { + override_key || AGENT_FLOW_LOCAL_KEYS.contains(&key) + } else { + !override_key + }; + if !applies { + continue; + } + let transform = transforms.and_then(|t| t.get(key)); + let is_set = core + || match transform.and_then(|t| t["type"].as_str()) { + Some("javascript") => truthy(transform.and_then(|t| t.get("expr"))), + Some("ai") => true, + _ => transform + .filter(|t| t.is_object()) + .and_then(|t| t.get("value")) + .is_some_and(|value| !value.is_null() && Some(value) != implicit.as_ref()), + }; + if !is_set && !proposed.contains_key(key) { + continue; + } + keys.push(key.to_string()); + // ponytail: only a static input opens on its value. An expression is evaluated by + // the session with no flow input or earlier results, so all but a literal one + // reads as unset there too; evaluate it here if literal expressions matter. + if let Some(value) = transform + .filter(|t| t["type"] == "static") + .and_then(|t| t.get("value")) + { + args.insert(key.to_string(), value.clone()); + } + } + for (key, value) in proposed { + args.insert(key.clone(), value.clone()); + } + // An absent `enabled_tools` runs every tool and `[]` none, and the row is fed from + // the run's arguments either way. + if keys.iter().any(|key| key == "enabled_tools") + && args.get("enabled_tools").is_none_or(Value::is_null) + { + args.insert("enabled_tools".into(), json!([])); + } + (keys, args) +} + +/// The input transforms a one-off test of an agent step runs with: each form key fed +/// from `flow_input`, over the step's own. A blank history input is unset. +fn agent_test_transforms( + step: Option<&Value>, + args: &Map<String, Value>, + form_keys: &[String], +) -> Map<String, Value> { + let kept = |key: &str| { + let blank = match args.get(key) { + None | Some(Value::Null) => true, + Some(Value::String(s)) => s.is_empty(), + Some(Value::Array(a)) => a.is_empty(), + Some(_) => false, + }; + !AGENT_HISTORY_KEYS.contains(&key) || !blank + }; + let mut out: Map<String, Value> = step + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter(|(key, _)| kept(key)) + .map(|(key, transform)| (key.clone(), transform.clone())) + .collect(); + for key in form_keys.iter().filter(|key| kept(key)) { + out.insert( + key.clone(), + json!({ "type": "javascript", "expr": format!("flow_input.{key}") }), + ); + } + out +} + +fn normalize_agent_ref(agent: &str) -> &str { + let agent = agent.strip_prefix("$res:").unwrap_or(agent); + agent.strip_prefix("res://").unwrap_or(agent) +} + +fn linked_agent_paths(module: &Value, paths: &mut Vec<String>) { + let value = &module["value"]; + if value["type"] != "aiagent" { + return; + } + if let Some(agent) = value["agent"].as_str().filter(|a| !a.is_empty()) { + let path = normalize_agent_ref(agent).to_string(); + if !paths.contains(&path) { + paths.push(path); + } + } + for tool in modules_of(value, "tools") { + linked_agent_paths(tool, paths); + } +} + +fn inline_agent_draft(module: &mut Value, drafts: &HashMap<String, Value>) { + let value = &mut module["value"]; + if value["type"] != "aiagent" { + return; + } + let draft = value["agent"] + .as_str() + .and_then(|agent| drafts.get(normalize_agent_ref(agent))); + if let (Some(draft), Some(map)) = (draft, value.as_object_mut()) { + map.remove("agent"); + let mut transforms = Map::new(); + for (key, arg) in draft.as_object().into_iter().flatten() { + if key != "tools" && !AGENT_HISTORY_KEYS.contains(&key.as_str()) { + transforms.insert(key.clone(), json!({ "type": "static", "value": arg })); + } + } + for key in AGENT_FLOW_LOCAL_KEYS.into_iter().chain(AGENT_OVERRIDE_KEYS) { + if let Some(own) = map + .get("input_transforms") + .and_then(|t| t.get(key)) + .filter(|t| truthy(Some(t))) + { + transforms.insert(key.to_string(), own.clone()); + } + } + map.insert( + "tools".into(), + draft + .get("tools") + .filter(|t| !t.is_null()) + .cloned() + .unwrap_or_else(|| json!([])), + ); + map.insert("input_transforms".into(), Value::Object(transforms)); + } + if let Some(tools) = value.get_mut("tools").and_then(Value::as_array_mut) { + for tool in tools { + inline_agent_draft(tool, drafts); + } + } +} + +/// Replace every linked agent that has an unsaved draft with the draft's own +/// configuration, so the test runs what the agent editor shows. +async fn with_agent_drafts(ctx: &Ctx<'_>, module: &mut Value) -> Result<(), String> { + let mut paths = Vec::new(); + linked_agent_paths(module, &mut paths); + let mut drafts = HashMap::new(); + for path in paths { + let route = ctx.ws(&format!("resources/get/{}?get_draft=true", path_enc(&path))); + match ctx.get(&route).await { + Ok(resource) => { + if let Some(draft) = resource.get("draft").filter(|d| !d.is_null()) { + drafts.insert( + path, + draft.get("args").cloned().unwrap_or_else(|| json!({})), + ); + } + } + // A deleted or unreadable agent is an ordinary state of a link: the step + // then runs against whatever is deployed. + Err(e) if ["401", "403", "404"].iter().any(|s| e.starts_with(s)) => {} + Err(e) => return Err(format!("Could not load the agent {path}: {e}")), + } + } + inline_agent_draft(module, &drafts); + Ok(()) +} + +async fn test_run_step(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let step_id = str_arg(args, "stepId")?; + let flow_value = flow_preview_value(&load_flow(ctx, path).await?.flow); + let Some(module) = find_module(&flow_value, step_id) else { + return Err(format!( + "Step with id \"{step_id}\" not found in flow. Available steps: {}", + available_step_ids(&flow_value) + )); + }; + let value = &module["value"]; + let module_type = value["type"].as_str().unwrap_or_default(); + let text = |key: &str| value[key].as_str().unwrap_or_default().to_string(); + // A preprocessor takes the arguments of its own entrypoint, which no schema declares. + let entrypoint = (module["id"] == "preprocessor").then_some("preprocessor"); + let label = format!("step {step_id}"); + let proposed = proposed_args(args); + + let (schema, proposed) = match module_type { + "rawscript" => ( + schema_for_test_run(&text("language"), &text("content"), None, entrypoint), + proposed, + ), + "script" | "flow" | "aidecision" => (None, proposed), + "aiagent" => { + let (keys, args) = agent_step_form(&migrated_agent_value(value), &proposed); + let properties: Map<String, Value> = + keys.into_iter().map(|key| (key, json!({}))).collect(); + (Some(json!({ "properties": properties })), args) + } + other => { + return Err(format!( + "Cannot test step of type \"{other}\". Supported types: rawscript, script, flow, aiagent, aidecision" + )); + } + }; + + // The targets read from the workspace, each with the schema of what will run. + let mut script = None; + let mut subflow_preview = None; + let schema = match module_type { + "script" => { + let target = text("path"); + let hash = value["hash"].as_str().filter(|h| !h.is_empty()); + // A draft of the script runs even where the step pins a hash. + let loaded = match hash { + Some(hash) if get_draft(ctx, "script", &target).await?.is_none() => { + let deployed = ctx + .get(&ctx.ws(&format!("scripts/get/h/{}", enc(hash)))) + .await?; + ( + deployed["content"].as_str().unwrap_or_default().to_string(), + deployed["language"] + .as_str() + .unwrap_or_default() + .to_string(), + deployed.get("schema").filter(|s| !s.is_null()).cloned(), + ) + } + _ => { + let loaded = load_script(ctx, &target).await?; + (loaded.content, loaded.language, loaded.schema) + } + }; + let schema = schema_for_test_run(&loaded.1, &loaded.0, loaded.2, entrypoint); + script = Some((target, loaded.0, loaded.1)); + schema + } + "flow" => { + // Only a draft is previewed; a deployed subflow runs by path, as its parent + // flow would run it. + let subflow = load_flow(ctx, &text("path")).await?; + if subflow.is_draft { + subflow_preview = Some(flow_preview_value(&subflow.flow)); + } + Some( + subflow + .flow + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})), + ) + } + // What a run supplies is the state; the provider and questions are the step's own. + "aidecision" => Some(json!({ "properties": { "state": {} } })), + _ => schema, + }; + + let prepared = match prepare_args(ctx, path, proposed, schema.as_ref(), "step").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let mut run_args = prepared.args; + let with_entrypoint = |args: Map<String, Value>| { + let mut out = Map::new(); + if entrypoint.is_some() { + out.insert("_ENTRYPOINT_OVERRIDE".into(), "preprocessor".into()); + } + out.extend(args); + Value::Object(out) + }; + // Run alone, stripped of the settings around it: a mock would answer for the step + // and a suspend would hold the run for an approval. + let single_step = + |step_value: Value| json!({ "modules": [{ "id": module["id"], "value": step_value }] }); + + let (started, is_flow) = match module_type { + "rawscript" => ( + run_script_preview( + ctx, + None, + &text("language"), + &text("content"), + with_entrypoint(run_args), + ) + .await, + false, + ), + "script" => { + let (target, content, language) = script.unwrap_or_default(); + ( + run_script_preview( + ctx, + Some(&target), + &language, + &content, + with_entrypoint(run_args), + ) + .await, + false, + ) + } + "flow" => { + let target = text("path"); + let started = match subflow_preview { + Some(preview) => { + run_flow_preview(ctx, Some(&target), &preview, Value::Object(run_args), None) + .await + } + None => ctx + .post( + &ctx.ws(&format!( + "jobs/run/f/{}?skip_preprocessor=true", + path_enc(&target) + )), + Value::Object(run_args), + ) + .await + .map(job_id), + }; + (started, true) + } + "aiagent" => { + let mut step_value = migrated_agent_value(value); + let form_keys: Vec<String> = schema + .as_ref() + .and_then(|s| s["properties"].as_object()) + .map(|p| p.keys().cloned().collect()) + .unwrap_or_default(); + step_value["input_transforms"] = Value::Object(agent_test_transforms( + step_value.get("input_transforms"), + &run_args, + &form_keys, + )); + let mut step = json!({ "id": module["id"], "value": step_value }); + with_agent_drafts(ctx, &mut step).await?; + let preview = json!({ "modules": [step] }); + ( + run_flow_preview(ctx, Some(path), &preview, Value::Object(run_args), None).await, + true, + ) + } + _ => { + let mut step_value = value.clone(); + let mut transforms = value["input_transforms"] + .as_object() + .cloned() + .unwrap_or_default(); + for key in run_args.keys() { + transforms.insert( + key.clone(), + json!({ "type": "javascript", "expr": format!("flow_input.{key}") }), + ); + } + step_value["input_transforms"] = Value::Object(transforms); + let preview = single_step(step_value); + ( + run_flow_preview( + ctx, + Some(path), + &preview, + Value::Object(std::mem::take(&mut run_args)), + None, + ) + .await, + true, + ) + } + }; + conclude(ctx, args, prepared.notes, started, &label, is_flow, "test").await +} + +fn migrated_agent_value(value: &Value) -> Value { + let mut value = value.clone(); + if let Some(transforms) = value + .get_mut("input_transforms") + .and_then(Value::as_object_mut) + { + migrate_agent_transforms(transforms); + } + value +} + +// ============================================================================ +// Reading runs back +// ============================================================================ + +/// Compact metadata for one run: what identifies it and how it ended. +fn summarize_run(job: &Value) -> Map<String, Value> { + let mut out = Map::new(); + for (key, source) in [ + ("id", "id"), + ("job_kind", "job_kind"), + ("path", "script_path"), + ("created_by", "created_by"), + ("created_at", "created_at"), + ("started_at", "started_at"), + ("schedule_path", "schedule_path"), + ("is_flow_step", "is_flow_step"), + ("tag", "tag"), + ("worker", "worker"), + ] { + if let Some(value) = job.get(source) { + out.insert(key.into(), value.clone()); + } + } + let flag = |key: &str| truthy(job.get(key)); + let status = if job.get("success").is_some() { + // `success` is true for a skipped job too, so it is read last. + if flag("canceled") { + "canceled" + } else if flag("is_skipped") { + "skipped" + } else if flag("success") { + "success" + } else { + "failure" + } + } else if !flag("running") { + "queued" + } else if flag("suspend") { + // Parked on an approval or a parallelism slot, not working. + "suspended" + } else { + "running" + }; + out.insert("status".into(), status.into()); + if job.get("success").is_some() { + if let Some(duration) = job.get("duration_ms") { + out.insert("duration_ms".into(), duration.clone()); + } + } + out +} + +async fn list_runs(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let limit = args + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(30) + .clamp(1, 100); + let mut query = format!("per_page={limit}"); + for (param, arg) in [ + ("script_path_exact", "path"), + ("created_by", "created_by"), + ("label", "label"), + ] { + if let Some(value) = opt_str_arg(args, arg) { + query.push_str(&format!("&{param}={}", enc(value))); + } + } + for flag in ["success", "running"] { + if let Some(value) = args.get(flag).and_then(Value::as_bool) { + query.push_str(&format!("&{flag}={value}")); + } + } + let jobs = ctx.get(&ctx.ws(&format!("jobs/list?{query}"))).await?; + let runs: Vec<Value> = jobs + .as_array() + .into_iter() + .flatten() + .map(|job| Value::Object(summarize_run(job))) + .collect(); + Ok(pretty(&Value::Array(runs))) +} + +async fn cancel_job(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let id = str_arg(args, "id")?; + Ok( + match ctx + .post( + &ctx.ws(&format!("jobs_u/queue/cancel/{}", enc(id))), + json!({}), + ) + .await + { + Ok(_) => format!("Job {id} was canceled."), + Err(e) => format!( + "Failed to cancel job {id}: {}. It may have already finished.", + api_message(&e) + ), + }, + ) +} + +/// Per-entry result budget asked of the server for the tree view. +const TREE_RESULT_HEAD_CHARS: usize = 700; +/// Cap on a full payload handed to the model: a drilled step result, and the run's own +/// args, result and logs. +const STEP_RESULT_MAX_CHARS: usize = 12000; +/// Cap on the whole rendered tree; heads shrink progressively to fit. +const TREE_TOTAL_BUDGET_CHARS: usize = 20000; +const MAX_FAILED_ITERATIONS_SHOWN: usize = 3; +/// `(head, success_head)` tried in order until the tree fits: succeeded steps lose +/// their results first so failure detail survives longest. +const SHRINK_LADDER: [(usize, usize); 4] = [ + (TREE_RESULT_HEAD_CHARS, TREE_RESULT_HEAD_CHARS), + (250, 250), + (250, 0), + (100, 0), +]; +/// Module types whose sibling jobs are iterations or branches; sibling jobs of any +/// other step are retry attempts of it. +const FAN_OUT_MODULE_TYPES: [&str; 5] = [ + "forloopflow", + "whileloopflow", + "branchall", + "branchone", + "aiagent", +]; +const LOGS_UNREADABLE: &str = "Logs could not be read for this run."; + +/// A run's jobs as the server lists them, depth-first, with each one's children. +struct RunTree<'a> { + entries: &'a [Value], + children: Vec<Vec<usize>>, +} + +impl<'a> RunTree<'a> { + fn new(entries: &'a [Value]) -> Self { + let depth = |i: usize| entries[i]["depth"].as_i64().unwrap_or(0); + let mut children = vec![Vec::new(); entries.len()]; + let mut stack: Vec<usize> = Vec::new(); + for i in 0..entries.len() { + while stack.last().is_some_and(|&top| depth(top) >= depth(i)) { + stack.pop(); + } + if let Some(&parent) = stack.last() { + children[parent].push(i); + } + stack.push(i); + } + RunTree { entries, children } + } + + fn status(&self, i: usize) -> &str { + self.entries[i]["status"].as_str().unwrap_or_default() + } + + fn shape_step( + &self, + i: usize, + heads: (usize, usize), + iteration: Option<&Value>, + ) -> Map<String, Value> { + let entry = &self.entries[i]; + let mut out = Map::new(); + if let Some(step) = entry["step_path"].as_str().filter(|s| !s.is_empty()) { + out.insert("step".into(), step.into()); + } + if let Some(iteration) = iteration { + out.insert("iteration".into(), iteration.clone()); + } + for key in ["label", "job_id", "status"] { + if let Some(value) = entry.get(key) { + out.insert(key.into(), value.clone()); + } + } + if let Some(duration) = entry.get("duration_ms").filter(|d| !d.is_null()) { + out.insert("duration_ms".into(), duration.clone()); + } + if let Some(prefix) = entry["result_prefix"].as_str() { + let budget = if truthy(entry.get("success")) { + heads.1 + } else { + heads.0 + }; + let length = entry["result_length"].as_u64(); + if budget == 0 { + if let Some(length) = length { + out.insert("result_total_chars".into(), length.into()); + } + } else { + // The server counts code points, so sizes are compared in them. + let head = js_head(prefix, budget); + let total = length.unwrap_or(prefix.chars().count() as u64); + out.insert("result".into(), head.into()); + if total > head.chars().count() as u64 { + out.insert("result_total_chars".into(), total.into()); + } + } + } + let steps = self.shape_children(i, heads); + if !steps.is_empty() { + out.insert("steps".into(), steps.into()); + } + out + } + + /// Sibling jobs grouped by flow step, so the iterations, branches or attempts of + /// one step read as one entry. + fn shape_children(&self, i: usize, heads: (usize, usize)) -> Vec<Value> { + let mut groups: Vec<(String, Vec<usize>)> = Vec::new(); + for &child in &self.children[i] { + let entry = &self.entries[child]; + let step_id = match entry["flow_step_id"].as_str() { + Some(id) => id.to_string(), + None => format!("job:{}", entry["job_id"].as_str().unwrap_or_default()), + }; + match groups.iter_mut().find(|(id, _)| *id == step_id) { + Some((_, nodes)) => nodes.push(child), + None => groups.push((step_id, vec![child])), + } + } + groups + .iter() + .map(|(step_id, nodes)| Value::Object(self.shape_group(step_id, nodes, heads))) + .collect() + } + + fn shape_group( + &self, + step_id: &str, + nodes: &[usize], + heads: (usize, usize), + ) -> Map<String, Value> { + let first = &self.entries[nodes[0]]; + if nodes.len() == 1 && first["sibling_count"].as_i64().is_some_and(|n| n <= 1) { + return self.shape_step(nodes[0], heads, None); + } + let step = first["step_path"].as_str().unwrap_or(step_id); + let parent_type = first["parent_module_type"].as_str().unwrap_or_default(); + let mut out = Map::new(); + out.insert("step".into(), step.into()); + if parent_type == "branchall" { + out.insert("type".into(), "branchall".into()); + let branches: Vec<Value> = nodes + .iter() + .map(|&n| Value::Object(self.shape_step(n, heads, None))) + .collect(); + out.insert("branches".into(), branches.into()); + return out; + } + + let index = |n: usize| &self.entries[n]["sibling_index"]; + let mut by_index = nodes.to_vec(); + by_index.sort_by_key(|&n| index(n).as_i64().unwrap_or(0)); + let last = by_index[by_index.len() - 1]; + + if !parent_type.is_empty() && !FAN_OUT_MODULE_TYPES.contains(&parent_type) { + // A retried step: the last attempt is its outcome, earlier ones are + // status-only references. + let mut shaped = self.shape_step(last, heads, None); + shaped.insert("attempts".into(), by_index.len().into()); + let previous: Vec<Value> = by_index[..by_index.len() - 1] + .iter() + .map(|&n| { + json!({ + "attempt": index(n), + "status": self.status(n), + "job_id": self.entries[n]["job_id"], + }) + }) + .collect(); + shaped.insert("previous_attempts".into(), previous.into()); + return shaped; + } + + let count = |status: &str| { + by_index + .iter() + .filter(|&&n| self.status(n) == status) + .count() + }; + let ok = count("success"); + let failed: Vec<usize> = by_index + .iter() + .copied() + .filter(|&n| matches!(self.status(n), "failure" | "canceled")) + .collect(); + let skipped = count("skipped"); + // Skipped is terminal: only running, queued and suspended are unfinished. + let unfinished = by_index.len() - ok - failed.len() - skipped; + let mut shown: Vec<usize> = failed + .iter() + .copied() + .take(MAX_FAILED_ITERATIONS_SHOWN) + .collect(); + if !shown.contains(&last) { + shown.push(last); + } + + out.insert( + "type".into(), + if parent_type.is_empty() { + "loop" + } else { + parent_type + } + .into(), + ); + out.insert("iterations".into(), by_index.len().into()); + out.insert("ok".into(), ok.into()); + if !failed.is_empty() { + let indices: Vec<Value> = failed.iter().map(|&n| index(n).clone()).collect(); + out.insert("failed_iterations".into(), indices.into()); + } + if skipped > 0 { + out.insert("skipped".into(), skipped.into()); + } + if unfinished > 0 { + out.insert("unfinished".into(), unfinished.into()); + } + let iterations: Vec<Value> = shown + .iter() + .map(|&n| Value::Object(self.shape_step(n, heads, Some(index(n))))) + .collect(); + out.insert("iterations_shown".into(), iterations.into()); + if by_index.len() > shown.len() { + out.insert( + "iterations_elided".into(), + (by_index.len() - shown.len()).into(), + ); + } + out + } + + fn render( + &self, + note: Option<&str>, + heads: (usize, usize), + run_overrides: &Map<String, Value>, + ) -> Value { + let mut run = self.shape_step(0, heads, None); + let steps = run.remove("steps"); + let kind = self.entries[0]["kind"].as_str().unwrap_or_default(); + // The server labels every depth-0 job "Flow", a plain script run included. + if steps.is_none() && kind != "flow" && kind != "flowpreview" { + run.insert("label".into(), format!("Job ({kind})").into()); + } + // A replacement result supersedes the tree entry's head. Without one the head + // stays: it is a real prefix of a payload too big to be read whole. + if run_overrides.contains_key("result") { + run.remove("result"); + run.remove("result_total_chars"); + } + for (key, value) in run_overrides { + run.insert(key.clone(), value.clone()); + } + let mut out = Map::new(); + if let Some(note) = note { + out.insert("note".into(), note.into()); + } + out.insert("run".into(), Value::Object(run)); + if let Some(steps) = steps { + out.insert("steps".into(), steps); + out.insert( + "hint".into(), + format!("Step results are truncated. Call get_run again with step=\"<step>\" (e.g. \"b/c\", or \"b[12]\" for one loop iteration) for a step's result in full (up to {STEP_RESULT_MAX_CHARS} chars), or with id set to a step's job_id for that step's own args, result and logs.").into(), + ); + } + Value::Object(out) + } +} + +/// Render a run's execution tree around the run's own summary and payloads +/// (`run_overrides`), shrinking result heads until the tree fits its budget. +fn shape_flow_run_tree(response: &Value, run_overrides: &Map<String, Value>) -> String { + let entries = response["entries"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + if entries.is_empty() { + return pretty1(&json!({ "run": run_overrides })); + } + let mut notes = Vec::new(); + if let Some(enclosing) = response["enclosing_job"].as_str().filter(|j| !j.is_empty()) { + notes.push(format!("This job is a step of a larger flow run — its enclosing run is {enclosing}; pass that id to see more of the tree.")); + } + if truthy(response.get("truncated")) { + notes.push(format!("The run has more jobs than the server returns — this tree only covers the first {} (depth-first), so tallies may undercount.", entries.len())); + } + if truthy(response.get("scope_filtered")) { + notes.push( + "Your token is tag-scoped: steps running on other tags are omitted from this tree." + .to_string(), + ); + } + let note = (!notes.is_empty()).then(|| notes.join(" ")); + + // The budget caps the tree, not the run's own args, result and logs: those are + // capped on their own. + let override_chars = js_len(&pretty1(&Value::Object(run_overrides.clone()))); + let tree = RunTree::new(entries); + let mut rendered = String::new(); + for heads in SHRINK_LADDER { + rendered = pretty1(&tree.render(note.as_deref(), heads, run_overrides)); + if js_len(&rendered).saturating_sub(override_chars) <= TREE_TOTAL_BUDGET_CHARS { + return rendered; + } + } + format!( + "{}\n… (tree truncated at {TREE_TOTAL_BUDGET_CHARS} chars — drill into specific steps with the step parameter)", + js_head(&rendered, TREE_TOTAL_BUDGET_CHARS + override_chars) + ) +} + +/// Cap a payload to `STEP_RESULT_MAX_CHARS`. `tail` keeps the end, for logs, where the +/// failure is at the bottom. +fn cap(text: &str, tail: bool) -> String { + if js_len(text) <= STEP_RESULT_MAX_CHARS { + return text.to_string(); + } + let total = text.chars().count(); + if total <= STEP_RESULT_MAX_CHARS { + return text.to_string(); + } + let note = format!("… (truncated: {total} chars total)"); + if tail { + format!("{note}\n{}", js_tail(text, STEP_RESULT_MAX_CHARS)) + } else { + format!("{}\n{note}", js_head(text, STEP_RESULT_MAX_CHARS)) + } +} + +fn stringify(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => pretty1(other), + } +} + +async fn get_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let id = str_arg(args, "id")?; + let results_route = |max: usize| { + ctx.ws(&format!( + "jobs_u/get_flow_all_results/{}?max_result_len={max}", + enc(id) + )) + }; + if let Some(step) = opt_str_arg(args, "step").filter(|s| !s.is_empty()) { + let route = format!( + "{}&step={}", + results_route(STEP_RESULT_MAX_CHARS), + enc(step) + ); + return Ok(step_result(&ctx.get(&route).await?, step)); + } + + // Only the job read is load-bearing: logs and the step tree each answer part of + // the question, so neither failing costs the rest. + let job_route = ctx.ws(&format!("jobs_u/get/{}?no_logs=true&no_code=true", enc(id))); + let logs_route = ctx.ws(&format!( + "jobs_u/get_logs/{}?remove_ansi_warnings=true", + enc(id) + )); + let tree_route = results_route(TREE_RESULT_HEAD_CHARS); + let (job, logs, results) = tokio::join!( + ctx.get(&job_route), + ctx.request("GET", &logs_route, None), + ctx.get(&tree_route), + ); + let results = results.unwrap_or_else(|_| json!({ "entries": [] })); + Ok(format!( + "{}\n\nRun page: {}", + shape_run(id, &job?, logs, &results), + ctx.page_url(&format!("/run/{}", enc(id))) + )) +} + +/// A run as `get_run` reports it: its summary, args, result and logs around the step +/// tree. `logs` is the log read's own outcome, so a failed read is told apart from an +/// empty log. +fn shape_run(id: &str, job: &Value, logs: Result<String, String>, results: &Value) -> String { + let mut run = summarize_run(job); + run.remove("id"); + // Why a run ended badly. + for (key, source) in [ + ("canceled_by", "canceled_by"), + ("canceled_reason", "canceled_reason"), + ("mem_peak_kb", "mem_peak"), + ] { + if let Some(value) = job.get(source).filter(|v| truthy(Some(v))) { + run.insert(key.into(), value.clone()); + } + } + // A successful read always carries the job itself as the root entry, so no entries + // means the read failed, and nothing else would name the run. + if results["entries"].as_array().is_none_or(Vec::is_empty) { + run.insert("job_id".into(), id.into()); + run.insert("steps_unavailable".into(), true.into()); + } + // The server swaps a payload too big to send for a marker, each field in its own + // form. That is reported, never fetched around: the result keeps the head the step + // tree carries. + if let Some(job_args) = job.get("args").filter(|a| truthy(Some(a))) { + let too_big = job_args + .as_object() + .is_some_and(|o| o.len() == 1 && o.get("reason") == Some(&json!("WINDMILL_TOO_BIG"))); + if too_big { + run.insert("args_truncated".into(), true.into()); + } else { + run.insert("args".into(), cap(&stringify(job_args), false).into()); + } + } + if let Some(result) = job.get("result") { + if result == "WINDMILL_TOO_BIG" { + run.insert("result_truncated".into(), true.into()); + } else { + run.insert("result".into(), cap(&stringify(result), false).into()); + } + } + let logs = match logs { + Err(_) => LOGS_UNREADABLE.to_string(), + Ok(logs) if logs.trim().is_empty() => "No logs for this run.".to_string(), + Ok(logs) => cap(&logs, true), + }; + run.insert("logs".into(), logs.into()); + shape_flow_run_tree(results, &run) +} + +/// One step's result in full, from the single entry the server resolved its address to. +fn step_result(response: &Value, step: &str) -> String { + if let Some(error) = response["step_error"].as_str().filter(|e| !e.is_empty()) { + let hidden = if truthy(response.get("scope_filtered")) { + " (Steps running on tags outside your token’s scope are hidden.)" + } else { + "" + }; + return format!("{error}{hidden}"); + } + let Some(entry) = response["entries"].get(0) else { + return "No jobs found for this run.".to_string(); + }; + let job_id = entry["job_id"].as_str().unwrap_or_default(); + let status = entry["status"].as_str().unwrap_or_default(); + if matches!(status, "running" | "queued" | "suspended") { + return format!("Step \"{step}\" (job {job_id}) has not completed yet — status: {status}."); + } + let Some(prefix) = entry["result_prefix"].as_str() else { + return format!("Step \"{step}\" (job {job_id}, {status}) has no recorded result."); + }; + let received = prefix.chars().count() as u64; + let total = entry["result_length"].as_u64().unwrap_or(received); + let truncated = if total > received { + format!("\n… (result truncated: {total} chars total)") + } else { + String::new() + }; + format!("Step \"{step}\" (job {job_id}, {status}) result:\n{prefix}{truncated}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(overrides: Value) -> Value { + let mut entry = json!({ + "job_id": "j", + "label": "Flow", + "kind": "script", + "depth": 1, + "sibling_index": 1, + "sibling_count": 1, + "status": "success", + "success": true, + "result_prefix": "\"ok\"", + "result_length": 4, + }); + for (key, value) in overrides.as_object().unwrap() { + entry[key] = value.clone(); + } + entry + } + + fn root() -> Value { + entry(json!({ "kind": "flow", "depth": 0, "step_path": "" })) + } + + fn shape(entries: Vec<Value>) -> Value { + serde_json::from_str(&shape_flow_run_tree( + &json!({ "entries": entries }), + &Map::new(), + )) + .unwrap() + } + + fn loop_entries(n: i64, failed: &[i64]) -> Vec<Value> { + let mut entries = vec![root()]; + for i in 1..=n { + entries.push(entry(json!({ + "step_path": "l", "flow_step_id": "l", "kind": "flow", + "parent_module_type": "forloopflow", "sibling_index": i, "sibling_count": n, + "status": if failed.contains(&i) { "failure" } else { "success" }, + "success": !failed.contains(&i), + }))); + } + entries + } + + #[test] + fn subflow_steps_nest_under_their_parent_step() { + let rendered = shape(vec![ + root(), + entry(json!({ "step_path": "a", "flow_step_id": "a" })), + entry(json!({ "step_path": "b", "flow_step_id": "b", "kind": "flow" })), + entry(json!({ + "step_path": "b/c", "flow_step_id": "c", "depth": 2, "status": "failure", + "success": false, "result_prefix": "{\"error\":\"boom\"}", "result_length": 16, + })), + ]); + assert_eq!(rendered["run"]["status"], "success"); + assert!(rendered["run"].get("step").is_none()); + let step_b = &rendered["steps"][1]; + assert_eq!(step_b["step"], "b"); + assert_eq!(step_b["steps"][0]["step"], "b/c"); + assert_eq!(step_b["steps"][0]["result"], "{\"error\":\"boom\"}"); + assert!(rendered["hint"].is_string()); + } + + #[test] + fn loop_iterations_collapse_to_a_tally() { + let looped = &shape(loop_entries(50, &[3, 7, 21, 30]))["steps"][0]; + assert_eq!(looped["type"], "forloopflow"); + assert_eq!(looped["iterations"], 50); + assert_eq!(looped["ok"], 46); + assert_eq!(looped["failed_iterations"], json!([3, 7, 21, 30])); + let shown: Vec<&Value> = looped["iterations_shown"] + .as_array() + .unwrap() + .iter() + .map(|i| &i["iteration"]) + .collect(); + assert_eq!(shown, [&json!(3), &json!(7), &json!(21), &json!(50)]); + assert_eq!(looped["iterations_elided"], 46); + + let mut entries = loop_entries(4, &[]); + entries[2] = entry(json!({ + "step_path": "l", "flow_step_id": "l", "parent_module_type": "forloopflow", + "sibling_index": 2, "sibling_count": 4, "status": "running", + "success": null, "result_prefix": null, "result_length": null, + })); + entries[3]["status"] = json!("skipped"); + let looped = &shape(entries)["steps"][0]; + assert_eq!(looped["ok"], 2); + assert_eq!(looped["unfinished"], 1); + assert_eq!(looped["skipped"], 1); + assert!(looped.get("failed_iterations").is_none()); + } + + #[test] + fn retried_steps_are_attempts_of_one_step() { + let mut entries = vec![root()]; + for i in 1..=3 { + entries.push(entry(json!({ + "step_path": "a", "flow_step_id": "a", "label": format!("Step a (attempt {i}/3)"), + "parent_module_type": "rawscript", "sibling_index": i, "sibling_count": 3, + "status": if i < 3 { "failure" } else { "success" }, "success": i == 3, + }))); + } + let step = &shape(entries)["steps"][0]; + assert!(step.get("iterations").is_none()); + assert_eq!(step["attempts"], 3); + assert_eq!(step["label"], "Step a (attempt 3/3)"); + assert_eq!(step["previous_attempts"][1]["status"], "failure"); + } + + #[test] + fn result_heads_shrink_to_fit_and_run_payloads_do_not_count() { + let mut entries = vec![root()]; + for i in 0..60 { + entries.push(entry(json!({ + "step_path": format!("s{i}"), "flow_step_id": format!("s{i}"), + "result_prefix": "x".repeat(700), "result_length": 5000, + }))); + } + let mut overrides = Map::new(); + overrides.insert("logs".into(), "y".repeat(12000).into()); + let rendered = shape_flow_run_tree(&json!({ "entries": entries }), &overrides); + assert!(rendered.len() - 12000 <= TREE_TOTAL_BUDGET_CHARS + 100); + let parsed: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["run"]["logs"].as_str().unwrap().len(), 12000); + let step = &parsed["steps"][0]; + assert!(step["result"].as_str().is_none_or(|r| r.len() <= 250)); + assert_eq!(step["result_total_chars"], 5000); + } + + #[test] + fn truncation_counts_utf16_units_without_splitting_a_character() { + let text = format!("{}😀b", "a".repeat(3)); + assert_eq!(js_head(&text, 4), "aaa"); + assert_eq!(js_head(&text, 5), "aaa😀"); + assert_eq!(js_tail(&text, 2), "b"); + let long = "é".repeat(STEP_RESULT_MAX_CHARS + 5); + assert!(cap(&long, true).starts_with("… (truncated: 12005 chars total)\né")); + assert_eq!(cap("short", false), "short"); + } + + #[test] + fn args_conform_to_the_schema_and_read_back_redacted() { + let schema = json!({ "properties": { + "name": { "type": "string" }, + "locked": { "type": "number", "disabled": true, "default": 1 }, + "creds": { "type": "object", "properties": { "token": { "type": "string", "password": true } } }, + "file": { "type": "string", "contentEncoding": "base64" }, + }}); + let proposed = json!({ + "name": "x", "locked": 2, "extra": true, + "creds": { "token": "s3cret", "user": "u" }, "file": "A".repeat(4096), + }); + let (mut args, undeclared) = + drop_undeclared_args(proposed.as_object().unwrap().clone(), &schema); + assert_eq!(undeclared, ["extra"]); + assert_eq!(enforce_disabled_defaults(&mut args, &schema), ["locked"]); + assert_eq!(args["locked"], 1); + let redacted = redact_args(&args, &schema); + assert_eq!( + redacted["creds"], + json!({ "token": "<hidden>", "user": "u" }) + ); + assert_eq!(redacted["file"], "<file: 3 KB>"); + args["creds"]["token"] = json!("$var:u/admin/secret_arg/abc"); + assert_eq!( + redact_args(&args, &schema)["creds"]["token"], + "$var:u/admin/secret_arg/abc" + ); + } + + #[test] + fn agent_step_form_opens_on_what_the_step_sets() { + let value = json!({ "type": "aiagent", "input_transforms": { + "provider": { "type": "static", "value": { "kind": "openai" } }, + "user_message": { "type": "javascript", "expr": "flow_input.q" }, + "max_iterations": { "type": "static", "value": 10 }, + "temperature": { "type": "static", "value": 0.2 }, + "memory_id": { "type": "static", "value": "stale" }, + }}); + let proposed = json!({ "user_message": "hi", "enabled_tools": null }); + let (keys, args) = agent_step_form(&value, proposed.as_object().unwrap()); + assert_eq!( + keys, + [ + "provider", + "temperature", + "user_message", + "system_prompt", + "memory_id", + "enabled_tools" + ] + ); + assert_eq!(args["user_message"], "hi"); + assert_eq!(args["enabled_tools"], json!([])); + let mut run_args = args.clone(); + run_args.insert("memory_id".into(), json!("")); + let transforms = agent_test_transforms(value.get("input_transforms"), &run_args, &keys); + assert_eq!( + transforms["user_message"]["expr"], + "flow_input.user_message" + ); + assert_eq!(transforms["max_iterations"]["value"], 10); + assert!(!transforms.contains_key("memory_id")); + } +} diff --git a/backend/windmill-api/src/mcp/session/settings.rs b/backend/windmill-api/src/mcp/session/settings.rs new file mode 100644 index 0000000000..3548dada4b --- /dev/null +++ b/backend/windmill-api/src/mcp/session/settings.rs @@ -0,0 +1,140 @@ +//! The workspace settings document, in the layout of the `settings.yaml` the CLI syncs. + +use serde_json::{json, Map, Value}; + +use super::workspace::api_message; +use super::{array, js_head, js_len, js_str, str_arg, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &["get_workspace_settings", "set_workspace_settings"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "get_workspace_settings" => get_workspace_settings(ctx, args).await, + "set_workspace_settings" => set_workspace_settings(ctx, args).await, + _ => unreachable!("{name} is not a settings tool"), + } +} + +const MAX_READ_CHARS: usize = 20_000; + +async fn get_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let doc = ctx + .get(&ctx.ws("workspaces/settings_document?secrets=redacted")) + .await?; + let keys: Vec<&str> = array(args.get("keys")) + .iter() + .filter_map(Value::as_str) + .collect(); + let mut picked = doc.clone(); + let mut note = String::new(); + if !keys.is_empty() { + picked = Value::Object( + keys.iter() + .filter_map(|k| Some((k.to_string(), doc.get(k)?.clone()))) + .collect::<Map<_, _>>(), + ); + let unset: Vec<&str> = keys + .iter() + .copied() + .filter(|k| doc.get(k).is_none()) + .collect(); + if !unset.is_empty() { + note = format!( + "\n# Not in the document (unset, or not a setting): {}", + unset.join(", ") + ); + } + } + let mut text = serde_yml::to_string(&picked).map_err(|e| e.to_string())?; + text.push_str(¬e); + Ok(if js_len(&text) > MAX_READ_CHARS { + format!( + "{}\n… truncated at {MAX_READ_CHARS} chars; read the keys you need with `keys`.", + js_head(&text, MAX_READ_CHARS) + ) + } else { + text + }) +} + +fn parse_settings_yaml(text: &str) -> Result<Value, String> { + match serde_yml::from_str::<Value>(text) { + Ok(doc @ Value::Object(_)) => Ok(doc), + Ok(_) => Err("The yaml must be a mapping of setting keys to values.".to_string()), + Err(e) => Err(e.to_string()), + } +} + +/// The dry run stands in for the session's confirmation card: it reports a no-op or an +/// invalid document before anything is written. +async fn set_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let route = ctx.ws("workspaces/settings_document"); + let document = match parse_settings_yaml(str_arg(args, "yaml")?) { + Ok(document) => document, + Err(e) => return Ok(format!("Nothing was changed: {e}")), + }; + let dry_run = json!({ "document": document, "replace": false, "dry_run": true }); + let dry_run = match ctx.post(&route, dry_run).await { + Ok(result) => result, + Err(e) => return Ok(format!("Nothing was changed: {}", api_message(&e))), + }; + if array(dry_run.get("changes")).is_empty() { + let warnings: Vec<String> = array(dry_run.get("warnings")).iter().map(js_str).collect(); + let warnings = if warnings.is_empty() { + String::new() + } else { + format!(" Warnings: {}", warnings.join(" ")) + }; + return Ok(format!( + "The settings already hold these values: nothing to change.{warnings}" + )); + } + let applied = match ctx + .post(&route, json!({ "document": document, "replace": false })) + .await + { + Ok(result) => result, + Err(e) => return Ok(json!({ "success": false, "error": api_message(&e) }).to_string()), + }; + let keys: Vec<String> = array(applied.get("changes")) + .iter() + .map(|c| js_str(c.get("key").unwrap_or(&Value::Null))) + .collect(); + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "These settings are now applied: {}.", + keys.join(", ") + )), + ); + if let Some(warnings) = applied + .get("warnings") + .filter(|w| !array(Some(w)).is_empty()) + { + out.insert("warnings".into(), warnings.clone()); + } + Ok(Value::Object(out).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_yaml_must_be_a_mapping() { + assert_eq!( + parse_settings_yaml("webhook: null\ncolor: red").unwrap(), + json!({ "webhook": null, "color": "red" }) + ); + for text in ["- a\n- b", "just text", "42"] { + assert_eq!( + parse_settings_yaml(text).unwrap_err(), + "The yaml must be a mapping of setting keys to values.", + "{text}" + ); + } + assert!(parse_settings_yaml("a: [").is_err()); + } +} diff --git a/backend/windmill-api/src/mcp/session/workspace.rs b/backend/windmill-api/src/mcp/session/workspace.rs new file mode 100644 index 0000000000..3e3cf45459 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/workspace.rs @@ -0,0 +1,1975 @@ +//! Listing and reading workspace items, and the draft store the write tools share. + +use std::collections::HashMap; + +use serde_json::{json, Map, Value}; + +use super::{ + array, bool_arg, enc, is_not_found, js_len, js_str, non_null, opt_str_arg, pretty, put, + str_arg, truthy, Ctx, ToolResult, +}; + +pub(super) const TOOLS: &[&str] = &[ + "list_workspace_items", + "read_workspace_item", + "write_script", + "edit_script", + "discard_local_draft", + "rebase_draft", + "list_pipelines", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "list_workspace_items" => list_workspace_items(ctx, args).await, + "read_workspace_item" => read_workspace_item(ctx, args).await, + "write_script" => { + write_script_draft( + ctx, + str_arg(args, "path")?, + opt_str_arg(args, "summary"), + str_arg(args, "language")?, + str_arg(args, "content")?, + bool_arg(args, "override"), + ) + .await + } + "edit_script" => edit_script(ctx, args).await, + "discard_local_draft" => discard_local_draft(ctx, args).await, + "rebase_draft" => rebase_draft(ctx, args).await, + "list_pipelines" => { + let pipelines = ctx.get(&ctx.ws("assets/pipelines")).await?; + Ok(pipelines.to_string()) + } + _ => unreachable!("{name} is not a workspace tool"), + } +} + +// ============================================================================ +// Shared helpers: the per-user draft store and the item shapes built on it. +// +// Vocabulary. An item *type* is what a tool takes (`script`, `flow`, `schedule`, +// `trigger`, `resource`, `variable`, `app`); a draft *kind* is what the drafts API +// stores under (`script`, `flow`, `raw_app`, `trigger_schedule`, `trigger_<kind>`, +// `resource`, `variable`). An *item* is the JSON object the tools show the model +// (`{type, path, summary, value, isDraft, ...}`). A draft's *value* is stored exactly +// as the Windmill editors store it, so either side opens the other's drafts: +// script the `NewScript` body (`path, summary, description, content, schema, +// is_template, language, kind`, + `parent_hash` once forked from a deploy) +// flow the `Flow` object (`path, summary, description, value, schema`, + +// `version_id` when forked from a deploy) +// raw_app `{summary, files, runnables, data, policy?, custom_path?, +// parent_version?, draft_path?}` (`normalize_app_draft`) +// trigger_schedule the schedule request body; `trigger_<kind>` that trigger's body +// resource `{path, description, args, labels, wsSpecific, resource_type}` +// variable `{path, variable: {value, is_secret, description}, labels, +// wsSpecific, account?, is_oauth?, expires_at?}` +// ============================================================================ + +pub(super) const TRIGGER_KINDS: &[&str] = &[ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email", +]; + +/// The item types in the order a listing reports them. +pub(super) const ITEM_TYPES: &[&str] = &[ + "script", "flow", "schedule", "trigger", "resource", "variable", "app", +]; + +/// Folders and groups only carry permissions, so they are listed and read but kept out of +/// `ITEM_TYPES`, which the draft, deploy and delete tools share. +const ACCESS_TYPES: &[&str] = &["folder", "group"]; + +pub(super) const FLOW_VALUE_SETTINGS_KEYS: &[&str] = &[ + "same_worker", + "preserve_step_tags", + "concurrent_limit", + "concurrency_key", + "concurrency_time_window_s", + "debounce_delay_s", + "debounce_key", + "debounce_args_to_accumulate", + "max_total_debouncing_time", + "max_total_debounces_amount", + "skip_expr", + "cache_ttl", + "cache_ignore_s3_path", + "delete_after_secs", + "flow_env", + "priority", + "early_return", + "chat_input_enabled", +]; + +const MAX_LIST_LIMIT: u64 = 100; + +pub(super) fn item_type_arg(args: &Value) -> Result<&str, String> { + let item_type = str_arg(args, "type")?; + if ITEM_TYPES.contains(&item_type) { + Ok(item_type) + } else { + Err(format!("Invalid type \"{item_type}\"")) + } +} + +fn object_or_empty(value: Option<&Value>) -> Value { + match value { + Some(Value::Object(o)) => Value::Object(o.clone()), + _ => json!({}), + } +} + +pub(super) fn api_message(error: &str) -> &str { + match error.split_once(": ") { + Some((status, rest)) if status.len() == 3 && status.bytes().all(|b| b.is_ascii_digit()) => { + rest + } + _ => error, + } +} + +pub(super) fn path_enc(path: &str) -> String { + path.split('/').map(enc).collect::<Vec<_>>().join("/") +} + +pub(super) fn draft_kind(item_type: &str, trigger_kind: Option<&str>) -> Option<String> { + match item_type { + "script" | "flow" | "resource" | "variable" => Some(item_type.to_string()), + "app" => Some("raw_app".to_string()), + "schedule" => Some("trigger_schedule".to_string()), + "trigger" => trigger_kind + .filter(|kind| TRIGGER_KINDS.contains(kind)) + .map(|kind| format!("trigger_{kind}")), + _ => None, + } +} + +/// The item type (and trigger kind) a draft kind surfaces as. None for the kinds no +/// tool addresses: low-code apps, webhook and poll triggers, pipelines. +fn item_type_of_kind(kind: &str) -> Option<(&'static str, Option<&str>)> { + Some(match kind { + "script" => ("script", None), + "flow" => ("flow", None), + "resource" => ("resource", None), + "variable" => ("variable", None), + "raw_app" => ("app", None), + "trigger_schedule" => ("schedule", None), + _ => { + let trigger_kind = kind.strip_prefix("trigger_")?; + if !TRIGGER_KINDS.contains(&trigger_kind) { + return None; + } + ("trigger", Some(trigger_kind)) + } + }) +} + +fn item_key(item_type: &str, path: &str, trigger_kind: Option<&str>) -> String { + if item_type == "trigger" { + format!("trigger:{}:{path}", trigger_kind.unwrap_or_default()) + } else { + format!("{item_type}:{path}") + } +} + +pub(super) fn service(item_type: &str, trigger_kind: Option<&str>) -> Result<String, String> { + Ok(match item_type { + "trigger" => { + let kind = trigger_kind + .filter(|kind| TRIGGER_KINDS.contains(kind)) + .ok_or("trigger_kind is required when type is trigger.")?; + format!("{kind}_triggers") + } + "script" | "flow" | "schedule" | "resource" | "variable" | "app" => { + format!("{item_type}s") + } + _ => return Err(format!("Invalid type \"{item_type}\"")), + }) +} + +pub(super) fn exists_route( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<String, String> { + let by_path = if item_type == "script" { "p/" } else { "" }; + Ok(ctx.ws(&format!( + "{}/exists/{by_path}{}", + service(item_type, trigger_kind)?, + path_enc(path) + ))) +} + +pub(super) fn get_route( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<String, String> { + let by_path = if matches!(item_type, "script" | "app") { + "p/" + } else { + "" + }; + // A variable's value never reaches a tool, so it is not decrypted to begin with. + let query = if item_type == "variable" { + "?decrypt_secret=false" + } else { + "" + }; + Ok(ctx.ws(&format!( + "{}/get/{by_path}{}{query}", + service(item_type, trigger_kind)?, + path_enc(path) + ))) +} + +pub(super) async fn item_exists( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<bool, String> { + let exists = ctx + .get(&exists_route(ctx, item_type, trigger_kind, path)?) + .await?; + Ok(exists.as_bool().unwrap_or(false)) +} + +/// `exists` hides an archived script, which its path still resolves to and a delete by +/// path still removes, so an archived script counts as deployed. +pub(super) async fn deployed_item_exists( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<bool, String> { + if item_exists(ctx, item_type, trigger_kind, path).await? { + return Ok(true); + } + if item_type != "script" { + return Ok(false); + } + match get_deployed(ctx, "script", None, path).await { + Ok(_) => Ok(true), + Err(e) if is_not_found(&e) => Ok(false), + Err(e) => Err(e), + } +} + +pub(super) async fn get_deployed( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<Value, String> { + ctx.get(&get_route(ctx, item_type, trigger_kind, path)?) + .await +} + +pub(super) struct Draft { + pub value: Value, + pub created_at: Option<String>, +} + +pub(super) async fn get_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, +) -> Result<Option<Draft>, String> { + let row = ctx + .get(&ctx.ws(&format!("drafts/get_own/{kind}/{}", path_enc(path)))) + .await?; + let Some(value) = non_null(row.get("value")).cloned() else { + return Ok(None); + }; + let created_at = row + .get("created_at") + .and_then(Value::as_str) + .map(String::from); + Ok(Some(Draft { value, created_at })) +} + +pub(super) async fn save_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, + value: Option<&Value>, + last_sync: Option<&str>, + force: bool, +) -> Result<bool, String> { + let mut body = Map::new(); + body.insert("value".into(), value.cloned().unwrap_or(Value::Null)); + if !force { + put(&mut body, "last_sync", last_sync.map(Value::from)); + } + body.insert("force".into(), json!(force)); + let response = ctx + .post( + &ctx.ws(&format!("drafts/update/{kind}/{}", path_enc(path))), + Value::Object(body), + ) + .await?; + Ok(response.get("status").and_then(Value::as_str) != Some("conflict")) +} + +pub(super) async fn delete_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, + last_sync: Option<&str>, +) -> Result<(), String> { + match save_draft(ctx, kind, path, None, last_sync, false).await { + Ok(true) => Ok(()), + Ok(false) => Err(format!( + "Draft \"{path}\" changed externally since you last read it; it was not removed. Re-read and retry." + )), + Err(e) => Err(api_message(&e).to_string()), + } +} + +pub(super) async fn list_drafts(ctx: &Ctx<'_>) -> Result<Vec<Value>, String> { + let rows = ctx.get(&ctx.ws("drafts/list")).await?; + let rows = array(Some(&rows)); + let mut drafts = Map::new(); + for row in rows { + let (Some(kind), Some(path)) = ( + row.get("kind").and_then(Value::as_str), + row.get("path").and_then(Value::as_str), + ) else { + continue; + }; + let Some((item_type, trigger_kind)) = item_type_of_kind(kind) else { + continue; + }; + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + item.insert("path".into(), json!(path)); + put( + &mut item, + "draftPath", + row.get("draft_path") + .filter(|p| p.as_str() != Some(path)) + .cloned(), + ); + put(&mut item, "summary", row.get("summary").cloned()); + item.insert("isDraft".into(), json!(true)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + if kind == "raw_app" { + item.insert("rawApp".into(), json!(true)); + } + drafts.insert(item_key(item_type, path, trigger_kind), Value::Object(item)); + } + Ok(drafts.into_iter().map(|(_, item)| item).collect()) +} + +pub(super) fn normalize_app_draft(value: &Value) -> Value { + let mut out = Map::new(); + put(&mut out, "summary", value.get("summary").cloned()); + out.insert("files".into(), object_or_empty(value.get("files"))); + out.insert("runnables".into(), object_or_empty(value.get("runnables"))); + out.insert( + "data".into(), + non_null(value.get("data")) + .cloned() + .unwrap_or_else(|| json!({ "tables": [] })), + ); + for key in ["policy", "custom_path", "parent_version", "draft_path"] { + put(&mut out, key, value.get(key).cloned()); + } + Value::Object(out) +} + +pub(super) fn draft_item(kind: &str, path: &str, draft: &Value) -> Option<Value> { + let (item_type, trigger_kind) = item_type_of_kind(kind)?; + let field = |key: &str| draft.get(key).cloned(); + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + item.insert("path".into(), json!(path)); + // A staged name that repeats the storage path tells the reader nothing. + let draft_path = |value: &Value| { + value + .get("draft_path") + .filter(|p| p.as_str() != Some(path)) + .cloned() + }; + match item_type { + "script" => { + put(&mut item, "draftPath", draft_path(draft)); + put(&mut item, "summary", field("summary")); + put(&mut item, "language", field("language")); + put(&mut item, "value", field("content")); + put(&mut item, "schema", field("schema")); + put(&mut item, "parentHash", field("parent_hash")); + } + "flow" => { + put(&mut item, "draftPath", draft_path(draft)); + put(&mut item, "summary", field("summary")); + put(&mut item, "parentVersionId", field("version_id")); + let or_null = |v: Option<&Value>| v.cloned().unwrap_or(Value::Null); + item.insert( + "value".into(), + json!({ + "value": or_null(draft.get("value")), + "schema": or_null(draft.get("schema")), + "groups": or_null(draft.get("value").and_then(|v| v.get("groups"))), + "description": or_null(draft.get("description")), + }), + ); + } + "app" => { + let value = normalize_app_draft(draft); + put(&mut item, "draftPath", draft_path(&value)); + put(&mut item, "summary", value.get("summary").cloned()); + put( + &mut item, + "parentVersionId", + value.get("parent_version").cloned(), + ); + item.insert("value".into(), value); + item.insert("rawApp".into(), json!(true)); + } + "schedule" => { + put( + &mut item, + "summary", + non_null(draft.get("summary")).cloned(), + ); + item.insert("value".into(), draft.clone()); + } + "trigger" => { + put( + &mut item, + "summary", + field("summary").filter(|s| truthy(Some(s))), + ); + item.insert("value".into(), draft.clone()); + } + "resource" => { + put( + &mut item, + "summary", + field("description").filter(|d| truthy(Some(d))), + ); + let mut value = Map::new(); + value.insert("path".into(), json!(path)); + put(&mut value, "value", field("args")); + put(&mut value, "description", field("description")); + value.insert( + "resource_type".into(), + non_null(draft.get("resource_type")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + put(&mut value, "labels", field("labels")); + put(&mut value, "ws_specific", field("wsSpecific")); + item.insert("value".into(), Value::Object(value)); + } + "variable" => { + let variable = draft.get("variable").cloned().unwrap_or(Value::Null); + put( + &mut item, + "summary", + variable + .get("description") + .filter(|d| truthy(Some(d))) + .cloned(), + ); + put(&mut item, "isSecret", variable.get("is_secret").cloned()); + let mut value = Map::new(); + value.insert("path".into(), json!(path)); + put(&mut value, "value", variable.get("value").cloned()); + put(&mut value, "is_secret", variable.get("is_secret").cloned()); + put( + &mut value, + "description", + variable.get("description").cloned(), + ); + put(&mut value, "account", field("account")); + put(&mut value, "is_oauth", field("is_oauth")); + put(&mut value, "expires_at", field("expires_at")); + put(&mut value, "labels", field("labels")); + put(&mut value, "ws_specific", field("wsSpecific")); + item.insert("value".into(), Value::Object(value)); + } + _ => return None, + } + item.insert("isDraft".into(), json!(true)); + Some(Value::Object(item)) +} + +pub(super) async fn get_draft_item( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, +) -> Result<Option<(Value, Draft)>, String> { + let Some(kind) = draft_kind(item_type, trigger_kind) else { + return Ok(None); + }; + let Some(draft) = get_draft(ctx, &kind, path).await? else { + return Ok(None); + }; + Ok(draft_item(&kind, path, &draft.value).map(|item| (item, draft))) +} + +fn stash_inline_script( + value: &mut Map<String, Value>, + id: &str, + scripts: &mut HashMap<String, String>, +) { + if let Some(Value::String(content)) = value.get("content") { + scripts.insert(id.to_string(), content.clone()); + value.insert("content".into(), json!(format!("inline_script.{id}"))); + } +} + +fn compact_modules(modules: Option<&Value>, scripts: &mut HashMap<String, String>) -> Value { + let Some(modules) = modules.and_then(Value::as_array) else { + return json!([]); + }; + Value::Array( + modules + .iter() + .map(|module| compact_module(module, scripts)) + .collect(), + ) +} + +fn compact_branches(value: &mut Map<String, Value>, scripts: &mut HashMap<String, String>) { + let Some(Value::Array(branches)) = value.get_mut("branches") else { + return; + }; + for branch in branches { + if let Value::Object(branch) = branch { + let modules = compact_modules(branch.get("modules"), scripts); + branch.insert("modules".into(), modules); + } + } +} + +fn compact_module(module: &Value, scripts: &mut HashMap<String, String>) -> Value { + let mut module = module.clone(); + let id = module + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + return module; + }; + match value.get("type").and_then(Value::as_str) { + Some("rawscript") => stash_inline_script(value, &id, scripts), + Some("forloopflow" | "whileloopflow") if truthy(value.get("modules")) => { + let modules = compact_modules(value.get("modules"), scripts); + value.insert("modules".into(), modules); + } + Some("branchone") => { + compact_branches(value, scripts); + if truthy(value.get("default")) { + let default = compact_modules(value.get("default"), scripts); + value.insert("default".into(), default); + } + } + Some("branchall") => compact_branches(value, scripts), + Some("aiagent") => { + if let Some(Value::Array(tools)) = value.get_mut("tools") { + for tool in tools { + let tool_id = tool + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let Some(tool_value) = tool.get_mut("value").and_then(Value::as_object_mut) + else { + continue; + }; + let is = |key: &str, expected: &str| { + tool_value.get(key).and_then(Value::as_str) == Some(expected) + }; + if is("tool_type", "flowmodule") && is("type", "rawscript") { + stash_inline_script(tool_value, &tool_id, scripts); + } + } + } + } + _ => {} + } + module +} + +pub(super) fn compact_flow( + value: &Value, + schema: Option<&Value>, +) -> (Value, HashMap<String, String>) { + let mut scripts = HashMap::new(); + let mut out = Map::new(); + out.insert( + "modules".into(), + compact_modules(value.get("modules"), &mut scripts), + ); + out.insert("schema".into(), schema.cloned().unwrap_or(Value::Null)); + for key in ["preprocessor_module", "failure_module"] { + let mut module = value.get(key).cloned().unwrap_or(Value::Null); + let id = module + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if let Some(module_value) = module.get_mut("value").and_then(Value::as_object_mut) { + if module_value.get("type").and_then(Value::as_str) == Some("rawscript") { + stash_inline_script(module_value, &id, &mut scripts); + } + } + out.insert(key.into(), module); + } + for key in ["groups", "notes"] { + out.insert(key.into(), value.get(key).cloned().unwrap_or(Value::Null)); + } + for key in FLOW_VALUE_SETTINGS_KEYS { + put(&mut out, key, value.get(*key).cloned()); + } + (Value::Object(out), scripts) +} + +pub(super) fn app_source_to_draft_value(app: &Value, fallback: Option<&Value>) -> Value { + // A deployed app nests its source under `value`; a draft carries it at the top. + let source = app.get("value").filter(|v| !v.is_null()).unwrap_or(app); + let data = source.get("data"); + let creation = data.and_then(|d| d.get("creation")); + let tables_only = |tables: &Value| json!({ "tables": tables }); + let data = if truthy(creation) { + let mut out = Map::new(); + out.insert( + "tables".into(), + data.and_then(|d| non_null(d.get("tables"))) + .cloned() + .unwrap_or_else(|| json!([])), + ); + put( + &mut out, + "datatable", + creation.and_then(|c| c.get("datatable")).cloned(), + ); + put( + &mut out, + "schema", + creation.and_then(|c| c.get("schema")).cloned(), + ); + Value::Object(out) + } else if truthy(data) { + data.cloned().unwrap_or_default() + } else if truthy(source.get("datatables")) { + tables_only(&source["datatables"]) + } else if truthy(source.get("dataTableRefs")) { + tables_only(&source["dataTableRefs"]) + } else { + tables_only(&json!([])) + }; + let or_fallback = |key: &str| { + non_null(app.get(key)) + .cloned() + .or_else(|| fallback?.get(key).cloned()) + }; + let mut out = Map::new(); + out.insert( + "summary".into(), + non_null(app.get("summary")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + out.insert("files".into(), object_or_empty(source.get("files"))); + out.insert("runnables".into(), object_or_empty(source.get("runnables"))); + out.insert("data".into(), data); + put(&mut out, "policy", or_fallback("policy")); + put(&mut out, "custom_path", or_fallback("custom_path")); + // The fork base: the head of a deployed app's versions, or what a draft already pinned. + let head = app + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .filter(|v| !v.is_null()) + .cloned(); + put( + &mut out, + "parent_version", + non_null(app.get("parent_version")) + .cloned() + .or(head) + .or_else(|| fallback?.get("parent_version").cloned()), + ); + Value::Object(out) +} + +pub(super) fn summarize_app_value(value: &Value) -> Value { + let entries = |key: &str| { + value + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + let frontend: Vec<Value> = entries("files") + .iter() + .map(|(path, content)| json!({ "path": path, "size": content.as_str().map(js_len).unwrap_or(0) })) + .collect(); + let backend: Vec<Value> = entries("runnables") + .iter() + .map(|(key, runnable)| { + let mut out = Map::new(); + out.insert("key".into(), json!(key)); + if !truthy(Some(runnable)) { + out.insert("name".into(), json!(key)); + out.insert("type".into(), json!("inline")); + return Value::Object(out); + } + out.insert( + "name".into(), + non_null(runnable.get("name")) + .cloned() + .unwrap_or_else(|| json!(key)), + ); + let kind = runnable.get("type").and_then(Value::as_str); + let by_path = matches!(kind, Some("path" | "runnableByPath")); + let run_type = match runnable.get("runType").and_then(Value::as_str) { + _ if !by_path => "inline", + Some("flow") => "flow", + Some("hubscript") => "hubscript", + _ => "script", + }; + out.insert("type".into(), json!(run_type)); + if by_path && truthy(runnable.get("path")) { + out.insert("path".into(), runnable["path"].clone()); + } + let inline = runnable.get("inlineScript").filter(|s| truthy(Some(s))); + if let Some(inline) = + inline.filter(|_| matches!(kind, Some("inline" | "runnableByName"))) + { + let language = match inline.get("language") { + Some(Value::String(l)) if l == "nativets" || l == "deno" => Some(json!("bun")), + other => other.cloned(), + }; + put(&mut out, "language", language); + let content = inline + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + out.insert("contentSize".into(), json!(js_len(content))); + } + let static_keys: Vec<&String> = runnable + .get("fields") + .and_then(Value::as_object) + .map(|fields| { + fields + .iter() + .filter(|(_, v)| v.get("type").and_then(Value::as_str) == Some("static")) + .map(|(k, _)| k) + .collect() + }) + .unwrap_or_default(); + if !static_keys.is_empty() { + out.insert("staticInputKeys".into(), json!(static_keys)); + } + Value::Object(out) + }) + .collect(); + let mut out = Map::new(); + out.insert("frontend".into(), Value::Array(frontend)); + out.insert("backend".into(), Value::Array(backend)); + if truthy(value.get("data")) { + out.insert("data".into(), value["data"].clone()); + } + Value::Object(out) +} + +/// `guest` is stored on an app whether or not anyone is admitted by it, so the mode +/// alone would say strangers can open an app that admits members only. +pub(super) async fn describe_app_exposure(ctx: &Ctx<'_>, mode: Option<&Value>) -> Option<Value> { + if mode.and_then(Value::as_str) != Some("guest") { + return mode.cloned(); + } + let usage = ctx.get(&ctx.ws("workspaces/guest_usage")).await; + let settings = ctx.get(&ctx.ws("workspaces/get_public_settings")).await; + // A switch that could not be read proves nothing either way: report the mode bare. + let (Ok(usage), Ok(settings)) = (usage, settings) else { + return mode.cloned(); + }; + let live = truthy(usage.get("available")) + && truthy(usage.get("instance_enabled")) + && truthy(settings.get("guest_access_enabled")); + if live { + mode.cloned() + } else { + Some(json!( + "guest, but inert: the instance or workspace admits no guest, so this app admits members only" + )) + } +} + +pub(super) fn serialize_for_read(item: &Value, execution_mode: Option<Value>) -> Value { + let item_type = item.get("type").and_then(Value::as_str); + let mut out = Map::new(); + let copy = |out: &mut Map<String, Value>, keys: &[&str]| { + for key in keys { + put(out, key, item.get(*key).cloned()); + } + }; + match item_type { + Some("variable") => copy( + &mut out, + &[ + "type", + "path", + "summary", + "isSecret", + "extraPerms", + "isDraft", + ], + ), + Some("app") if item.get("value").is_some_and(|v| v.get("files").is_some()) => { + copy(&mut out, &["type", "path", "summary"]); + out.insert("value".into(), summarize_app_value(&item["value"])); + copy(&mut out, &["rawApp"]); + put( + &mut out, + "executionMode", + execution_mode.or_else(|| item.get("executionMode").cloned()), + ); + copy(&mut out, &["extraPerms", "isDraft"]); + } + Some("flow") if truthy(item.get("value")) => { + let flow = &item["value"]; + // A draft carries `groups` beside the flow value; a set one outranks the value's. + let mut value = flow.get("value").cloned().unwrap_or_else(|| json!({})); + if let (Some(groups), Some(value)) = (flow.get("groups"), value.as_object_mut()) { + if groups.is_null() { + value.remove("groups"); + } else { + value.insert("groups".into(), groups.clone()); + } + } + copy(&mut out, &["type", "path", "summary"]); + out.insert( + "value".into(), + compact_flow(&value, non_null(flow.get("schema"))).0, + ); + copy(&mut out, &["extraPerms", "isDraft"]); + } + _ => { + let mut item = item.clone(); + if let (Some(mode), Some(item)) = (execution_mode, item.as_object_mut()) { + item.insert("executionMode".into(), mode); + } + return item; + } + } + Value::Object(out) +} + +pub(super) struct LoadedScript { + pub content: String, + pub language: String, + pub summary: Option<String>, + pub schema: Option<Value>, +} + +pub(super) async fn load_script(ctx: &Ctx<'_>, path: &str) -> Result<LoadedScript, String> { + let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).map(String::from); + if let Some((item, _)) = get_draft_item(ctx, "script", path, None).await? { + let (Some(content), Some(language)) = ( + text(&item, "value"), + text(&item, "language").filter(|l| !l.is_empty()), + ) else { + return Err(format!( + "Draft script \"{path}\" is missing content or language." + )); + }; + return Ok(LoadedScript { + content, + language, + summary: text(&item, "summary"), + schema: non_null(item.get("schema")).cloned(), + }); + } + let script = get_deployed(ctx, "script", None, path).await?; + Ok(LoadedScript { + content: text(&script, "content").unwrap_or_default(), + language: text(&script, "language").unwrap_or_default(), + summary: text(&script, "summary"), + schema: non_null(script.get("schema")).cloned(), + }) +} + +pub(super) struct LoadedFlow { + /// `{value, schema, groups}`, plus `description` when read from a draft. + pub flow: Value, + pub summary: Option<String>, + pub is_draft: bool, +} + +pub(super) async fn load_flow(ctx: &Ctx<'_>, path: &str) -> Result<LoadedFlow, String> { + let summary = |value: &Value| { + value + .get("summary") + .and_then(Value::as_str) + .map(String::from) + }; + if let Some((item, _)) = get_draft_item(ctx, "flow", path, None).await? { + let Some(flow) = item.get("value").filter(|v| v.is_object()) else { + return Err(format!("Draft flow \"{path}\" has no value.")); + }; + return Ok(LoadedFlow { flow: flow.clone(), summary: summary(&item), is_draft: true }); + } + let flow = get_deployed(ctx, "flow", None, path).await?; + Ok(LoadedFlow { flow: deployed_flow_value(&flow), summary: summary(&flow), is_draft: false }) +} + +fn deployed_flow_value(flow: &Value) -> Value { + let mut out = Map::new(); + put(&mut out, "value", flow.get("value").cloned()); + put(&mut out, "schema", flow.get("schema").cloned()); + out.insert( + "groups".into(), + flow.get("value") + .and_then(|v| non_null(v.get("groups"))) + .cloned() + .unwrap_or(Value::Null), + ); + Value::Object(out) +} + +pub(super) struct WriteBase { + pub base: Option<Value>, + pub from_draft: bool, + pub last_sync: Option<String>, +} + +pub(super) async fn write_base( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, +) -> Result<WriteBase, String> { + // A failed draft read must not fall through to the deployed item: the write would + // then be built on it and replace the draft's own changes. + let draft = match draft_kind(item_type, trigger_kind) { + Some(kind) => get_draft(ctx, &kind, path).await?, + None => None, + }; + if let Some(draft) = draft { + return Ok(WriteBase { + base: Some(draft.value), + from_draft: true, + last_sync: draft.created_at, + }); + } + let base = if item_exists(ctx, item_type, trigger_kind, path).await? { + Some(get_deployed(ctx, item_type, trigger_kind, path).await?) + } else { + None + }; + Ok(WriteBase { base, from_draft: false, last_sync: None }) +} + +pub(super) enum Persist { + Saved(Value), + Failed(String), +} + +pub(super) async fn persist_draft( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, + value: &Value, + last_sync: Option<&str>, + force: bool, +) -> Result<Persist, String> { + let kind = draft_kind(item_type, trigger_kind) + .ok_or_else(|| format!("Unsupported draft type \"{item_type}\"."))?; + let item = draft_item(&kind, path, value) + .ok_or_else(|| format!("Could not synthesize {item_type} draft \"{path}\"."))?; + let failure = match save_draft(ctx, &kind, path, Some(value), last_sync, force).await { + Ok(true) => return Ok(Persist::Saved(item)), + Ok(false) => json!({ + "success": false, + "conflict": true, + "message": format!("The {item_type} draft \"{path}\" changed externally since you last read it. Re-run this tool to merge onto the latest version, or pass override:true to overwrite. If an editor for it is open, a conflict dialog is also shown there."), + }), + Err(e) => json!({ + "success": false, + "error": true, + "message": format!("The {item_type} draft \"{path}\" could NOT be saved ({}). The change was not persisted — retry; do not assume it succeeded.", api_message(&e)), + }), + }; + Ok(Persist::Failed(pretty(&failure))) +} + +/// Whether a resource's draft or deployed value is an AI agent, which has its own page. +pub(super) fn is_agent(value: &Value) -> bool { + value.get("resource_type").and_then(Value::as_str) == Some("ai_agent") +} + +/// The editor that shows the user's draft: a detail page shows only the deployed version. +fn draft_page(item_type: &str, path: &str, value: &Value) -> Option<String> { + Some(match item_type { + "script" => format!("/scripts/edit/{path}?nomenubar=true"), + "flow" => format!("/flows/edit/{path}?nomenubar=true"), + "resource" if is_agent(value) => format!("/agents/edit/{path}?nomenubar=true"), + _ => return None, + }) +} + +pub(super) fn finish_draft_write(ctx: &Ctx<'_>, item: &Value, existed: bool) -> String { + let text = |key: &str| item.get(key).and_then(Value::as_str).unwrap_or_default(); + let (item_type, path) = (text("type"), text("path")); + let verb = if existed { "Updated" } else { "Created" }; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!("{verb} {item_type} \"{path}\" as a per-user draft (saved server-side, visible only to this user — not a deployed workspace item). It was not deployed.")), + ); + let value = item.get("value").unwrap_or(&Value::Null); + if let Some(page) = draft_page(item_type, path, value) { + out.insert("url".into(), json!(ctx.page_url(&page))); + } + // A flow is not echoed back: the model just sent it. A variable comes back redacted. + match item_type { + "flow" => {} + "variable" => { + out.insert("item".into(), serialize_for_read(item, None)); + } + _ => { + out.insert("item".into(), item.clone()); + } + } + pretty(&Value::Object(out)) +} + +pub(super) fn find_and_replace( + content: &str, + old: &str, + new: &str, + replace_all: bool, + label: &str, +) -> Result<String, String> { + let matches = if old.is_empty() { + 0 + } else { + content.matches(old).count() + }; + if matches == 0 { + return Err(format!("old_string was not found in the {label}.")); + } + if !replace_all && matches != 1 { + return Err(format!( + "old_string matched {matches} locations. Make it more specific or set replace_all to true." + )); + } + Ok(content.replace(old, new)) +} + +pub(super) fn empty_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object", + }) +} + +pub(super) fn infer_script_schema( + language: &str, + content: &str, + previous: Option<&Value>, + entrypoint: Option<&str>, +) -> Option<Value> { + use windmill_common::scripts::{ScriptKind, ScriptLang}; + let language: ScriptLang = serde_json::from_value(Value::String(language.to_string())).ok()?; + let kind = (entrypoint == Some("preprocessor")).then_some(ScriptKind::Preprocessor); + let schema = windmill_api_scripts::scripts::infer_main_schema( + &language, + kind.as_ref(), + content, + previous, + )?; + serde_json::to_value(schema).ok() +} + +/// Lines with their terminator kept, so a missing final newline is a difference. +fn patch_lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +/// `(tag, line)` for every line of both sides, `' '` for a shared one, deletions ahead +/// of insertions within a change. +fn diff_lines<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(char, &'a str)> { + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old[prefix..] + .iter() + .rev() + .zip(new[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + let (a, b) = ( + &old[prefix..old.len() - suffix], + &new[prefix..new.len() - suffix], + ); + let mut ops: Vec<(char, &str)> = old[..prefix].iter().map(|l| (' ', *l)).collect(); + let (n, m) = (a.len(), b.len()); + // ponytail: quadratic LCS table. Past the cap the changed region is reported as + // replaced wholesale, still a valid patch; switch to Myers if that ever shows. + const MAX_CELLS: usize = 4_000_000; + if (n + 1).saturating_mul(m + 1) > MAX_CELLS { + ops.extend(a.iter().map(|l| ('-', *l))); + ops.extend(b.iter().map(|l| ('+', *l))); + } else { + let width = m + 1; + let mut lcs = vec![0u32; (n + 1) * width]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i * width + j] = if a[i] == b[j] { + lcs[(i + 1) * width + j + 1] + 1 + } else { + lcs[(i + 1) * width + j].max(lcs[i * width + j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut inserted = Vec::new(); + while i < n || j < m { + if i < n && j < m && a[i] == b[j] { + ops.extend(inserted.drain(..)); + ops.push((' ', a[i])); + i += 1; + j += 1; + } else if j == m || (i < n && lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) { + ops.push(('-', a[i])); + i += 1; + } else { + inserted.push(('+', b[j])); + j += 1; + } + } + ops.extend(inserted); + } + ops.extend(old[old.len() - suffix..].iter().map(|l| (' ', *l))); + ops +} + +pub(super) fn unified_patch( + old_name: &str, + new_name: &str, + old: &str, + new: &str, + context: usize, +) -> String { + let ops = diff_lines(&patch_lines(old), &patch_lines(new)); + let mut out = String::new(); + if old_name == new_name { + out.push_str(&format!("Index: {old_name}\n")); + } + out.push_str(&format!( + "===================================================================\n--- {old_name}\t\n+++ {new_name}\t\n" + )); + let changes: Vec<usize> = (0..ops.len()).filter(|i| ops[*i].0 != ' ').collect(); + let mut next = 0; + while next < changes.len() { + let first = changes[next]; + let mut last = first; + // Two changes share a hunk when their contexts would touch. + while next + 1 < changes.len() && changes[next + 1] - last - 1 <= 2 * context { + next += 1; + last = changes[next]; + } + next += 1; + let start = first.saturating_sub(context); + let end = (last + context + 1).min(ops.len()); + let count = + |range: &[(char, &str)], skip: char| range.iter().filter(|op| op.0 != skip).count(); + let (old_before, new_before) = (count(&ops[..start], '+'), count(&ops[..start], '-')); + let (old_count, new_count) = (count(&ops[start..end], '+'), count(&ops[start..end], '-')); + // An empty side is addressed by the line before it. + let old_start = old_before + usize::from(old_count > 0); + let new_start = new_before + usize::from(new_count > 0); + out.push_str(&format!( + "@@ -{old_start},{old_count} +{new_start},{new_count} @@\n" + )); + for (tag, line) in &ops[start..end] { + out.push(*tag); + match line.strip_suffix('\n') { + Some(line) => { + out.push_str(line); + out.push('\n'); + } + None => { + out.push_str(line); + out.push_str("\n\\ No newline at end of file\n"); + } + } + } + } + out +} + +// ============================================================================ +// Tools +// ============================================================================ + +fn deployed_item(item_type: &str, trigger_kind: Option<&str>, row: &Value) -> Value { + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + put(&mut item, "path", row.get("path").cloned()); + let mut is_draft = false; + match item_type { + "script" | "flow" => { + put(&mut item, "summary", row.get("summary").cloned()); + if item_type == "script" { + put(&mut item, "language", row.get("language").cloned()); + } + // A listing synthesizes rows for drafts with no deployed counterpart. + is_draft = truthy(row.get("draft_only")); + } + "schedule" | "trigger" => put(&mut item, "summary", non_null(row.get("summary")).cloned()), + "resource" => put(&mut item, "summary", row.get("description").cloned()), + "variable" => { + put(&mut item, "summary", row.get("description").cloned()); + put(&mut item, "isSecret", row.get("is_secret").cloned()); + } + _ => { + put(&mut item, "summary", row.get("summary").cloned()); + // The server omits the flag rather than sending false. + item.insert( + "rawApp".into(), + non_null(row.get("raw_app")) + .cloned() + .unwrap_or(json!(false)), + ); + } + } + item.insert("isDraft".into(), json!(is_draft)); + Value::Object(item) +} + +/// Put `value` (and a script's `schema`) where the session's item shape has them. +fn with_value(item: Value, value: Option<Value>, schema: Option<Value>) -> Value { + let Value::Object(item) = item else { + return item; + }; + let mut out = Map::new(); + for (key, field) in item { + if key == "isDraft" { + put(&mut out, "value", value.clone()); + put(&mut out, "schema", schema.clone()); + } + out.insert(key, field); + } + Value::Object(out) +} + +async fn list_workspace_items(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let requested: Vec<&str> = args + .get("types") + .and_then(Value::as_array) + .map(|types| types.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let (access_types, item_types): (Vec<&str>, Vec<&str>) = requested + .into_iter() + .partition(|t| ACCESS_TYPES.contains(t)); + // Asking only for folders or groups must not fall back to the default scripts and flows. + let types = if item_types.is_empty() && access_types.is_empty() { + vec!["script", "flow"] + } else { + item_types + }; + let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50); + let page = args.get("page").and_then(Value::as_u64); + let prefix = opt_str_arg(args, "path_prefix"); + + let mut query = format!("per_page={}", limit.min(MAX_LIST_LIMIT)); + if let Some(page) = page { + query.push_str(&format!("&page={page}")); + } + + // Group rows carry no members: the list endpoint does not return them. + let mut access_rows = Vec::new(); + for access_type in ACCESS_TYPES.iter().filter(|t| access_types.contains(t)) { + let rows = ctx + .get(&ctx.ws(&format!("{access_type}s/list?{query}"))) + .await?; + for row in array(Some(&rows)) { + let mut item = Map::new(); + item.insert("type".into(), json!(access_type)); + put(&mut item, "path", row.get("name").cloned()); + put(&mut item, "summary", row.get("summary").cloned()); + if *access_type == "folder" { + put(&mut item, "owners", row.get("owners").cloned()); + } + put(&mut item, "extraPerms", row.get("extra_perms").cloned()); + access_rows.push(Value::Object(item)); + } + } + + if let Some(prefix) = prefix { + query.push_str(&format!("&path_start={}", enc(prefix))); + } + + let mut by_key = Map::new(); + for item_type in ITEM_TYPES.iter().filter(|t| types.contains(t)) { + let kinds: Vec<Option<&str>> = if *item_type == "trigger" { + TRIGGER_KINDS.iter().map(|kind| Some(*kind)).collect() + } else { + vec![None] + }; + let extra = if matches!(*item_type, "script" | "flow") { + "&include_draft_only=true&without_description=true" + } else { + "" + }; + for trigger_kind in kinds { + let route = ctx.ws(&format!( + "{}/list?{query}{extra}", + service(item_type, trigger_kind)? + )); + let rows = match ctx.get(&route).await { + Ok(rows) => rows, + // A trigger kind this build does not serve answers 404: skip that kind + // alone. Any other failure is real. + Err(e) if trigger_kind.is_some() && is_not_found(&e) => continue, + Err(e) => return Err(e), + }; + for row in array(Some(&rows)) { + let path = row.get("path").and_then(Value::as_str).unwrap_or_default(); + by_key.insert( + item_key(item_type, path, trigger_kind), + deployed_item(item_type, trigger_kind, row), + ); + } + } + } + + // Drafts are not paginated: they overlay page 1 only, capped per type, so later + // pages never repeat a page-1 item as its draft twin. + if page.unwrap_or(1) == 1 { + let mut count_by_type: HashMap<String, u64> = HashMap::new(); + for draft in list_drafts(ctx).await? { + let text = |key: &str| draft.get(key).and_then(Value::as_str); + let (item_type, path) = ( + text("type").unwrap_or_default(), + text("path").unwrap_or_default(), + ); + if !types.contains(&item_type) { + continue; + } + // A draft's staged name is often not where it is stored, and this pass is + // the only source of draft-only rows under a prefix: either name may match. + if let Some(prefix) = prefix { + let staged = text("draftPath").is_some_and(|p| p.starts_with(prefix)); + if !path.starts_with(prefix) && !staged { + continue; + } + } + let count = count_by_type.entry(item_type.to_string()).or_default(); + if *count >= limit { + continue; + } + *count += 1; + by_key.insert( + item_key(item_type, path, text("triggerKind")), + draft.clone(), + ); + } + } + + let query = opt_str_arg(args, "query") + .map(|q| q.trim().to_lowercase()) + .filter(|q| !q.is_empty()); + let results: Vec<Value> = by_key + .into_iter() + .map(|(_, item)| item) + .chain(access_rows) + .filter(|item| { + let Some(query) = &query else { return true }; + ["path", "summary"].iter().any(|key| { + item.get(*key) + .and_then(Value::as_str) + .is_some_and(|text| text.to_lowercase().contains(query)) + }) + }) + .collect(); + Ok(pretty(&Value::Array(results))) +} + +async fn read_workspace_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + if let Some(access_type) = opt_str_arg(args, "type").filter(|t| ACCESS_TYPES.contains(t)) { + return read_access_entity(ctx, access_type, str_arg(args, "path")?).await; + } + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Ok( + json!({ "success": false, "error": "trigger_kind is required when type is trigger." }) + .to_string(), + ); + } + let deployed_only = opt_str_arg(args, "version") == Some("deployed"); + // Hub scripts live outside the workspace: no draft, no workspace lookup. + let hub = path.starts_with("hub/"); + + if !deployed_only && !hub { + if let Some((item, _)) = get_draft_item(ctx, item_type, path, trigger_kind).await? { + // The mode reported is the draft's, which is what deploying it will write. + let mode = if item_type == "app" { + let mode = item + .get("value") + .and_then(|v| v.get("policy")) + .and_then(|p| p.get("execution_mode")); + describe_app_exposure(ctx, mode).await + } else { + None + }; + return Ok(pretty(&serialize_for_read(&item, mode))); + } + } + + let item = match item_type { + "script" if hub => { + let script = ctx + .get(&format!("/scripts/hub/get_full/{}", path_enc(path))) + .await?; + let mut row = script.clone(); + row["path"] = json!(path); + let mut item = with_value( + deployed_item("script", None, &row), + script.get("content").cloned(), + script.get("schema").cloned(), + ); + item["isDraft"] = json!(false); + item + } + "script" | "flow" => { + let route = get_route(ctx, item_type, None, path)?; + let deployed = ctx + .get(&format!("{route}?get_draft={}", !deployed_only)) + .await?; + let extra_perms = deployed.get("extra_perms").cloned(); + // The draft stored on the item itself outranks its deployed version. + let row = non_null(deployed.get("draft")) + .cloned() + .filter(|_| !deployed_only) + .unwrap_or(deployed); + if item_type == "script" { + with_extra_perms( + with_value( + deployed_item("script", None, &row), + row.get("content").cloned(), + row.get("schema").cloned(), + ), + extra_perms, + ) + } else { + let item = with_value( + deployed_item("flow", None, &row), + Some(deployed_flow_value(&row)), + None, + ); + serialize_for_read(&with_extra_perms(item, extra_perms), None) + } + } + "variable" => { + let row = get_deployed(ctx, "variable", None, path).await?; + let item = with_extra_perms( + deployed_item("variable", None, &row), + row.get("extra_perms").cloned(), + ); + serialize_for_read(&item, None) + } + "app" => { + // Metadata only: file and runnable contents come through `read_app_file`. + let app = get_deployed(ctx, "app", None, path).await?; + let mode = + describe_app_exposure(ctx, app.get("policy").and_then(|p| p.get("execution_mode"))) + .await; + let mut item = Map::new(); + item.insert("type".into(), json!("app")); + put(&mut item, "path", app.get("path").cloned()); + // A grid is not files and runnables: summarizing one would report an empty app. + if app.get("raw_app") == Some(&json!(false)) { + put(&mut item, "summary", app.get("summary").cloned()); + } else { + let value = app_source_to_draft_value(&app, None); + put(&mut item, "summary", value.get("summary").cloned()); + item.insert("value".into(), summarize_app_value(&value)); + } + put(&mut item, "rawApp", app.get("raw_app").cloned()); + put(&mut item, "executionMode", mode); + put(&mut item, "extraPerms", app.get("extra_perms").cloned()); + item.insert("isDraft".into(), json!(false)); + Value::Object(item) + } + _ => { + let row = get_deployed(ctx, item_type, trigger_kind, path).await?; + let item = with_value( + deployed_item(item_type, trigger_kind, &row), + Some(row.clone()), + None, + ); + with_extra_perms(item, row.get("extra_perms").cloned()) + } + }; + Ok(pretty(&item)) +} + +/// Who a deployed item is shared with. A draft has no permissions of its own, so a draft +/// read leaves it out rather than reporting the item unshared. +fn with_extra_perms(mut item: Value, extra_perms: Option<Value>) -> Value { + if let Some(item) = item.as_object_mut() { + put(item, "extraPerms", extra_perms); + } + item +} + +/// Accepts the `f/<name>` / `g/<name>` form the model sees in extra_perms keys and paths. +fn access_entity_name<'a>(access_type: &str, path: &'a str) -> &'a str { + let prefix = if access_type == "folder" { "f/" } else { "g/" }; + path.strip_prefix(prefix) + .unwrap_or(path) + .trim_end_matches('/') +} + +async fn read_access_entity(ctx: &Ctx<'_>, access_type: &str, path: &str) -> ToolResult { + let name = access_entity_name(access_type, path); + let row = ctx + .get(&ctx.ws(&format!("{access_type}s/get/{}", enc(name)))) + .await?; + let mut entity = Map::new(); + entity.insert("type".into(), json!(access_type)); + put(&mut entity, "name", row.get("name").cloned()); + put(&mut entity, "summary", row.get("summary").cloned()); + if access_type == "folder" { + put(&mut entity, "owners", row.get("owners").cloned()); + put(&mut entity, "extraPerms", row.get("extra_perms").cloned()); + put( + &mut entity, + "defaultPermissionedAs", + row.get("default_permissioned_as").cloned(), + ); + } else { + put(&mut entity, "members", row.get("members").cloned()); + put(&mut entity, "extraPerms", row.get("extra_perms").cloned()); + } + Ok(pretty(&Value::Object(entity))) +} + +async fn write_script_draft( + ctx: &Ctx<'_>, + path: &str, + summary: Option<&str>, + language: &str, + content: &str, + force: bool, +) -> ToolResult { + let base = write_base(ctx, "script", path, None).await?; + let existed = base.base.is_some(); + let mut draft = match base.base { + Some(Value::Object(mut script)) => { + if !base.from_draft { + let hash = script.get("hash").cloned().unwrap_or(Value::Null); + script.insert("parent_hash".into(), hash); + // The deployed lock describes the deployed content, not the draft's; the + // deploy recomputes it. + script.shift_remove("lock"); + script.shift_remove("lock_error_logs"); + } + script.insert("path".into(), json!(path)); + put(&mut script, "summary", summary.map(Value::from)); + script.insert("content".into(), json!(content)); + script.insert("language".into(), json!(language)); + script + } + _ => json!({ + "path": path, + "summary": summary.unwrap_or_default(), + "description": "", + "content": content, + "schema": empty_schema(), + "is_template": false, + "language": language, + "kind": "script", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + // Inferred into the schema the base carries: its properties are the only copy of + // what no parser emits (`password`, enums, formats, titles). Code that does not + // parse keeps the schema as it was. + let previous = draft + .get("schema") + .filter(|schema| truthy(schema.get("properties"))) + .cloned() + .unwrap_or_else(empty_schema); + let code = if content.is_empty() { " " } else { content }; + // A language this build cannot parse, or code that does not parse yet, keeps the + // schema the draft had. + let schema = infer_script_schema(language, code, Some(&previous), None).unwrap_or(previous); + draft.insert("schema".into(), schema); + let persisted = persist_draft( + ctx, + "script", + path, + None, + &Value::Object(draft), + base.last_sync.as_deref(), + force, + ) + .await?; + Ok(match persisted { + Persist::Saved(item) => finish_draft_write(ctx, &item, existed), + Persist::Failed(text) => text, + }) +} + +async fn edit_script(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let script = load_script(ctx, path).await?; + let updated = find_and_replace( + &script.content, + str_arg(args, "old_string")?, + str_arg(args, "new_string")?, + bool_arg(args, "replace_all"), + "script source", + )?; + write_script_draft( + ctx, + path, + script.summary.as_deref(), + &script.language, + &updated, + false, + ) + .await +} + +async fn discard_local_draft(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when discarding a trigger draft.".to_string()); + } + let not_found = || format!("No draft found for {item_type} \"{path}\"."); + let kind = draft_kind(item_type, trigger_kind).ok_or_else(not_found)?; + let draft = get_draft(ctx, &kind, path).await?.ok_or_else(not_found)?; + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "Discarded the draft for {item_type} \"{path}\". The deployed workspace item was not changed." + )), + ); + out.insert("type".into(), json!(item_type)); + out.insert("path".into(), json!(path)); + put(&mut out, "triggerKind", trigger_kind.map(Value::from)); + Ok(pretty(&Value::Object(out))) +} + +/// Discards a stale draft and hands back its own changes against the version it was +/// forked from. Discarding rather than resetting keeps the fork base honest: the next +/// write starts from the current head, and a premature deploy finds no draft instead +/// of shipping the latest version unchanged. +async fn rebase_draft(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let (label, noun) = match item_type { + "script" => ("script", "Script"), + "flow" => ("flow", "Flow"), + "app" => ("app", "App"), + _ => return Err("rebase_draft currently supports scripts, flows, and apps.".to_string()), + }; + let no_draft = || format!("No {label} draft found for \"{path}\"."); + let (item, draft) = get_draft_item(ctx, item_type, path, None) + .await? + .ok_or_else(no_draft)?; + let value = item.get("value").cloned().unwrap_or(Value::Null); + let usable = match item_type { + "script" => value.is_string() && truthy(item.get("language")), + _ => value.is_object(), + }; + if !usable { + return Err(no_draft()); + } + if !item_exists(ctx, item_type, None, path).await? { + return Err(format!( + "{noun} \"{path}\" is not deployed; there is no newer version to rebase onto." + )); + } + let latest = get_deployed(ctx, item_type, None, path).await?; + let (base, head, head_key) = match item_type { + "script" => ( + item.get("parentHash").filter(|h| truthy(Some(h))).cloned(), + latest.get("hash").cloned(), + "latest_hash", + ), + "flow" => ( + non_null(item.get("parentVersionId")).cloned(), + latest.get("version_id").cloned(), + "latest_version", + ), + _ => ( + non_null(item.get("parentVersionId")).cloned(), + latest + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .cloned(), + "latest_version", + ), + }; + let head_text = head + .as_ref() + .map_or_else(|| "undefined".to_string(), js_str); + if base.is_some() && base == head { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("alreadyLatest".into(), json!(true)); + put(&mut out, head_key, head); + out.insert( + "message".into(), + json!(format!( + "Draft \"{path}\" is already based on the latest deployed version ({head_text})." + )), + ); + return Ok(Value::Object(out).to_string()); + } + + // Captured before the discard: this diff is the only record of what to replay. A + // base version that can no longer be fetched diffs against nothing, so the whole + // draft surfaces. + let base_id = base.as_ref().map(js_str); + let (theirs, ours) = match item_type { + "script" => { + let base_content = match &base_id { + Some(hash) => ctx + .get(&ctx.ws(&format!("scripts/get/h/{}", enc(hash)))) + .await + .ok() + .and_then(|s| s.get("content").and_then(Value::as_str).map(String::from)), + None => None, + }; + ( + base_content.unwrap_or_default(), + value.as_str().unwrap_or_default().to_string(), + ) + } + "flow" => { + let base_value = match &base_id { + Some(version) => ctx + .get(&ctx.ws(&format!("flows/get/v/{}", enc(version)))) + .await + .ok() + .and_then(|flow| flow.get("value").cloned()), + None => None, + }; + ( + pretty(&base_value.unwrap_or_else(|| json!({}))), + pretty(value.get("value").unwrap_or(&Value::Null)), + ) + } + _ => { + let source = |app: &Value| { + let mut out = Map::new(); + for key in ["files", "runnables", "data"] { + put(&mut out, key, app.get(key).cloned()); + } + Value::Object(out) + }; + let base_app = match &base_id { + Some(version) => ctx + .get(&ctx.ws(&format!("apps/get/v/{}", enc(version)))) + .await + .ok() + .map(|app| app_source_to_draft_value(&app, Some(&app))), + None => None, + }; + ( + pretty(&source( + &base_app.unwrap_or_else(|| json!({ "files": {}, "runnables": {} })), + )), + pretty(&source(&value)), + ) + } + }; + let your_changes = unified_patch("fork-base", "your-draft", &theirs, &ours, 4); + + let (diff, tools, version) = match item_type { + "script" => ( + "a diff", + "edit_script / write_script", + format!("hash {head_text}"), + ), + "flow" => ( + "a JSON diff", + "the flow edit tools", + format!("version {head_text}"), + ), + _ => ( + "a JSON diff", + "the app edit tools", + format!("version {head_text}"), + ), + }; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "Discarded the stale draft for \"{path}\". Your changes are in \"your_changes\" ({diff} against the version you forked from). Re-apply them with {tools} — the new draft will be based on the latest deployed version ({version}) — then deploy." + )), + ); + put(&mut out, head_key, head); + out.insert("your_changes".into(), json!(your_changes)); + let result = pretty(&Value::Object(out)); + // The diff is the only copy of the draft once it is discarded: a result the server + // would truncate must keep the draft instead. + if result.len() > windmill_mcp::server::MAX_TOOL_RESULT_BYTES { + let editor = match item_type { + "script" => "/scripts/edit", + "flow" => "/flows/edit", + _ => "/apps_raw/edit", + }; + return Err(format!( + "The draft of \"{path}\" differs from the version it was forked from by more than a tool result can hold, so it was kept as is. Ask the user to reconcile it with the latest deployed version ({version}) in the editor: {}", + ctx.page_url(&format!("{editor}/{path}")) + )); + } + let kind = draft_kind(item_type, None).ok_or_else(no_draft)?; + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_entities_are_named_with_or_without_their_prefix() { + assert_eq!(access_entity_name("folder", "f/ops/"), "ops"); + assert_eq!(access_entity_name("folder", "ops"), "ops"); + assert_eq!(access_entity_name("group", "g/devs"), "devs"); + // Only the type's own prefix is stripped. + assert_eq!(access_entity_name("group", "f/devs"), "f/devs"); + } + + #[test] + fn find_and_replace_requires_one_match_unless_replace_all() { + let replace = |all| find_and_replace("a b a", "a", "x", all, "script source"); + assert_eq!(replace(true).unwrap(), "x b x"); + assert_eq!( + replace(false).unwrap_err(), + "old_string matched 2 locations. Make it more specific or set replace_all to true." + ); + assert_eq!( + find_and_replace("a", "z", "x", false, "script source").unwrap_err(), + "old_string was not found in the script source." + ); + } + + // Expected texts are jsdiff's `createTwoFilesPatch` output for the same inputs. + #[test] + fn unified_patch_matches_jsdiff() { + let header = + "===================================================================\n--- fork-base\t\n+++ your-draft\t\n"; + let patch = |old, new, context| unified_patch("fork-base", "your-draft", old, new, context); + assert_eq!( + patch( + "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\n", + "a\nb\nC\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\nO\np\nq", + 4 + ), + format!("{header}@@ -1,7 +1,7 @@\n a\n b\n-c\n+C\n d\n e\n f\n g\n@@ -11,6 +11,7 @@\n k\n l\n m\n n\n-o\n+O\n p\n+q\n\\ No newline at end of file\n") + ); + assert_eq!( + patch("", "x\ny\n", 4), + format!("{header}@@ -0,0 +1,2 @@\n+x\n+y\n") + ); + assert_eq!( + patch("x\ny", "", 4), + format!("{header}@@ -1,2 +0,0 @@\n-x\n-y\n\\ No newline at end of file\n") + ); + assert_eq!(patch("same\n", "same\n", 4), header); + assert_eq!( + patch("a\nb\nc\n", "a\nX\nY\nc\n", 1), + format!("{header}@@ -1,3 +1,4 @@\n a\n-b\n+X\n+Y\n c\n") + ); + assert_eq!( + patch("a\nb", "a\nb\n", 4), + format!("{header}@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n") + ); + } + + #[test] + fn compact_flow_replaces_nested_inline_scripts() { + let raw = |id: &str| json!({ "id": id, "value": { "type": "rawscript", "content": id.to_uppercase(), "language": "bun" } }); + let value = json!({ + "modules": [ + raw("a"), + { "id": "loop", "value": { "type": "forloopflow", "modules": [raw("b")] } }, + { "id": "br", "value": { "type": "branchone", "branches": [{ "modules": [raw("c")] }], "default": [raw("d")] } }, + ], + "failure_module": raw("failure"), + "same_worker": true, + }); + let (compact, scripts) = compact_flow(&value, None); + assert_eq!(scripts.len(), 5); + assert_eq!(scripts["c"], "C"); + assert_eq!( + compact["modules"][2]["value"]["default"][0]["value"]["content"], + "inline_script.d" + ); + assert_eq!( + compact["failure_module"]["value"]["content"], + "inline_script.failure" + ); + let keys: Vec<&String> = compact.as_object().unwrap().keys().collect(); + assert_eq!( + keys, + [ + "modules", + "schema", + "preprocessor_module", + "failure_module", + "groups", + "notes", + "same_worker" + ] + ); + } + + #[test] + fn draft_item_drops_a_staged_path_equal_to_the_storage_path() { + let draft = json!({ "path": "u/a/s", "draft_path": "u/a/s", "summary": "s", "language": "bun", "content": "x", "parent_hash": "h" }); + let item = draft_item("script", "u/a/s", &draft).unwrap(); + assert_eq!( + item, + json!({ "type": "script", "path": "u/a/s", "summary": "s", "language": "bun", "value": "x", "parentHash": "h", "isDraft": true }) + ); + assert!(draft_item("app", "u/a/s", &draft).is_none()); + } +} diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 6940c76043..dcf84e1851 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -700,6 +700,47 @@ pub async fn create_http_request( workspace_id: &str, api_authed: &ApiAuthed, body_json: Option<Value>, +) -> BackendResult<reqwest::Response> { + // Scope the minted JWT to the proxied route so a scope-restricted MCP token + // can't be widened into a full-privilege blank check. See + // `jwt_scopes_for_proxied_route`. + let parsed = reqwest::Url::parse(url) + .map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?; + let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?; + send_as_caller(method, url, workspace_id, api_authed, body_json, scopes).await +} + +/// A JWT that acts as `api_authed`, carrying `scopes`; `None` is the caller's full rights. +pub(crate) async fn mint_caller_jwt( + workspace_id: &str, + api_authed: &ApiAuthed, + scopes: Option<Vec<String>>, +) -> BackendResult<String> { + // Carry the caller's job provenance into the JWT: a job's WM_TOKEN is capped at + // workspace admin (GHSA-hfh4-cx4h-3fcr), and dropping `job_id` here would re-mint an + // uncapped token that satisfies require_super_admin / require_devops_role. + create_jwt_token( + Authed::from(api_authed.clone()), + workspace_id, + 3600, + api_authed.job_id, + None, + None, + scopes, + ) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) +} + +/// Call the API as `api_authed` with a JWT carrying `scopes`; `None` is the caller's +/// full rights, for a server whose own code picks every route it calls. +pub async fn send_as_caller( + method: &str, + url: &str, + workspace_id: &str, + api_authed: &ApiAuthed, + body_json: Option<Value>, + scopes: Option<Vec<String>>, ) -> BackendResult<reqwest::Response> { let client = &HTTP_CLIENT; let mut request_builder = match method { @@ -716,29 +757,7 @@ pub async fn create_http_request( } }; - // Scope the minted JWT to the proxied route so a scope-restricted MCP token - // can't be widened into a full-privilege blank check. See - // `jwt_scopes_for_proxied_route`. - let parsed = reqwest::Url::parse(url) - .map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?; - let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?; - - // Add authorization header. Carry the caller's job provenance into the proxy - // JWT: a job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), and - // dropping `job_id` here would re-mint an uncapped token that satisfies - // require_super_admin / require_devops_role on the proxied route. - let authed = Authed::from(api_authed.clone()); - let token = create_jwt_token( - authed, - workspace_id, - 3600, - api_authed.job_id, - None, - None, - scopes, - ) - .await - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let token = mint_caller_jwt(workspace_id, api_authed, scopes).await?; request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); // Add body if present diff --git a/backend/windmill-mcp/src/common/scope.rs b/backend/windmill-mcp/src/common/scope.rs index 418458d4fe..42327215ae 100644 --- a/backend/windmill-mcp/src/common/scope.rs +++ b/backend/windmill-mcp/src/common/scope.rs @@ -20,9 +20,26 @@ pub struct McpScopeConfig { pub granular: bool, /// Hub app filter (if any) pub hub_apps: Option<String>, + /// The workspaces a token without a bound workspace may act in + /// (`mcp:workspaces:a,b`); `None` is every workspace its owner belongs to. + pub workspaces: Option<Vec<String>>, + /// `mcp:read_run`: reads, plus running deployed scripts and flows, and nothing else. + pub read_run: bool, + /// `mcp:read_only`: reads alone, as the token's read-only flag does. An OAuth grant + /// carries scopes and no flag, so this is how a sign-in asks for read-only access. + pub read_only: bool, + /// No endpoint tools on any URL: set by `mcp:read_only`, `mcp:read_run` and + /// `mcp:runnables_as_tools`, and by the server on `/api/mcp` for a selection. + pub no_endpoints: bool, } impl McpScopeConfig { + pub fn allows_workspace(&self, workspace_id: &str) -> bool { + self.workspaces + .as_ref() + .is_none_or(|ws| ws.iter().any(|w| w == workspace_id)) + } + /// Whether the token grants access to *any* concrete resource of this type by /// path. Used to decide whether to advertise the run-by-path tools in /// multi-workspace mode (a `mcp:scripts:*`-only token should see @@ -68,6 +85,21 @@ impl McpScopeConfig { /// semantics), this requires every requested pattern to be covered by some /// caller pattern — so `mcp:scripts:f/x` cannot widen into `mcp:scripts:*`. pub fn contains(&self, requested: &McpScopeConfig) -> bool { + if let Some(caller_ws) = self.workspaces.as_ref() { + match requested.workspaces.as_ref() { + Some(req_ws) if req_ws.iter().all(|w| caller_ws.contains(w)) => {} + _ => return false, + } + } + if self.read_only && !requested.read_only { + return false; + } + if self.read_run && !(requested.read_run || requested.read_only) { + return false; + } + if self.no_endpoints && !requested.no_endpoints { + return false; + } if self.all { return true; } @@ -125,6 +157,10 @@ fn pattern_covers(caller: &str, requested: &str) -> bool { && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) } +/// Marks a selection made as "scripts and flows as tools": it gets no endpoint tools on any +/// URL, which an older selection, carrying none of the newer scopes, still does. +pub const RUNNABLES_AS_TOOLS_SCOPE: &str = "mcp:runnables_as_tools"; + /// Parse MCP scopes from token scope strings pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> { let mut config = McpScopeConfig::default(); @@ -180,6 +216,31 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> { continue; } + if scope == "mcp:read_run" { + config.read_run = true; + config.no_endpoints = true; + continue; + } + + if scope == "mcp:read_only" { + config.read_only = true; + config.no_endpoints = true; + continue; + } + + if scope == RUNNABLES_AS_TOOLS_SCOPE { + config.no_endpoints = true; + continue; + } + + if let Some(ids) = scope.strip_prefix("mcp:workspaces:") { + config + .workspaces + .get_or_insert_with(Vec::new) + .extend(parse_resource_list(ids)); + continue; + } + if let Some(resources) = scope.strip_prefix("mcp:endpoints:") { // New granular endpoint scope: mcp:endpoints:name1,name2 config.endpoints.extend(parse_resource_list(resources)); @@ -409,4 +470,38 @@ mod tests { // mcp:all grants all endpoints. assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:endpoints:getResource"]))); } + + #[test] + fn test_workspaces_scope() { + let two = cfg(&["mcp:all", "mcp:workspaces:dev,staging"]); + assert!(two.allows_workspace("dev") && !two.allows_workspace("prod")); + assert!(cfg(&["mcp:all"]).allows_workspace("prod")); + // A workspace list only narrows: it cannot be dropped or widened by a grant. + assert!(two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev"]))); + assert!(!two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev,prod"]))); + assert!(!two.contains(&cfg(&["mcp:all"]))); + assert!(cfg(&["mcp:all"]).contains(&two)); + } + + #[test] + fn test_read_run_scope_only_narrows() { + let read_run = cfg(&["mcp:all", "mcp:read_run"]); + assert!(read_run.read_run && read_run.all); + assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_run"]))); + assert!(!read_run.contains(&cfg(&["mcp:all"]))); + assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_only"]))); + let read_only = cfg(&["mcp:all", "mcp:read_only"]); + assert!(!read_only.contains(&cfg(&["mcp:all", "mcp:read_run"]))); + } + + #[test] + fn test_runnables_as_tools_scope_only_narrows() { + let fenced = cfg(&["mcp:favorites", "mcp:runnables_as_tools"]); + assert!(fenced.no_endpoints); + assert!(cfg(&["mcp:all", "mcp:read_only"]).no_endpoints); + assert!(cfg(&["mcp:all", "mcp:read_run"]).no_endpoints); + assert!(fenced.contains(&cfg(&["mcp:favorites", "mcp:runnables_as_tools"]))); + assert!(!fenced.contains(&cfg(&["mcp:favorites"]))); + assert!(cfg(&["mcp:all"]).contains(&fenced)); + } } diff --git a/backend/windmill-mcp/src/common/types.rs b/backend/windmill-mcp/src/common/types.rs index 273a952cbb..7d91e94255 100644 --- a/backend/windmill-mcp/src/common/types.rs +++ b/backend/windmill-mcp/src/common/types.rs @@ -22,6 +22,11 @@ pub struct WorkspaceId(pub String); #[derive(Clone, Debug)] pub struct MultiWorkspaceMcp; +/// Marker extension: the tools server lists a token's scripts and flows and no endpoint +/// tools. Set on `/api/mcp`, where the full toolset is what reaches the API. +#[derive(Clone, Debug)] +pub struct NoEndpointTools; + /// Raw bearer token wrapper for Axum extensions. In multi-workspace mode the /// runner needs the raw token to re-resolve auth for each requested workspace. #[derive(Clone, Debug)] diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 6fa560e377..dc3a23bb51 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -4,6 +4,7 @@ //! (typically windmill-api) to provide the actual functionality for the MCP server. use async_trait::async_trait; +use rmcp::model::{CallToolResult, Tool}; use rmcp::ErrorData; use serde_json::Value; use std::collections::HashMap; @@ -16,6 +17,15 @@ use crate::server::endpoints::EndpointTool; /// Result type for backend operations using rmcp's ErrorData directly pub type BackendResult<T> = Result<T, ErrorData>; +/// What a v2 token may do: read, read and run deployed scripts and flows (`mcp:read_run`), +/// or everything its owner can. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionAccess { + ReadOnly, + ReadAndRun, + Full, +} + /// What the backend needs about the HTTP request a tool call arrived on, in order /// to hand a runnable the headers of the call that triggered it. pub struct McpRequest<'a> { @@ -234,4 +244,35 @@ pub trait McpBackend: Send + Sync + Clone + 'static { /// Get all available endpoint tools fn all_endpoint_tools(&self) -> Vec<EndpointTool>; + + // ───────────────────────────────────────────────────────────────── + // Workspace server (`SessionRunner`): the AI-session toolset + // ───────────────────────────────────────────────────────────────── + + /// The instructions a client reaching one workspace receives on connecting, built for + /// this user. + async fn session_instructions( + &self, + auth: &Self::Auth, + workspace_id: &str, + ) -> BackendResult<String>; + + /// The instructions for a client reaching several workspaces: what holds in all of them. + fn generic_session_instructions(&self) -> String; + + /// What `generic_session_instructions` leaves out, for one workspace. + async fn workspace_context(&self, auth: &Self::Auth, workspace_id: &str) -> String; + + /// The tools a token with this access is offered. + fn session_tools(&self, access: SessionAccess) -> Vec<Tool>; + + async fn call_session_tool( + &self, + auth: &Self::Auth, + workspace_id: &str, + name: &str, + args: Value, + access: SessionAccess, + origin: Option<&str>, + ) -> BackendResult<CallToolResult>; } diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 5b08d211a5..7418e23206 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -113,9 +113,27 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool { return mcp_tool; } + // Surface the requirement in the prose description too (the schema is + // authoritative, but some models/clients lean on the text). Kept terse — this + // repeats across every workspace-scoped tool in the list. + if let Some(desc) = mcp_tool.description.take() { + mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into()); + } else { + mcp_tool.description = Some("Requires `workspace_id`.".into()); + } + with_workspace_id_arg(mcp_tool) +} + +/// The tool with a required `workspace_id` argument, which the caller strips before +/// dispatching. +pub fn with_workspace_id_arg(mut mcp_tool: Tool) -> Tool { let mut schema = (*mcp_tool.input_schema).clone(); - if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) { + if let Some(props) = schema + .entry("properties") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + { props.insert( "workspace_id".to_string(), serde_json::json!({ @@ -136,15 +154,6 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool { } } - // Surface the requirement in the prose description too (the schema is - // authoritative, but some models/clients lean on the text). Kept terse — this - // repeats across every workspace-scoped tool in the list. - if let Some(desc) = mcp_tool.description.take() { - mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into()); - } else { - mcp_tool.description = Some("Requires `workspace_id`.".into()); - } - mcp_tool.input_schema = Arc::new(schema); mcp_tool } diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index ad7c14d320..545812a411 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -9,16 +9,18 @@ pub mod backend; pub mod endpoints; mod list_changes; pub mod runner; +mod session; pub mod tools; // Re-export main types -pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo}; -pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter}; +pub use crate::common::types::{McpToken, MultiWorkspaceMcp, NoEndpointTools, WorkspaceInfo}; +pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter, SessionAccess}; pub use endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only, - list_workspaces_tool, non_empty_body_fields, EndpointTool, + list_workspaces_tool, non_empty_body_fields, with_workspace_id_arg, EndpointTool, }; -pub use runner::{has_endpoint_path_policy, Runner}; +pub use runner::{has_endpoint_path_policy, Runner, MAX_TOOL_RESULT_BYTES}; +pub use session::SessionRunner; pub use tools::create_tool_from_item; // Re-export rmcp types for convenience @@ -26,8 +28,8 @@ pub use rmcp::handler::server::ServerHandler; pub use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, - ToolAnnotations, + ListToolsResult, MetaObject, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, + ServerInfo, Tool, ToolAnnotations, }; pub use rmcp::service::{RequestContext, RoleServer}; pub use rmcp::transport::streamable_http_server::{ diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 2eab72469e..e2ce8b6c04 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -11,7 +11,8 @@ use crate::common::transform::{ short_name_last_segments, }; use crate::common::types::{ - McpToken, MultiWorkspaceMcp, ResourceInfo, SchemaType, ToolableItem, WorkspaceId, + McpToken, MultiWorkspaceMcp, NoEndpointTools, ResourceInfo, SchemaType, ToolableItem, + WorkspaceId, }; use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ @@ -37,7 +38,7 @@ use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the /// `initialize` handshake, so both eras are answered on the same endpoint. -const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ +pub(super) const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ ProtocolVersion::V_2024_11_05, ProtocolVersion::V_2025_03_26, ProtocolVersion::V_2025_06_18, @@ -53,12 +54,12 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// covers only some of what the listing depends on (not resources, favorites or /// permissions), lags by up to a poll interval, and reaches only clients that hold a /// `subscriptions/listen` stream. -const LIST_TTL_MS: u64 = 0; +pub(super) const LIST_TTL_MS: u64 = 0; /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. -const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +pub(super) const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; /// How long a `subscriptions/listen` stream waits after a change before notifying, /// collecting the rest of the burst into the same notification. @@ -70,9 +71,9 @@ use http::request::Parts as HttpParts; /// Maximum size in bytes for tool result text content. /// Anthropic enforces a 25,000 token limit per tool result. /// At ~3.5 bytes/token, 87,500 bytes is a safe upper bound. -const MAX_TOOL_RESULT_BYTES: usize = 87_500; +pub const MAX_TOOL_RESULT_BYTES: usize = 87_500; -fn truncate_tool_result(text: String) -> String { +pub(super) fn truncate_tool_result(text: String) -> String { if text.len() <= MAX_TOOL_RESULT_BYTES { return text; } @@ -103,7 +104,7 @@ impl<B: McpBackend> Clone for Runner<B> { /// Whether the request targets one bound workspace or spans every workspace the /// token can access. -enum McpMode { +pub(super) enum McpMode { /// A single workspace, resolved from the URL path or the token's bound /// workspace. Tools operate against this workspace implicitly. Single(String), @@ -114,10 +115,12 @@ enum McpMode { } /// Everything a request carries besides its MCP payload. -struct McpContext<A> { - auth: A, - mode: McpMode, - headers: http::HeaderMap, +pub(super) struct McpContext<A> { + pub(super) auth: A, + pub(super) mode: McpMode, + pub(super) headers: http::HeaderMap, + /// See `McpScopeConfig::no_endpoints`. + pub(super) no_endpoints: bool, } impl<B: McpBackend> Runner<B> { @@ -128,7 +131,7 @@ impl<B: McpBackend> Runner<B> { /// Extract authentication, the workspace mode and the HTTP request itself /// from the request context - fn extract_context( + pub(super) fn extract_context( context: &RequestContext<RoleServer>, ) -> Result<McpContext<B::Auth>, ErrorData> { let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| { @@ -165,13 +168,31 @@ impl<B: McpBackend> Runner<B> { ErrorData::internal_error("WorkspaceId not found", None) }) .map(|w_id| w_id.0.clone())?; + // A token without a bound workspace reaches a workspace's own URL too. + let scope_config = parse_mcp_scopes(auth.scopes().unwrap_or(&[])) + .map_err(|e| ErrorData::internal_error(e, None))?; + if !scope_config.allows_workspace(&workspace_id) { + return Err(workspace_not_granted(&workspace_id)); + } McpMode::Single(workspace_id) }; - Ok(McpContext { auth: auth.clone(), mode, headers: http_parts.headers.clone() }) + Ok(McpContext { + auth: auth.clone(), + mode, + headers: http_parts.headers.clone(), + no_endpoints: http_parts.extensions.get::<NoEndpointTools>().is_some(), + }) } } +pub(super) fn workspace_not_granted(workspace_id: &str) -> ErrorData { + ErrorData::invalid_params( + format!("This token was not granted workspace '{workspace_id}'"), + None, + ) +} + /// How an endpoint tool interacts with the token's `mcp:scripts:` / `mcp:flows:` /// path scopes. Mirrored in /// `frontend/src/lib/components/mcp/endpointScopePolicy.ts` so the scope picker @@ -301,6 +322,9 @@ fn endpoint_tool_in_scope( scope_config: &crate::common::scope::McpScopeConfig, endpoint_tool: &EndpointTool, ) -> bool { + if scope_config.no_endpoints { + return false; + } let endpoint_allowed = !scope_config.granular || scope_config.is_allowed("endpoint", &endpoint_tool.name); match endpoint_path_policy(&endpoint_tool.name) { @@ -327,6 +351,15 @@ fn authorize_endpoint_call( args: &mut Value, read_only: bool, ) -> Result<(), ErrorData> { + if scope_config.no_endpoints { + return Err(ErrorData::invalid_params( + format!( + "Tool '{}' is not available to this token", + endpoint_tool.name + ), + None, + )); + } // A destination the caller omitted, or left empty because it had to fill in every // argument, means "leave the item where it is". Resolved to the path the item is // already at, so the endpoint receives a body naming its own path rather than one @@ -558,14 +591,16 @@ impl<B: McpBackend> ServerHandler for Runner<B> { _request: Option<PaginatedRequestParams>, context: RequestContext<RoleServer>, ) -> Result<ListToolsResult, ErrorData> { - let McpContext { auth, mode, .. } = Self::extract_context(&context)?; + let McpContext { auth, mode, no_endpoints, .. } = Self::extract_context(&context)?; // Parse MCP scopes to determine what to expose let scopes = auth.scopes().unwrap_or(&[]); - let scope_config = + let mut scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + scope_config.no_endpoints |= no_endpoints; - let read_only = auth.read_only(); + // v1 has no read-and-run access: such a token reads only. + let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only; match mode { McpMode::Single(workspace_id) => { @@ -586,13 +621,14 @@ impl<B: McpBackend> ServerHandler for Runner<B> { request: CallToolRequestParams, context: RequestContext<RoleServer>, ) -> Result<CallToolResponse, ErrorData> { - let McpContext { auth, mode, headers } = Self::extract_context(&context)?; + let McpContext { auth, mode, headers, no_endpoints } = Self::extract_context(&context)?; // Parse MCP scopes for authorization let scopes = auth.scopes().unwrap_or(&[]); - let scope_config = + let mut scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; - let read_only = auth.read_only(); + scope_config.no_endpoints |= no_endpoints; + let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only; let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); let mcp_request = McpRequest { headers: &headers, tool_name: request.name.as_ref() }; @@ -1088,11 +1124,12 @@ impl<B: McpBackend> Runner<B> { mut args: Value, ) -> Result<CallToolResult, ErrorData> { if name.as_ref() == "list_workspaces" { - let workspaces = self + let mut workspaces = self .backend .list_accessible_workspaces(base_auth) .await .map_err(|e| ErrorData::internal_error(e.message, None))?; + workspaces.retain(|w| scope_config.allows_workspace(&w.id)); return Ok(CallToolResult::success(vec![ContentBlock::text( serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()), )])); @@ -1140,6 +1177,9 @@ impl<B: McpBackend> Runner<B> { ) })? .to_string(); + if !scope_config.allows_workspace(&workspace_id) { + return Err(workspace_not_granted(&workspace_id)); + } let resolved = self .backend @@ -1281,6 +1321,17 @@ mod tests { ); } + #[test] + fn a_selection_without_endpoints_gets_none() { + // The scope withholds them on every URL, as the server does on `/api/mcp`. + let selection = cfg(&["mcp:favorites", "mcp:endpoints:*", "mcp:runnables_as_tools"]); + assert!(!endpoint_tool_in_scope(&selection, &ep("listJobs", "GET"))); + assert!( + authorize_endpoint_call(&selection, &ep("listJobs", "GET"), &mut json!({}), false) + .is_err() + ); + } + #[test] fn read_only_refuses_non_get_endpoint() { // Reaches the read-only check via mcp:all so scope isn't the blocker. diff --git a/backend/windmill-mcp/src/server/session.rs b/backend/windmill-mcp/src/server/session.rs new file mode 100644 index 0000000000..1130a01e84 --- /dev/null +++ b/backend/windmill-mcp/src/server/session.rs @@ -0,0 +1,338 @@ +//! The Windmill MCP server's toolset: an AI session's tools and guidance for an external +//! client. `/api/mcp` serves it to tokens with `mcp:all`, and the tools server (`Runner`) +//! to tokens scoped to a selection of scripts and flows. +//! +//! Every tool takes the workspace it acts in, whether the token reaches one workspace or +//! several, so a client's tool schemas do not change with the token it signed in with. + +use crate::common::scope::{parse_mcp_scopes, McpScopeConfig}; +use crate::common::types::WorkspaceInfo; +use crate::server::backend::{McpAuth, McpBackend, SessionAccess}; +use crate::server::endpoints::{list_workspaces_tool, with_workspace_id_arg}; +use crate::server::runner::{ + truncate_tool_result, workspace_not_granted, McpContext, McpMode, Runner, LIST_CACHE_SCOPE, + LIST_TTL_MS, SUPPORTED_PROTOCOL_VERSIONS, +}; +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, DiscoverResult, + Implementation, InitializeRequestParams, InitializeResult, ListToolsResult, + PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::ErrorData; +use serde_json::Value; +use std::borrow::Cow; +use std::sync::Arc; + +/// `mcp:all`, and no scope from outside `mcp:` that confines the token to some routes +/// or paths (`variables:read:f/x/*`): the tools call the API without a route scope, so +/// that confinement would be lost here. The job tag fence is carried through instead. +fn admits(scopes: &[String], all: bool) -> bool { + all && scopes + .iter() + .all(|s| s.starts_with("mcp:") || s.starts_with("if_jobs:filter_tags:")) +} + +fn access<A: McpAuth>(auth: &A, scope: &McpScopeConfig) -> SessionAccess { + if auth.read_only() || scope.read_only { + SessionAccess::ReadOnly + } else if scope.read_run { + SessionAccess::ReadAndRun + } else { + SessionAccess::Full + } +} + +const GET_WORKSPACE_CONTEXT: &str = "get_workspace_context"; + +/// Workspaces named in the instructions before the rest are left to `list_workspaces`. +const MAX_LISTED_WORKSPACES: usize = 40; + +fn get_workspace_context_tool() -> Tool { + let schema = serde_json::json!({ "type": "object", "properties": {} }); + with_workspace_id_arg( + Tool::new( + Cow::Borrowed(GET_WORKSPACE_CONTEXT), + "Your username in a workspace, the folders you can write to there, and the \ + workspace's skills and instructions. Call it before your first action in a \ + workspace.", + Arc::new(schema.as_object().unwrap().clone()), + ) + .with_annotations(ToolAnnotations::new().read_only(true)), + ) +} + +/// A request's caller, and the workspaces its token reaches. +struct Caller<A> { + auth: A, + mode: McpMode, + scope: McpScopeConfig, + /// The scheme and host the client reached, for links to Windmill pages. + origin: Option<String>, +} + +/// `x-forwarded-*` first, since a proxy in front rewrites the host the request names. +fn request_origin(headers: &http::HeaderMap) -> Option<String> { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|v| !v.is_empty()) + }; + let host = header("x-forwarded-host").or_else(|| header("host"))?; + let scheme = header("x-forwarded-proto").unwrap_or("http"); + Some(format!("{scheme}://{host}")) +} + +pub struct SessionRunner<B: McpBackend> { + backend: Arc<B>, +} + +impl<B: McpBackend> Clone for SessionRunner<B> { + fn clone(&self) -> Self { + Self { backend: self.backend.clone() } + } +} + +impl<B: McpBackend> SessionRunner<B> { + pub fn new(backend: B) -> Self { + Self { backend: Arc::new(backend) } + } + + /// This server writes drafts and deploys, so it takes the scope that already reaches + /// the create/update endpoint tools and nothing narrower: a token scoped to a few + /// scripts was never consented for authoring. + fn caller(context: &RequestContext<RoleServer>) -> Result<Caller<B::Auth>, ErrorData> { + let McpContext { auth, mode, headers, .. } = Runner::<B>::extract_context(context)?; + let scope = parse_mcp_scopes(auth.scopes().unwrap_or(&[])) + .map_err(|e| ErrorData::internal_error(e, None))?; + if !admits(auth.scopes().unwrap_or(&[]), scope.all) { + return Err(ErrorData::invalid_request( + "This server needs an `mcp:all` token with no other route or path scope", + None, + )); + } + Ok(Caller { auth, mode, scope, origin: request_origin(&headers) }) + } + + async fn workspaces(&self, caller: &Caller<B::Auth>) -> Result<Vec<WorkspaceInfo>, ErrorData> { + let mut workspaces = self + .backend + .list_accessible_workspaces(&caller.auth) + .await?; + match &caller.mode { + McpMode::Single(bound) => workspaces.retain(|w| &w.id == bound), + McpMode::Multi(_) => workspaces.retain(|w| caller.scope.allows_workspace(&w.id)), + } + Ok(workspaces) + } + + /// The caller as they are in `workspace_id`, if the token reaches it. + async fn auth_in( + &self, + caller: &Caller<B::Auth>, + workspace_id: &str, + ) -> Result<B::Auth, ErrorData> { + match &caller.mode { + McpMode::Single(bound) if bound == workspace_id => Ok(caller.auth.clone()), + McpMode::Multi(token) if caller.scope.allows_workspace(workspace_id) => { + self.backend + .resolve_workspace_auth(token, workspace_id) + .await + } + _ => Err(workspace_not_granted(workspace_id)), + } + } + + async fn info_for( + &self, + context: &RequestContext<RoleServer>, + ) -> Result<ServerInfo, ErrorData> { + let caller = Self::caller(context)?; + let workspaces = self.workspaces(&caller).await?; + let instructions = match workspaces.as_slice() { + [only] => { + let auth = self.auth_in(&caller, &only.id).await?; + format!( + "Every tool takes a `workspace_id`: pass `{}`, the workspace this connection \ + works in.\n\n{}", + only.id, + self.backend.session_instructions(&auth, &only.id).await? + ) + } + _ => { + let mut listed = workspaces + .iter() + .take(MAX_LISTED_WORKSPACES) + .map(|w| format!("`{}` ({})", w.id, w.name)) + .collect::<Vec<_>>() + .join(", "); + if workspaces.len() > MAX_LISTED_WORKSPACES { + listed.push_str(&format!( + " (+{} more)", + workspaces.len() - MAX_LISTED_WORKSPACES + )); + } + format!( + "This connection works across several Windmill workspaces: {listed}. Every \ + tool takes a `workspace_id` naming the one it acts in; list_workspaces lists \ + them. Before your first action in a workspace, call get_workspace_context \ + with its id: it gives your username there (the `<username>` in the paths \ + below), the folders you can write to, and the workspace's skills and \ + instructions. When the user has not said which workspace, ask rather than \ + guess, and name the workspace whenever you change something.\n\n{}", + self.backend.generic_session_instructions() + ) + } + }; + Ok(self.get_info().with_instructions(instructions)) + } +} + +impl<B: McpBackend> ServerHandler for SessionRunner<B> { + /// Without instructions: those are built per caller, in `initialize` and `discover`. + fn get_info(&self) -> ServerInfo { + let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION")) + .with_title("Windmill") + .with_website_url("https://windmill.dev"); + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) + } + + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext<RoleServer>, + ) -> Result<InitializeResult, ErrorData> { + context.peer.set_peer_info(request.clone()); + let mut info = self.info_for(&context).await?; + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + async fn discover( + &self, + context: RequestContext<RoleServer>, + ) -> Result<DiscoverResult, ErrorData> { + Ok(DiscoverResult::from_server_info( + SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + self.info_for(&context).await?, + )) + } + + async fn list_tools( + &self, + _request: Option<PaginatedRequestParams>, + context: RequestContext<RoleServer>, + ) -> Result<ListToolsResult, ErrorData> { + let caller = Self::caller(&context)?; + let mut tools = vec![list_workspaces_tool()]; + // A bound token's one workspace is described in the instructions already. + if let McpMode::Multi(_) = caller.mode { + tools.push(get_workspace_context_tool()); + } + tools.extend( + self.backend + .session_tools(access(&caller.auth, &caller.scope)) + .into_iter() + .map(with_workspace_id_arg), + ); + // Without these a `2026-07-28` client rejects the whole listing. + Ok(ListToolsResult::with_all_items(tools) + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext<RoleServer>, + ) -> Result<CallToolResponse, ErrorData> { + let caller = Self::caller(&context)?; + if request.name == "list_workspaces" { + let workspaces = self.workspaces(&caller).await?; + return Ok(CallToolResult::success(vec![ContentBlock::text( + serde_json::to_string_pretty(&workspaces).unwrap_or_default(), + )]) + .into()); + } + let mut args = request.arguments.unwrap_or_default(); + let workspace_id = + match args.remove("workspace_id") { + Some(Value::String(w)) if !w.is_empty() => w, + _ => return Err(ErrorData::invalid_params( + "Missing required `workspace_id` argument. Call list_workspaces to see the \ + workspaces you can act in.", + None, + )), + }; + let auth = self.auth_in(&caller, &workspace_id).await?; + if request.name == GET_WORKSPACE_CONTEXT { + let text = self.backend.workspace_context(&auth, &workspace_id).await; + return Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()); + } + let mut result = self + .backend + .call_session_tool( + &auth, + &workspace_id, + request.name.as_ref(), + Value::Object(args), + access(&auth, &caller.scope), + caller.origin.as_deref(), + ) + .await?; + for block in result.content.iter_mut() { + if let Some(text) = block.as_text() { + *block = ContentBlock::text(truncate_tool_result(text.text.clone())); + } + } + Ok(result.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn origin_prefers_forwarded_headers() { + let mut headers = http::HeaderMap::new(); + headers.insert("host", "backend:8000".parse().unwrap()); + assert_eq!( + request_origin(&headers).as_deref(), + Some("http://backend:8000") + ); + headers.insert("x-forwarded-host", "wm.example.com, proxy".parse().unwrap()); + headers.insert("x-forwarded-proto", "https".parse().unwrap()); + assert_eq!( + request_origin(&headers).as_deref(), + Some("https://wm.example.com") + ); + } + + #[test] + fn only_an_unnarrowed_mcp_all_token_is_admitted() { + let admitted = |scopes: &[&str]| { + let scopes: Vec<String> = scopes.iter().map(|s| s.to_string()).collect(); + let all = parse_mcp_scopes(&scopes).is_ok_and(|c| c.all); + admits(&scopes, all) + }; + assert!(admitted(&["mcp:all"])); + assert!(admitted(&["mcp:all", "if_jobs:filter_tags:gpu"])); + assert!(admitted(&["mcp:all", "mcp:workspaces:dev"])); + assert!(!admitted(&["mcp:all", "variables:read:u/a/safe/*"])); + assert!(!admitted(&["mcp:all:f/x/*"])); + assert!(!admitted(&["mcp:favorites"])); + assert!(!admitted(&[])); + } +} diff --git a/frontend/package.json b/frontend/package.json index c58d484ec2..6ba7e5c280 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "package": "svelte-package -o package && node scripts/package-system-prompts.js && node scripts/fix-svelte-module-imports.js", "generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false", "generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript", + "gen:mcp-session": "WM_GEN_MCP_SESSION=1 vitest run --project server src/lib/components/copilot/chat/global/mcpSessionGen.test.ts", "pretest": "tsc --incremental -p tests/tsconfig.json", "filter-classes": "node filterTailwindClasses.js", "test:unit": "vitest", diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 10c7f62569..d76136c728 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -350,9 +350,10 @@ } else { goto(resolvedRd ?? '/') } - // See (root)/+layout.svelte for why /projects/import skips the picker. + // See (root)/+layout.svelte for why these skip the picker. } else if ( resolvedRd?.startsWith('/user/workspaces') || + resolvedRd?.startsWith('/oauth/mcp_authorize') || resolvedRd?.startsWith('/projects/import') ) { goto(resolvedRd) diff --git a/frontend/src/lib/components/UserSettings.svelte b/frontend/src/lib/components/UserSettings.svelte index d5811e737b..306ce3505e 100644 --- a/frontend/src/lib/components/UserSettings.svelte +++ b/frontend/src/lib/components/UserSettings.svelte @@ -6,7 +6,8 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import TokensTable from './settings/TokensTable.svelte' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, tick } from 'svelte' + import CreateToken from './settings/CreateToken.svelte' import UserInfoSettings from './settings/UserInfoSettings.svelte' import AIUserSettings from './settings/AIUserSettings.svelte' import AiUsagePanel from './workspaceSettings/AiUsagePanel.svelte' @@ -36,14 +37,18 @@ }: Props = $props() let drawer: Drawer | undefined = $state() - let openWithMcpMode = $state(false) + let mcpSection: HTMLDivElement | undefined = $state() + let tokensTable: TokensTable | undefined = $state() let darkVariant = $state<DarkModeVariant>(getDarkModeVariant()) const dispatch = createEventDispatcher() - export function openDrawer(mcpMode: boolean = false) { - openWithMcpMode = mcpMode + export async function openDrawer(mcpMode: boolean = false) { drawer?.openDrawer() + if (mcpMode) { + await tick() + mcpSection?.scrollIntoView({ block: 'start' }) + } } export function closeDrawer() { @@ -120,10 +125,22 @@ {/if} {/if} + {#if showMcpMode} + <div bind:this={mcpSection} class="flex flex-col p-4 border border-border-light rounded-md"> + <h2 class="text-emphasis text-sm font-semibold mb-1"> + Build and run from your AI client + </h2> + <div class="text-xs text-secondary mb-2"> + Connect Claude, ChatGPT or Cursor to Windmill over MCP: build scripts, flows and apps, + run your automations and check their runs, from your chat. + </div> + <CreateToken mcpOnly onTokenCreated={() => tokensTable?.listTokens()} /> + </div> + {/if} + <div class="grow min-h-0"> <TokensTable - {showMcpMode} - {openWithMcpMode} + bind:this={tokensTable} defaultNewTokenLabel={newTokenLabel} defaultNewTokenWorkspace={newTokenWorkspace} {scopes} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index ea5bdfdc14..c832fbfc1f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -329,7 +329,10 @@ const INSTRUCTION_SUBJECTS = [ // annotated scripts in a folder, so it gets authoring guidance, not a CRUD type. // `workspace_settings` is the settings document an admin reads and changes. const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline', 'workspace_settings'] as const -const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const +export const ALL_INSTRUCTION_SUBJECTS = [ + ...INSTRUCTION_SUBJECTS, + ...INSTRUCTION_SUBJECTS_EXTRA +] as const const MAX_LIST_LIMIT = 100 type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'> type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0] @@ -1428,7 +1431,8 @@ type FolderPromptContext = { function buildFolderGuidance( username: string, ctx: FolderPromptContext | undefined, - canCreateFolder: boolean + canCreateFolder: boolean, + external = false ): string { if (!ctx) return '' const MAX = 40 @@ -1456,7 +1460,7 @@ function buildFolderGuidance( const lines: string[] = [] if (writable.length > 0) { lines.push( - `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` + `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use${external ? '' : ' (askUserQuestion)'} rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` ) } else { lines.push( @@ -1484,7 +1488,10 @@ const buildGlobalSystemPrompt = ( folderCtx?: FolderPromptContext, skills: AiSkillListItem[] = [], mcpServers: McpServer[] = [], - access?: SessionAccess + access?: SessionAccess, + // The prompt as an external MCP client reads it: the same rules, minus the tools and + // surfaces only the Windmill UI has (pages, the run form, questions, the open editor). + external = false ) => { // Each `can*` mirrors the capability the matching tools declare in `requires`, so a // rule cannot outlive the tool it describes. An unresolved profile keeps every block. @@ -1555,7 +1562,7 @@ const buildGlobalSystemPrompt = ( // Each gated block carries its own leading newline, so dropping one leaves no blank // line behind and a full-access prompt is byte-for-byte the ungated text. const when = (cond: boolean, block: string) => (cond ? block : '') - const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder) + const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder, external) const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : '' // A grant to `g/<group>` reaches the user only through membership, which no permission // list shows. Outside the folder guidance, which a role that cannot draft never gets. @@ -1599,8 +1606,45 @@ const buildGlobalSystemPrompt = ( get(enterpriseLicense) ? 'Enterprise Edition' : 'Community Edition' }. Use that to judge whether a feature is available before promising it.` : '' + // The core rules, placed differently for an MCP client (see `coreRules`). + const draftsOnlyRule = when( + canWriteAnyDraft, + ` +- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` + ) + const findItemsRule = + '- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.' + const deployRule = when( + canWriteAnyDraft && canDeployAnyKind, + ` +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.` + ) + const instructionsRule = + '- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.' + const testDraftRule = `- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.` + const testRunList = canRunFlowPreview + ? 'test_run_script, test_run_flow, test_run_step' + : 'test_run_script, test_run_step' + const runRule = `${when( + canRunPreview, + ` +- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${ + external + ? `${testRunList}, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.` + : `${testRunList}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` + }` + )}${when( + !canRunPreview, + ` +- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` + )}` - return `You are Windmill's global workspace assistant. + const intro = `${external ? 'You are working in a Windmill workspace through its MCP server.' : "You are Windmill's global workspace assistant."}${when( + external, + ` + +The user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call "the preview"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.` + )} The current user's workspace username is "${username}".${instanceLine} @@ -1632,7 +1676,8 @@ ${ : '' }.` )}` -}${when( +}` + const paths = `${when( canWriteAnyDraft, ` @@ -1648,24 +1693,27 @@ Path conventions: )}${when( canCreateFolder && !canWriteAnyDraft, '\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.' - )} + )}` + // An MCP client may keep only the first 4096 characters of its instructions, so there + // these rules come before the path conventions and are left out of the later list. + const coreRules = `${draftsOnlyRule} +${findItemsRule}${deployRule}${when(canWriteDraft, `\n${instructionsRule}`)}${when( + canRunPreview && canWriteDraft, + `\n${testDraftRule}` + )}${runRule}` -Rules:${when( - canWriteAnyDraft, - ` -- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` - )} -- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. + return `${intro}${when(external, `\n\nRules:${coreRules}`)}${paths} + +${external ? 'More rules:' : `Rules:${draftsOnlyRule}\n${findItemsRule}`} ${groupMembershipRule}- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed. - An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when( canDraftResources, " You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there." - )} -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( - canWriteAnyDraft && canDeployAnyKind, - ` -- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.` )}${when( + !external, + ` +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".` + )}${activePreviewRule}${when(!external, deployRule)}${when( canWriteAnyDraft, ` - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft).${when( @@ -1703,12 +1751,10 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i - Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. - Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API. - If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself. -${when(canCreateResourceType, '- create_resource_type creates a resource type the workspace lacks. The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.\n')}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -${pipelineBullet}` +${when(canCreateResourceType, `- create_resource_type creates a resource type the workspace lacks. ${external ? 'Show the user the schema you propose and create it once they agree.' : 'The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.'}\n`)}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}${when(!external, `${instructionsRule}\n`)}${pipelineBullet}` )}${when( canRunPreview && canWriteDraft, - ` -- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. + `${when(!external, `\n${testDraftRule}`)} - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` )}${when( buildsFlows && (!access || access.has('run_flow_preview')), @@ -1716,23 +1762,21 @@ ${pipelineBullet}` - After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.` )} - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. -- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). +- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).${when( + !external, + ` - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. -- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when( - canWriteDraft, +- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.` + )}${when( + canWriteDraft && !external, ` - Do not offer or open the Compare & Deploy page for normal draft review. Only use open_page with page "compare" when the user explicitly asks to deploy a forked workspace's changes to its parent workspace.` - )}${when( - canRunPreview, + )}${when(!external, runRule)}${when( + !external, ` -- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${canRunFlowPreview ? 'test_run_script, test_run_flow, test_run_step' : 'test_run_script, test_run_step'}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` - )}${when( - !canRunPreview, - ` -- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` - )} - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). -- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. +- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.` + )} - Keep context targeted.${ previewTools ? `${when( @@ -1792,7 +1836,7 @@ Raw apps: - Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${ buildsApps ? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.' - : 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.' + : `When the app has to store data and the user did not say where, settle the data setup with ${external ? 'the user' : 'askUserQuestion'} BEFORE init_app or any SQL (get_instructions subject "app" lists the questions${external ? '' : ', one askUserQuestion each'}), then pass the outcome as init_app's data.` } ${when(canDeployAnyKind, '- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.')}` )} @@ -2699,14 +2743,15 @@ ${getScriptPrompt(selected)}` async function getFlowInstructions( workspace: string | undefined, - composeOnly: boolean + composeOnly: boolean, + external = false ): Promise<string> { - const aiAgentProviders = formatAiAgentProvidersPrompt( - await getAiAgentProviderCatalog(workspace), - { - canAskUser: true - } - ) + // An external client's catalog is rendered where no workspace is known, so it gets none. + const aiAgentProviders = external + ? '' + : formatAiAgentProvidersPrompt(await getAiAgentProviderCatalog(workspace), { + canAskUser: true + }) return `# Global draft flow instructions - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. @@ -2751,7 +2796,11 @@ ${composeOnly ? 'This reference is written for every author. For this user, skip type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number] -function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string { +function getAppInstructions( + language: ScriptLang | undefined, + composeOnly: boolean, + external = false +): string { // getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with // it: telling the model to re-fetch a reference it is already holding wastes a turn, and // naming the wrong SDK contradicts the text right below the sentence. @@ -2766,7 +2815,7 @@ function getAppInstructions(language: ScriptLang | undefined, composeOnly: boole - To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${ composeOnly ? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.' - : ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.' + : ` and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. ${external ? 'Ask the user each of its questions, suggested answer first,' : 'Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first,'} before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool.` } \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. ${ composeOnly @@ -2835,21 +2884,22 @@ function getPipelineInstructions(): string { return getPipelinePrompt() } -function getInstructions( +export function getInstructions( subject: InstructionSubject, language: ScriptLang | undefined, workspace: string | undefined, - composeOnly: boolean + composeOnly: boolean, + external = false ): string | Promise<string> { switch (subject) { case 'script': return getScriptInstructions(language) case 'flow': - return getFlowInstructions(workspace, composeOnly) + return getFlowInstructions(workspace, composeOnly, external) case 'resource': return getResourceInstructions() case 'app': - return getAppInstructions(language, composeOnly) + return getAppInstructions(language, composeOnly, external) case 'datatable': return getDatatableInstructions(language) case 'pipeline': @@ -9060,8 +9110,19 @@ async function deployDraft( break } case 'resource': { - const requestBody = draft.value as any + let requestBody = draft.value as any if (await ResourceService.existsResource({ workspace, path })) { + // The server resets an agent's identity to whoever deploys, unless told to keep + // it: the identity is the deployed one, never the draft's. + const deployed = await ResourceService.getResource({ workspace, path }) + const obo = (deployed.value as any)?.on_behalf_of + if (deployed.resource_type === 'ai_agent' && obo) { + requestBody = { + ...requestBody, + value: { ...requestBody.value, on_behalf_of: obo }, + preserve_on_behalf_of: true + } + } await ResourceService.updateResource({ workspace, path, requestBody }) } else { await ResourceService.createResource({ workspace, requestBody }) @@ -9500,6 +9561,8 @@ export function prepareGlobalSystemMessage( /** Capabilities of the chat's operating workspace; undefined keeps every block. Must * match the profile the toolset was filtered with, or the prompt names withheld tools. */ access?: SessionAccess + /** Render for an external MCP client rather than the Windmill chat. */ + external?: boolean } ): ChatCompletionSystemMessageParam { const user = opts?.user ?? get(userStore) @@ -9518,7 +9581,8 @@ export function prepareGlobalSystemMessage( folderCtx, opts?.skills ?? [], opts?.mcpServers ?? [], - opts?.access + opts?.access, + opts?.external ?? false ) if (instructions?.workspace?.trim()) { content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}` diff --git a/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts new file mode 100644 index 0000000000..3de18622a5 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts @@ -0,0 +1,255 @@ +/** + * Generates what the Windmill MCP server's toolset (backend/windmill-api/src/mcp/session) + * shows a model, from the same definitions an AI session uses: tool names, descriptions and + * input schemas, the system prompt, and the `get_instructions` texts. The server implements + * the tools in Rust; everything the model reads comes from here, so the two cannot drift + * apart in wording. + * + * Run `npm run gen:mcp-session` after changing a GLOBAL tool definition, the global system + * prompt, or anything under system_prompts/. Without that env var this is the freshness check. + */ +import { describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { + ALL_INSTRUCTION_SUBJECTS, + getInstructions, + globalToolsFor, + prepareGlobalSystemMessage +} from './core' +import type { ScriptLang } from '$lib/gen' +import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' + +const OUT_DIR = fileURLToPath( + new URL('../../../../../../../backend/windmill-api/src/mcp/session/gen/', import.meta.url) +) + +// Sentences that describe the chat's run form, which an MCP client does not have. Each +// must still be found in the description it is listed for: a reworded description fails +// generation rather than shipping a promise of a form. +const DESCRIPTION_REWRITES: [tool: string, from: string, to: string][] = [ + [ + 'test_run_script', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'test_run_flow', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'test_run_step', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'run_script', + " Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the script's own deployed page instead; either way this call waits for them and runs what they confirm.", + ' It runs with exactly the `args` you send.' + ], + [ + 'run_flow', + " Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow's own deployed page instead; either way this call waits for them and runs what they confirm.", + ' It runs with exactly the `args` you send.' + ], + ['create_resource_type', ' once the user confirms the schema', ''], + ['set_workspace_settings', ' The user reviews the changes before they apply.', ''] +] + +// A rewrite only proves its old sentence is still there; this catches one added next to it. +const CHAT_ONLY_PROMISE = /argument form|the user (confirms|reviews|approves)/i + +function toolDefs() { + return globalToolsFor({ sessionPreview: false }).map((tool) => { + const fn = tool.def.function + let description = fn.description ?? '' + for (const [name, from, to] of DESCRIPTION_REWRITES) { + if (name !== fn.name) continue + if (!description.includes(from)) { + throw new Error(`${name}: its description no longer contains "${from}"`) + } + description = description.replace(from, to) + } + if (CHAT_ONLY_PROMISE.test(description)) { + throw new Error(`${fn.name}: its description promises a chat-only confirmation`) + } + return { + name: fn.name, + description, + parameters: fn.parameters, + // The session's own read-only marker: what plan mode lets through. + read_only: tool.planModeSafe === true + } + }) +} + +const WRITABLE = '{{writable_folders}}' +const READONLY = '{{readonly_folders}}' + +function prompt( + user: { is_admin: boolean; folders?: string[]; folders_read?: string[] }, + extra: Parameters<typeof prepareGlobalSystemMessage>[1] = {}, + instructions?: { workspace?: string } +): string { + const content = prepareGlobalSystemMessage(instructions, { + user: { username: '{{username}}', ...user } as any, + external: true, + ...extra + }).content as string + // The builder wraps each folder as `f/<name>`; the server substitutes a whole list. + return content + .replaceAll('`f/' + WRITABLE + '`', WRITABLE) + .replaceAll('`f/' + READONLY + '`', READONLY) +} + +function suffixOf(longer: string, base: string, what: string): string { + if (!longer.startsWith(base)) throw new Error(`${what} is no longer appended to the prompt`) + return longer.slice(base.length) +} + +function promptParts() { + const member = prompt({ is_admin: false, folders: [WRITABLE] }) + const skills = suffixOf( + prompt( + { is_admin: false, folders: [WRITABLE] }, + { skills: [{ path: '{{skill_path}}', name: '', description: '{{skill_description}}' }] } + ), + member, + 'the skills section' + ) + const skillLine = skills.split('\n').pop()! + // The folder guidance differs per workspace, so it is cut out of the prompt: the server + // puts it back for a single workspace, and serves it from `get_workspace_context` otherwise. + const withoutFolders = prompt({ is_admin: false }) + const variants = { + admin: prompt({ is_admin: true, folders: [WRITABLE] }), + admin_no_folders: prompt({ is_admin: true, folders: [] }), + member, + member_no_folders: prompt({ is_admin: false, folders: [] }), + member_readonly: prompt({ + is_admin: false, + folders: [WRITABLE], + folders_read: [WRITABLE, READONLY] + }), + member_no_folders_readonly: prompt({ is_admin: false, folders: [], folders_read: [READONLY] }) + } + let at: number | undefined + const folder_guidance = Object.fromEntries( + Object.entries(variants).map(([name, full]) => { + let i = 0 + while (full[i] === withoutFolders[i]) i++ + const rest = withoutFolders.slice(i) + if ((at !== undefined && at !== i) || !full.endsWith(rest)) { + throw new Error(`the folder guidance (${name}) is no longer one inserted block`) + } + at = i + return [name, full.slice(i, full.length - rest.length)] + }) + ) + return { + base: withoutFolders.slice(0, at) + '{{folder_guidance}}' + withoutFolders.slice(at), + folder_guidance, + skills_header: skills.slice(0, skills.length - skillLine.length), + skill_line: skillLine, + workspace_instructions: suffixOf( + prompt( + { is_admin: false, folders: [WRITABLE] }, + {}, + { workspace: '{{workspace_instructions}}' } + ), + member, + 'the workspace instructions' + ) + } +} + +/** What `get_trigger_schema` and `get_schedule_schema` answer: derived from the generated + * request schemas, so the tools themselves are the only faithful source. */ +async function schemas() { + const tools = globalToolsFor({ sessionPreview: false }) + const tool = (name: string) => tools.find((t) => t.def.function.name === name)! + const run = (name: string, args: unknown) => + tool(name).fn({ + args, + workspace: '', + toolId: '', + toolCallbacks: {} as any, + helpers: {} as any + }) + const kinds: string[] = (tool('get_trigger_schema').def.function.parameters as any).properties + .kind.enum + const trigger: Record<string, string> = {} + for (const kind of kinds) { + trigger[kind] = await run('get_trigger_schema', { kind }) + } + return { trigger, schedule: await run('get_schedule_schema', {}) } +} + +/** Every file to write, keyed by name. */ +async function generate(): Promise<Record<string, string>> { + const tools = toolDefs() + const files: Record<string, string> = { + 'tools.json': JSON.stringify(tools, null, '\t') + '\n', + 'prompt.json': JSON.stringify(promptParts(), null, '\t') + '\n' + } + const languages: (ScriptLang | undefined)[] = [ + undefined, + ...(tools.find((t) => t.name === 'get_instructions')!.parameters as any).properties.language + .enum + ] + // Keyed by subject then language ('' when none was given); identical texts are stored once. + const index: Record<string, Record<string, string>> = {} + const texts: Record<string, string> = {} + for (const subject of ALL_INSTRUCTION_SUBJECTS) { + index[subject] = {} + const byText = new Map<string, string>() + for (const language of languages) { + const text = await getInstructions(subject, language, undefined, false, true) + let id = byText.get(text) + if (!id) { + id = `${subject}${language ? '.' + language : ''}` + byText.set(text, id) + texts[id] = text + } + index[subject][language ?? ''] = id + } + } + // What `init_app` seeds a new raw app with. + files['app_templates.json'] = + JSON.stringify( + { templates: FRAMEWORK_TEMPLATES, starter_runnables: STARTER_RUNNABLES }, + null, + '\t' + ) + '\n' + files['schemas.json'] = JSON.stringify(await schemas(), null, '\t') + '\n' + files['instructions.json'] = JSON.stringify({ index, texts }, null, '\t') + '\n' + return files +} + +function committedFiles(dir = OUT_DIR, prefix = ''): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? committedFiles(join(dir, e.name), `${prefix}${e.name}/`) : [prefix + e.name] + ) +} + +describe('MCP session server generated definitions', () => { + it('match the session tools and prompts (npm run gen:mcp-session)', async () => { + const files = await generate() + expect(Object.keys(files)).toContain('tools.json') + if (process.env.WM_GEN_MCP_SESSION) { + rmSync(OUT_DIR, { recursive: true, force: true }) + mkdirSync(OUT_DIR, { recursive: true }) + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(OUT_DIR, name), content) + } + return + } + expect(committedFiles().sort()).toEqual(Object.keys(files).sort()) + for (const [name, content] of Object.entries(files)) { + expect(readFileSync(join(OUT_DIR, name), 'utf8'), name).toBe(content) + } + }) +}) diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index 2b86025549..ff451b1da4 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -5,13 +5,11 @@ import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' import { Bot, ExternalLink, Terminal } from 'lucide-svelte' import { shell } from 'svelte-highlight/languages' - import { mcpTokenUrlDisabled } from '$lib/mcpAuth' import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' type ConnectTab = 'cli' | 'mcp' let drawer: Drawer | undefined = $state() - let tokenUrlDisabled = $state(false) let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) @@ -27,10 +25,6 @@ wmill sync pull`) export function openDrawer(tab: ConnectTab = 'cli') { selectedTab = tab openVersion += 1 - // Falls back like CreateToken below, which shows the bare URL when the read fails. - void mcpTokenUrlDisabled() - .then((v) => (tokenUrlDisabled = v)) - .catch(() => (tokenUrlDisabled = true)) drawer?.openDrawer() } @@ -105,15 +99,12 @@ wmill sync pull`) <div class="flex flex-col gap-4"> <div class="flex items-start justify-between gap-3 flex-wrap"> <div class="flex flex-col gap-1"> - <h3 class="text-sm font-semibold text-emphasis">MCP URL</h3> + <h3 class="text-sm font-semibold text-emphasis" + >Build and run from your AI client</h3 + > <p class="text-xs text-secondary max-w-xl"> - {#if tokenUrlDisabled} - The MCP server URL for the current workspace. Your client signs in to - Windmill to use it. - {:else} - Generate an MCP server URL for the current workspace and choose which - scripts, flows, and endpoints the client can access. - {/if} + Connect Claude, ChatGPT or Cursor to Windmill: build scripts, flows and + apps, run your automations and check their runs, from your chat. </p> </div> diff --git a/frontend/src/lib/components/mcp/McpAccessPicker.svelte b/frontend/src/lib/components/mcp/McpAccessPicker.svelte new file mode 100644 index 0000000000..c80ab00ed2 --- /dev/null +++ b/frontend/src/lib/components/mcp/McpAccessPicker.svelte @@ -0,0 +1,146 @@ +<script lang="ts" module> + export type McpAccess = 'read' | 'read_run' | 'full' | 'selected' + + /** Marks a selection, so the per-workspace URLs add no API endpoint tools to it. */ + export const RUNNABLES_AS_TOOLS = 'mcp:runnables_as_tools' + + const ACCESS_SCOPES: Record<Exclude<McpAccess, 'selected'>, string[]> = { + read: ['mcp:all', 'mcp:read_only'], + read_run: ['mcp:all', 'mcp:read_run'], + full: ['mcp:all'] + } + + /** The access a token's MCP scopes give: `mcp:all` and how it is narrowed, or else a + * selection of scripts, flows and endpoints. */ + export function mcpAccessOf(scopes: string[]): McpAccess { + scopes = scopes.filter((s) => s !== RUNNABLES_AS_TOOLS) + if (!scopes.includes('mcp:all')) return scopes.length > 0 ? 'selected' : 'full' + if (scopes.includes('mcp:read_only')) return 'read' + if (scopes.includes('mcp:read_run')) return 'read_run' + return 'full' + } +</script> + +<script lang="ts"> + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import McpScopeSelector from './McpScopeSelector.svelte' + import Popover from '$lib/components/Popover.svelte' + import { Badge } from '$lib/components/common' + import Section from '$lib/components/Section.svelte' + import { SvelteMap } from 'svelte/reactivity' + + interface Props { + /** The workspace the selection picker browses. */ + workspaceId: string + /** The token's MCP scopes, the workspace list aside. */ + scopes?: string[] + access?: McpAccess + initialScopes?: string[] + } + + let { + workspaceId, + scopes = $bindable(), + access = $bindable(), + initialScopes = [] + }: Props = $props() + + const initialAccess = mcpAccessOf(initialScopes) + access = initialAccess + const initialSelection = + initialAccess === 'selected' + ? initialScopes.filter((s) => s !== RUNNABLES_AS_TOOLS).join(' ') + : undefined + let selection = $state(initialSelection ?? 'mcp:favorites') + + $effect(() => { + scopes = + access === 'selected' || access === undefined + ? [ + ...selection.split(' ').filter((s) => s.length > 0 && s !== RUNNABLES_AS_TOOLS), + RUNNABLES_AS_TOOLS + ] + : ACCESS_SCOPES[access] + }) + + // The tools each access level gives, as the server lists them. + type ToolsetEntry = { name: string; description: string } + let toolsCollapsed = $state(true) + const toolsets = new SvelteMap<string, ToolsetEntry[]>() + const toolsetAccess = $derived(access === 'selected' ? undefined : access) + $effect(() => { + const level = toolsetAccess + if (toolsCollapsed || !level || toolsets.has(level)) return + fetch(`/api/mcp/toolset?access=${level}`) + .then((r) => (r.ok ? r.json() : [])) + .then((tools: ToolsetEntry[]) => toolsets.set(level, tools)) + .catch(() => toolsets.set(level, [])) + }) + const tools = $derived(toolsetAccess ? toolsets.get(toolsetAccess) : undefined) +</script> + +<div class="flex flex-col gap-4"> + <div> + <span class="block mb-1 text-emphasis text-xs font-semibold">Access</span> + <ToggleButtonGroup bind:selected={access} allowEmpty={false}> + {#snippet children({ item })} + <ToggleButton + {item} + value="full" + label="Build and run" + tooltip="Everything you can do yourself: explore, write, test and deploy drafts, and run." + /> + <ToggleButton + {item} + value="read_run" + label="Read and run" + tooltip="Explore, and run deployed scripts and flows. The client changes nothing itself; the scripts and flows it runs can." + /> + <ToggleButton + {item} + value="read" + label="Read only" + tooltip="Explore and read. Nothing is run or changed." + /> + <ToggleButton + {item} + value="selected" + label="Scripts and flows as tools" + tooltip="The scripts and flows you pick, each as its own tool, and nothing else. To share a few automations." + /> + {/snippet} + </ToggleButtonGroup> + </div> + {#if toolsetAccess} + <Section label="Tools the client gets" small collapsable bind:collapsed={toolsCollapsed}> + <div class="flex flex-col gap-1 -mt-2"> + <span class="text-2xs text-secondary"> + {tools ? `${tools.length} tools. Hover one for what it does.` : 'Loading tools…'} + </span> + <div class="flex flex-wrap gap-1"> + {#each tools ?? [] as tool (tool.name)} + <Popover notClickable> + {#snippet text()} + <div class="text-xs max-w-md">{tool.description}</div> + {/snippet} + <Badge rounded small color="gray">{tool.name}</Badge> + </Popover> + {/each} + </div> + </div> + </Section> + {/if} + {#if access === 'selected'} + <p class="text-2xs text-secondary -mt-2"> + The client can run these scripts and flows and nothing else: it cannot explore the workspace, + read runs, build, or run anything outside the selection. + </p> + <McpScopeSelector + {workspaceId} + bind:scope={selection} + initialScope={initialSelection} + runnablesOnly + /> + {/if} +</div> diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 46274d10d0..35b815fcff 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -25,9 +25,19 @@ scope: string initialScope?: string readOnly?: boolean + /** Scripts and flows alone, for `/api/mcp`: a selection gets no endpoint tools there, so + * endpoints are left out, and since `mcp:all` is the full toolset, "All scripts/flows" + * is written as `mcp:scripts:* mcp:flows:*`. */ + runnablesOnly?: boolean } - let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props() + let { + workspaceId, + scope = $bindable(), + initialScope, + readOnly = false, + runnablesOnly = false + }: Props = $props() // Mirrors ITEMS_FETCH_MAX_LIMIT in backend/windmill-api/src/mcp/utils.rs: the // server exposes at most this many scripts and this many flows (per type) as @@ -147,8 +157,15 @@ scriptFolders.length === flowFolders.length && scriptFolders.every((f, i) => f === flowFolders[i]) const endpointsIsAll = byKind.endpoints?.length === 1 && byKind.endpoints[0] === '*' + // Without endpoints, scopes say "folder" or "all" with the scripts and flows alone. + const endpointsAsExpected = runnablesOnly ? !byKind.endpoints : endpointsIsAll + const isWildcard = (patterns?: string[]) => patterns?.length === 1 && patterns[0] === '*' - if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsIsAll) { + if (runnablesOnly && isWildcard(byKind.scripts) && isWildcard(byKind.flows)) { + return { ...empty, mode: 'all', hubApps } + } + + if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsAsExpected) { return { mode: 'folder', folders: scriptFolders.filter((f): f is string => !!f), @@ -205,14 +222,21 @@ scopeParts.push(`mcp:flows:${flowPaths.join(',')}`) } - if (selectedEndpoints.length > 0) { + if (!runnablesOnly && selectedEndpoints.length > 0) { scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`) } } else if (selectedMode === 'folder') { const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',') if (selectedFolders.length > 0) { - scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`, `mcp:endpoints:*`] + scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`] + if (!runnablesOnly) { + scopeParts.push(`mcp:endpoints:*`) + } } + } else if (selectedMode === 'all' && runnablesOnly) { + // `mcp:all` is the full toolset on `/api/mcp`: every script and flow as its own + // tool is said with wildcards instead. + scopeParts = ['mcp:scripts:*', 'mcp:flows:*'] } else { scopeParts = [`mcp:${selectedMode}`] } @@ -656,16 +680,18 @@ {/if} </div> - <div class="flex flex-col gap-2 mt-2"> - {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} - <MultiSelect - items={safeSelectItems(selectableEndpointTools.map((e) => e.name))} - placeholder="Select endpoints" - bind:value={selectedEndpoints} - /> - </div> + {#if !runnablesOnly} + <div class="flex flex-col gap-2 mt-2"> + {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} + <MultiSelect + items={safeSelectItems(selectableEndpointTools.map((e) => e.name))} + placeholder="Select endpoints" + bind:value={selectedEndpoints} + /> + </div> + {/if} - {#if withheldEndpoints.length > 0} + {#if !runnablesOnly && withheldEndpoints.length > 0} <Alert type="warning" size="xs" title="Some selected endpoints will not be exposed"> <span class="text-xs"> Endpoints that cannot be limited to specific script paths are withheld from a token @@ -675,7 +701,7 @@ </Alert> {/if} - {#if runByPathTools.length > 0} + {#if !runnablesOnly && runByPathTools.length > 0} <div class="flex flex-col gap-2 mt-2"> <span class="block text-xs font-semibold">Run tools</span> <p class="text-xs text-tertiary"> @@ -701,8 +727,9 @@ {/if} <div class="text-xs text-primary mt-2"> - Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length} - endpoints + Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows{#if !runnablesOnly}, + {selectedEndpoints.length} + endpoints{/if} </div> <!-- Wildcard Patterns Section --> @@ -785,23 +812,25 @@ </p> {/if} - <span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span> - <div class="flex flex-wrap gap-1"> - {#each exposedEndpointTools as endpoint (endpoint.name)} - <Popover notClickable> - {#snippet text()} - <div class="flex flex-col gap-1"> - <div class="text-xs">{endpoint.description}</div> - <div class="text-xs"> - {endpoint.method} - {endpoint.path} + {#if !runnablesOnly} + <span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span> + <div class="flex flex-wrap gap-1"> + {#each exposedEndpointTools as endpoint (endpoint.name)} + <Popover notClickable> + {#snippet text()} + <div class="flex flex-col gap-1"> + <div class="text-xs">{endpoint.description}</div> + <div class="text-xs"> + {endpoint.method} + {endpoint.path} + </div> </div> - </div> - {/snippet} - <Badge rounded small color="green">{endpoint.name}</Badge> - </Popover> - {/each} - </div> + {/snippet} + <Badge rounded small color="green">{endpoint.name}</Badge> + </Popover> + {/each} + </div> + {/if} </div> {/if} {/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8a1d2989d2..eec6fc3b0e 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -156,6 +156,10 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean + /** Open on the preview alone, with the file sidebar and the build logs retracted, + * whatever the width, as a narrow container does. The saved sidebar preference is + * left as it is. */ + compact?: boolean /** User-typed path when it differs from `savedApp.path`. The route injects * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined @@ -230,6 +234,7 @@ autosaveWorkspace = undefined, autosavePath = undefined, defaultSplitWithPreview = true, + compact = false, pendingDraftPath = $bindable(undefined), onDetails, onResetToDeployed, @@ -538,7 +543,9 @@ addEventListener('message', function (e) { call: "createRoot(document.getElementById('root')!).render(<App />)" } }) - let logsCollapsed = $state(false) + // Folded when the editor opens on the preview alone, where the log matters only on a + // failed build (the Preview tab turns red then); shown beside the code in split view. + let logsCollapsed = $state(untrack(() => !defaultSplitWithPreview || compact)) let logsDiv: HTMLDivElement | undefined = $state(undefined) $effect(() => { if (logsDiv && logs && !logsCollapsed) { @@ -566,7 +573,7 @@ addEventListener('message', function (e) { let activeTabId: string = $state(PREVIEW_TAB_ID) // 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)) + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview && !compact)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -840,7 +847,7 @@ addEventListener('message', function (e) { const w = rootWidth if (appliedNarrowDefault || w <= 0) return appliedNarrowDefault = true - if (w < NARROW_PX) { + if (compact || w < NARROW_PX) { splitWithPreview = false sidebarCollapsed.setWithoutPersist(true) } diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 56952eb584..b8d512b799 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -1,19 +1,21 @@ <script lang="ts"> import { onMount, untrack } from 'svelte' import { userWorkspaces, usersWorkspaceStore, type UserWorkspace } from '$lib/stores' - import { Alert, Button, Skeleton } from '../common' + import { Button, Skeleton } from '../common' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import Toggle from '../Toggle.svelte' import { SettingService, UserService, type NewToken } from '$lib/gen' import { mcpTokenUrlDisabled } from '$lib/mcpAuth' import TokenDisplay from './TokenDisplay.svelte' import ScopesPicker from './ScopesPicker.svelte' - import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte' - import { shell } from 'svelte-highlight/languages' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import TextInput from '../text_input/TextInput.svelte' import Select from '../select/Select.svelte' + import Section from '../Section.svelte' + import ClipboardPanel from '../details/ClipboardPanel.svelte' + import McpAccessPicker, { type McpAccess } from '../mcp/McpAccessPicker.svelte' + import MultiSelect from '../select/MultiSelect.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -44,16 +46,20 @@ displayCreateToken = true }: Props = $props() - // Sentinel workspace value meaning "all workspaces the user can access". - // Produces a workspace-less MCP token served through the /api/mcp/gateway - // endpoint, where tools take an explicit workspace_id argument. - const ALL_WORKSPACES = '*' - let newToken = $state<string | undefined>(undefined) let newMcpToken = $state<string | undefined>(undefined) + let newMcpTokenUrl = $state('') + // What an MCP token may do, as the access picker sets it. + let mcpAccess = $state<McpAccess>('full') + let mcpScopes = $state<string[]>(['mcp:all']) // What the user picked; `newTokenExpiration` is that pick held within the ceiling. let pickedExpiration = $state<number | undefined>(undefined) let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace)) + // The workspaces an MCP token reaches. One binds the token to it; several leave it unbound + // and are listed in its scopes; all of them leaves it unbound with no list, so it also + // reaches the workspaces its owner joins later. + let mcpWorkspaces = $state<string[]>([]) + let mcpAllWorkspaces = $state(false) let mcpCreationMode = $state(false) let lastRequestedMcpMode = $state<boolean | undefined>(undefined) let mcpLabelAutofilled = $state(false) @@ -136,10 +142,11 @@ mcpCreationMode = true void loadMcpUrlPolicy() resetExpirationOnModeChange() - newTokenWorkspace = defaultNewTokenWorkspace ?? $operatingWorkspace + const current = defaultNewTokenWorkspace ?? $operatingWorkspace + mcpWorkspaces = current ? [current] : [] + mcpAllWorkspaces = false newToken = undefined newMcpToken = undefined - readOnly = false if (!newTokenLabel) { newTokenLabel = 'MCP token' mcpLabelAutofilled = true @@ -167,7 +174,9 @@ date = new Date(new Date().getTime() + newTokenExpiration * 1000) } - const tokenScopes = scopes ?? pickedScopes ?? undefined + const pickedOrFixed = scopes ?? (mcpMode ? mcpScopes : pickedScopes) ?? undefined + const tokenScopes = + pickedOrFixed && workspacesScope ? [...pickedOrFixed, workspacesScope] : pickedOrFixed const createdToken = await UserService.createToken({ requestBody: { @@ -175,13 +184,14 @@ expiration: date?.toISOString(), scopes: tokenScopes, workspace_id: tokenWorkspaceId, - read_only: readOnly + read_only: mcpMode ? false : readOnly } as NewToken }) if (mcpMode) { newToken = undefined newMcpToken = `${createdToken}` + newMcpTokenUrl = `${mcpUrl}?token=${createdToken}` } else { newMcpToken = undefined newToken = `${createdToken}` @@ -197,13 +207,15 @@ } const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $operatingWorkspace)) - const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES) - const tokenWorkspaceId = $derived( - isAllWorkspaces - ? undefined - : mcpCreationMode - ? newTokenWorkspace || $operatingWorkspace - : newTokenWorkspace + const mcpBoundWorkspace = $derived( + !mcpAllWorkspaces && mcpWorkspaces.length === 1 ? mcpWorkspaces[0] : undefined + ) + const mcpReachesWorkspaces = $derived(mcpAllWorkspaces || mcpWorkspaces.length > 0) + const tokenWorkspaceId = $derived(mcpCreationMode ? mcpBoundWorkspace : newTokenWorkspace) + const workspacesScope = $derived( + mcpCreationMode && !mcpAllWorkspaces && mcpWorkspaces.length > 1 + ? `mcp:workspaces:${mcpWorkspaces.join(',')}` + : undefined ) // Mirrors the server's exemption for tokens owned by a service account: one in the workspace @@ -236,25 +248,21 @@ ? maxExpirationSecs : pickedExpiration ) - // The workspace used to browse scripts/flows/endpoints in the scope picker. - // For an all-workspaces token there is no single workspace, so fall back to - // the current one just for populating the endpoint list. + // The workspace used to browse scripts/flows/endpoints in the scope picker: the first + // picked one, or the current one for a token reaching all of them. const scopeWorkspaceId = $derived( - isAllWorkspaces ? $operatingWorkspace || '' : newTokenWorkspace || $operatingWorkspace || '' + (mcpCreationMode && !mcpAllWorkspaces ? mcpWorkspaces[0] : newTokenWorkspace) || + $operatingWorkspace || + '' ) - // Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL - // and is copyable, so the OAuth panel withholds it rather than showing a broken one. The - // token branch guards the same case by disabling its generate button. // On the browsing origin, not the API base url: the OAuth metadata the server // publishes for this URL is built from `base_url`, and a client rejects a mismatch. - const mcpUrl = $derived( - isAllWorkspaces - ? `${window.location.origin}/api/mcp/gateway` - : newTokenWorkspace - ? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp` - : undefined + // One URL whatever the access: the token's scopes decide which tools it gets. + const mcpUrl = `${window.location.origin}/api/mcp` + // Selected scripts and flows are listed per workspace: across several, a path could be two. + const selectionNeedsOneWorkspace = $derived( + mcpAccess === 'selected' && (mcpAllWorkspaces || mcpWorkspaces.length !== 1) ) - const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`) $effect(() => { const requestedMcpMode = mcpOnly || openWithMcpMode @@ -278,13 +286,87 @@ }) </script> +{#snippet mcpTokenForm()} + <div class="flex flex-col gap-3"> + <McpAccessPicker + workspaceId={scopeWorkspaceId} + bind:scopes={mcpScopes} + bind:access={mcpAccess} + /> + {#if !lockWorkspace} + {@render mcpWorkspacePicker()} + {#if selectionNeedsOneWorkspace} + <p class="text-2xs text-secondary"> + Selected scripts and flows come from one workspace: pick one. + </p> + {/if} + {/if} + {#if !mcpOnly} + <div> + <span class="block mb-1 text-emphasis text-xs font-semibold" + >Label <span class="text-xs text-primary">(optional)</span></span + > + <TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" /> + </div> + {/if} + {#if maxExpirationSecs != undefined} + <div> + <span class="block mb-1 text-xs text-emphasis font-semibold">Expires In</span> + <Select + bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} + placeholder="Pick an expiration" + inputClass="w-full" + items={expirationItems} + /> + <p class="mt-1 text-xs text-tertiary" + >This instance limits tokens to {maxExpirationLabel}.</p + > + </div> + {/if} + <div class="flex"> + <Button + onclick={() => createToken(true)} + disabled={!mcpReachesWorkspaces || selectionNeedsOneWorkspace || mcpScopes.length === 0} + variant="default" + unifiedSize="sm" + > + Generate URL with token + </Button> + </div> + </div> +{/snippet} + +{#snippet mcpWorkspacePicker()} + <div class="flex flex-col gap-1"> + <span class="block text-emphasis text-xs font-semibold">Workspaces</span> + <Toggle + bind:checked={mcpAllWorkspaces} + options={{ right: 'All my workspaces, including ones I join later' }} + size="xs" + /> + {#if !mcpAllWorkspaces} + <MultiSelect + bind:value={mcpWorkspaces} + items={workspaces.map((w) => ({ label: w.name, value: w.id }))} + placeholder="Pick workspaces" + reorderable={false} + /> + {/if} + {#if mcpAllWorkspaces || mcpWorkspaces.length > 1} + <p class="text-xs text-tertiary"> + Tools take a <code>workspace_id</code> argument naming the workspace they act in. + </p> + {/if} + </div> +{/snippet} + <div> <!-- Stays bounded by the panel width: a content-driven width (min-w-min) would let a long scope chip stretch this card and push the rest of the form out of view. --> - <div class="p-4 rounded-md mb-6 bg-surface-tertiary"> - <h3 class="pb-2 font-semibold text-emphasis text-sm"> - {mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title} - </h3> + <div class="p-4 rounded-md bg-surface-tertiary {!mcpOnly || newMcpToken ? 'mb-6' : ''}"> + {#if !mcpOnly} + <h3 class="pb-2 font-semibold text-emphasis text-sm">{title}</h3> + {/if} {#if showMcpMode && !mcpOnly} <div @@ -316,37 +398,30 @@ {#if mcpCreationMode && mcpUrlPolicy === 'loading'} <Skeleton layout={[[2], 0.5, [1]]} /> - {:else if mcpCreationMode && mcpUrlPolicy === 'oauth'} - {#if !lockWorkspace} - <div class="mb-4 max-w-md"> - <span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span> - <!-- No all-workspaces entry: the gateway's consent screen binds the token it issues - to the one workspace picked there, so OAuth has no multi-workspace grant to offer. --> - <Select - bind:value={newTokenWorkspace} - items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))} - /> + {:else if mcpCreationMode} + <div class="flex flex-col gap-6"> + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold text-emphasis">MCP URL</span> + <ClipboardPanel content={mcpUrl} size="sm" /> + <p class="text-2xs text-secondary"> + Add it to your client as a remote MCP server. It opens a Windmill page where you sign in + and choose the workspaces and what the client may do: build and run, read and run, read + only, or only the scripts and flows you pick. + </p> </div> - {/if} - {#if mcpUrl} - <CopyableCodeBlock code={mcpUrl} language={shell} wrap /> + {#if mcpUrlPolicy === 'token'} + <Section label="Use a token instead of signing in" small collapsable collapsed> + {@render mcpTokenForm()} + </Section> + {/if} - <div class="mt-2"> - <Alert type="info" title="This instance requires MCP clients to sign in" size="xs"> - Paste this URL into your client. It opens a Windmill page where you approve the access - it asks for, and no token needs to be copied around. - </Alert> - </div> - {:else} - <p class="text-xs text-tertiary">Pick a workspace to get its MCP URL.</p> - {/if} - - {#if !mcpOnly} - <div class="mt-4 flex justify-end gap-2 flex-row"> - <Button onClick={exitMcpMode} variant="default">Cancel</Button> - </div> - {/if} + {#if !mcpOnly} + <div class="flex justify-end"> + <Button onClick={exitMcpMode} variant="default">Cancel</Button> + </div> + {/if} + </div> {:else} {#if scopes != undefined} <div class="mb-4"> @@ -370,7 +445,7 @@ {#if !scopes || scopes.length === 0} <ScopesPicker - mode={mcpCreationMode ? 'mcp' : 'standard'} + mode="standard" workspaceId={scopeWorkspaceId} bind:value={pickedScopes} bind:readOnly @@ -378,31 +453,6 @@ {/if} <div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4"> - {#if mcpCreationMode} - {#if !lockWorkspace} - <div> - <span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span> - <Select - bind:value={newTokenWorkspace} - items={[ - { - label: 'All workspaces', - value: ALL_WORKSPACES, - subtitle: 'Multi-workspace' - }, - ...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id })) - ]} - /> - {#if isAllWorkspaces} - <p class="mt-1 text-xs text-tertiary"> - This token works across every workspace you can access. Tools take a - <code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them. - </p> - {/if} - </div> - {/if} - {/if} - {#if !mcpOnly} <div> <span class="block mb-1 text-emphasis text-xs font-semibold" @@ -412,27 +462,25 @@ </div> {/if} - {#if !mcpCreationMode || maxExpirationSecs != undefined} - <div> - <span class="block mb-1 text-xs text-emphasis font-semibold"> - Expires In - {#if maxExpirationSecs == undefined} - <span class="text-xs text-primary">(optional)</span> - {/if} - </span> - <Select - bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} - placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'} - inputClass="w-full" - items={expirationItems} - /> - {#if maxExpirationSecs != undefined} - <p class="mt-1 text-xs text-tertiary"> - This instance limits tokens to {maxExpirationLabel}. - </p> + <div> + <span class="block mb-1 text-xs text-emphasis font-semibold"> + Expires In + {#if maxExpirationSecs == undefined} + <span class="text-xs text-primary">(optional)</span> {/if} - </div> - {/if} + </span> + <Select + bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} + placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'} + inputClass="w-full" + items={expirationItems} + /> + {#if maxExpirationSecs != undefined} + <p class="mt-1 text-xs text-tertiary"> + This instance limits tokens to {maxExpirationLabel}. + </p> + {/if} + </div> </div> <div class="mt-4 flex justify-end gap-2 flex-row"> @@ -446,13 +494,7 @@ Cancel </Button> {/if} - <Button - on:click={() => createToken(mcpCreationMode)} - disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)} - variant="accent" - > - {mcpCreationMode ? 'Generate MCP URL' : 'New token'} - </Button> + <Button on:click={() => createToken()} variant="accent">New token</Button> </div> {/if} </div> @@ -462,6 +504,6 @@ {/if} {#if newMcpToken && displayCreateToken} - <TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} /> + <TokenDisplay token={newMcpToken} mcpUrl={newMcpTokenUrl} /> {/if} </div> diff --git a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte index 44a44a7937..7125093749 100644 --- a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte +++ b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte @@ -6,6 +6,7 @@ import Modal from '../common/modal/Modal.svelte' import TextInput from '../text_input/TextInput.svelte' import ScopesPicker from './ScopesPicker.svelte' + import McpAccessPicker, { mcpAccessOf } from '../mcp/McpAccessPicker.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -41,8 +42,26 @@ let labelValue = $state('') let pickedScopes = $state<string[] | null>(null) + let mcpScopes = $state<string[]>([]) let saving = $state(false) + // The access picker re-derives a token's scopes from what it can show, which drops what it + // does not model (an old token's endpoint list, say). So they are rewritten only once the + // user has used the picker; until then the token keeps exactly the scopes it had. + let mcpTouched = $state(false) + // Carried over as is, the picker knowing nothing of them: dropping the workspace list + // would widen the token to every workspace. + const isWorkspaceList = (s: string) => s.startsWith('mcp:workspaces:') + const keptAside = (s: string) => + isWorkspaceList(s) || (mcpAccessOf(mcpScopes) !== 'selected' && s.startsWith('mcp:hub:')) + const scopesToSave = $derived( + isMcp + ? mcpTouched + ? [...mcpScopes, ...(initialScopes ?? []).filter(keptAside)] + : (initialScopes ?? []) + : pickedScopes + ) + // Seed the label field from the token whenever the modal opens or the token // identity changes (untracked read so typing doesn't reset it). ScopesPicker // seeds itself via the `{#key tokenPrefix}` remount below. @@ -51,6 +70,7 @@ if (open) { untrack(() => { labelValue = initialLabel ?? '' + mcpTouched = false }) } }) @@ -69,7 +89,7 @@ try { const trimmed = labelValue.trim() const labelChanged = labelEditable && trimmed !== (initialLabel ?? '') - const scopesChanged = !scopesEqual(pickedScopes, initialScopes ?? null) + const scopesChanged = !scopesEqual(scopesToSave, initialScopes ?? null) if (labelChanged) { await UserService.updateTokenLabel({ @@ -80,7 +100,7 @@ if (scopesChanged) { await UserService.updateTokenScopes({ tokenPrefix, - requestBody: { scopes: pickedScopes } + requestBody: { scopes: scopesToSave } }) } if (labelChanged || scopesChanged) { @@ -95,7 +115,7 @@ } } - const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && !pickedScopes)) + const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && mcpScopes.length === 0)) </script> <Modal bind:open title="Edit token" class="!max-w-3xl"> @@ -117,14 +137,30 @@ </div> <div class="flex flex-col gap-1"> - <span class="text-xs font-semibold text-emphasis">Scopes</span> + {#if !isMcp} + <span class="text-xs font-semibold text-emphasis">Scopes</span> + {/if} {#key tokenPrefix} - <ScopesPicker - mode={isMcp ? 'mcp' : 'standard'} - workspaceId={mcpWorkspaceId} - {initialScopes} - bind:value={pickedScopes} - /> + {#if isMcp} + <div + role="group" + onpointerdown={() => (mcpTouched = true)} + onkeydown={() => (mcpTouched = true)} + > + <McpAccessPicker + workspaceId={mcpWorkspaceId} + initialScopes={(initialScopes ?? []).filter((s) => !isWorkspaceList(s))} + bind:scopes={mcpScopes} + /> + </div> + {:else} + <ScopesPicker + mode="standard" + workspaceId={mcpWorkspaceId} + {initialScopes} + bind:value={pickedScopes} + /> + {/if} {/key} </div> </div> diff --git a/frontend/src/lib/components/settings/TokenDisplay.svelte b/frontend/src/lib/components/settings/TokenDisplay.svelte index 5697b55b1b..238e7faa00 100644 --- a/frontend/src/lib/components/settings/TokenDisplay.svelte +++ b/frontend/src/lib/components/settings/TokenDisplay.svelte @@ -74,8 +74,8 @@ <!-- Additional info using alert info styling --> <div class="mt-1 {alertStyles.bgClass} rounded-md p-2"> <p class="text-xs {alertStyles.descriptionClass}"> - <strong>Next steps:</strong> Use this URL in your MCP-compatible client (like Claude Desktop) - to access your Windmill scripts and flows as tools. + <strong>Next steps:</strong> Add this URL to your MCP-compatible client (like Claude Desktop) + as a remote MCP server. </p> </div> {/if} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 1024aefa72..a13c06312b 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -129,7 +129,7 @@ editModalOpen = true } - async function listTokens(): Promise<void> { + export async function listTokens(): Promise<void> { tokens = await UserService.listTokens({ excludeEphemeral: true, page: tokenPage, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 9f21b48dd5..92df0e72a4 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -647,6 +647,7 @@ <RawAppEditor bind:this={rawAppEditor} ownsPageHeader + compact={page.url.searchParams.get('view') === 'preview'} onDetails={(e) => goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)} onSavedNewAppPath={(savedPath) => { draftSync.remove() diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte index 830e545719..a917de4d4a 100644 --- a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte @@ -6,10 +6,11 @@ import { page } from '$app/state' import CenteredModal from '$lib/components/CenteredModal.svelte' import { Button } from '$lib/components/common' - import Select from '$lib/components/select/Select.svelte' + import MultiSelect from '$lib/components/select/MultiSelect.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import McpAccessPicker, { type McpAccess } from '$lib/components/mcp/McpAccessPicker.svelte' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import McpScopeSelector from '$lib/components/mcp/McpScopeSelector.svelte' import Check from 'lucide-svelte/icons/check' // Get OAuth params from URL @@ -18,7 +19,12 @@ let clientId = page.url.searchParams.get('client_id') || '' let clientName = page.url.searchParams.get('client_name') || 'Unknown Client' let redirectUri = page.url.searchParams.get('redirect_uri') || '' - let scope = $state(page.url.searchParams.get('scope') || 'mcp:all') + // What the client asked for seeds the access picker; what is approved is the picker's. + const requestedScopes = (page.url.searchParams.get('scope') || 'mcp:all') + .split(' ') + .filter((s) => s.startsWith('mcp:')) + let scopes = $state<string[]>([]) + let access = $state<McpAccess>() let oauthState = page.url.searchParams.get('state') || '' let codeChallenge = page.url.searchParams.get('code_challenge') || '' let codeChallengeMethod = page.url.searchParams.get('code_challenge_method') || '' @@ -41,9 +47,21 @@ let success = $state(false) let successRedirectUrl = $state('') - // Gateway mode: fetch user's workspaces for the picker + // Gateway mode: fetch user's workspaces for the picker. One picked workspace binds the + // token to it; several, or all of them, leave it unbound and its tools name their workspace. let workspaces: Array<{ value: string; label: string }> = $state([]) + let pickedWorkspaces = $state<string[]>([]) + let allWorkspaces = $state(false) let loadingWorkspaces = $state(false) + const grantsWorkspace = $derived(!isGateway || allWorkspaces || pickedWorkspaces.length > 0) + // Selected scripts and flows are listed per workspace: across several, a path could be two. + const selectionNeedsOneWorkspace = $derived( + isGateway && access === 'selected' && (allWorkspaces || pickedWorkspaces.length !== 1) + ) + // The workspace the scope picker browses scripts and flows in. + const scopeWorkspaceId = $derived( + isGateway ? (pickedWorkspaces[0] ?? (allWorkspaces ? workspaces[0]?.value : '')) : workspaceId + ) if (isGateway) { loadingWorkspaces = true @@ -57,10 +75,12 @@ })) // Auto-select if only one workspace if (workspaces.length === 1) { - workspaceId = workspaces[0].value + pickedWorkspaces = [workspaces[0].value] } }) - .catch(() => { + .catch((e) => { + // Logged out: the root layout is already sending the browser to the login page. + if (e?.status === 401) return sendUserToast('Failed to load workspaces', true) }) .finally(() => { @@ -83,7 +103,7 @@ async function onApprove() { if (!redirectUriValid) return - if (!workspaceId) { + if (!grantsWorkspace) { sendUserToast('Please select a workspace', true) return } @@ -95,18 +115,19 @@ ? `/api/mcp/gateway/oauth/server/approve` : `/api/w/${workspaceId}/mcp/oauth/server/approve` - const body: Record<string, string> = { + const body: Record<string, unknown> = { client_id: clientId, redirect_uri: redirectUri, - scope: scope, + scope: scopes.join(' '), state: oauthState, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod } - // Gateway mode sends workspace_id in the body + // Gateway mode sends the workspaces in the body if (isGateway) { - body.workspace_id = workspaceId + body.workspace_ids = allWorkspaces ? [] : pickedWorkspaces + body.all_workspaces = allWorkspaces } const response = await fetch(approveUrl, { @@ -170,8 +191,8 @@ <p class="text-center text-sm text-primary mb-6"> <span class="font-semibold text-accent">{clientName}</span> is requesting access to - {#if isGateway && !workspaceId} - your Windmill workspace. + {#if isGateway} + your Windmill workspaces. {:else} your <span class="font-semibold text-accent">{workspaceId}</span> @@ -180,30 +201,53 @@ </p> {#if isGateway} - <div class="mb-6"> - <p class="text-xs font-semibold text-emphasis mb-2">Select a workspace:</p> + <div class="mb-6 flex flex-col gap-2"> + <p class="text-xs font-semibold text-emphasis">Workspaces it may work in:</p> {#if loadingWorkspaces} <p class="text-xs text-secondary">Loading workspaces...</p> {:else if workspaces.length === 0} <p class="text-xs text-secondary">No workspaces available.</p> {:else} - <Select items={workspaces} bind:value={workspaceId} placeholder="Choose a workspace" /> + <Toggle + bind:checked={allWorkspaces} + options={{ right: 'All my workspaces, including ones I join later' }} + size="xs" + /> + {#if !allWorkspaces} + <MultiSelect + items={workspaces} + bind:value={pickedWorkspaces} + placeholder="Choose workspaces" + reorderable={false} + /> + {/if} {/if} </div> {/if} - {#if workspaceId} - <div class="mb-6"> - <McpScopeSelector {workspaceId} bind:scope /> - </div> - {/if} + <div class="mb-6 flex flex-col gap-1"> + <McpAccessPicker + workspaceId={scopeWorkspaceId ?? ''} + initialScopes={requestedScopes} + bind:scopes + bind:access + /> + {#if selectionNeedsOneWorkspace} + <p class="text-2xs text-secondary"> + Selected scripts and flows come from one workspace: pick one. + </p> + {/if} + </div> <div class="flex flex-row justify-around gap-x-4"> <Button variant="border" size="lg" disabled={loading} onClick={onDeny}>Deny</Button> <Button variant="accent" size="lg" - disabled={loading || (isGateway && !workspaceId)} + disabled={loading || + !grantsWorkspace || + selectionNeedsOneWorkspace || + scopes.length === 0} {loading} onClick={onApprove}>Approve</Button > diff --git a/system_prompts/README.md b/system_prompts/README.md index 9051a71112..32c0c521c4 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -10,6 +10,10 @@ are built from it: `cli/src/guidance/skills.gen.ts`, next to the `AGENTS.wmill.md` template in `cli/src/guidance/core.ts`. +The workspace MCP server (`backend/windmill-api/src/mcp/session/`) is a third reader, one step +removed: it serves the global chat's own `get_instructions` texts and system prompt, rendered for +an external client by `npm run gen:mcp-session` in `frontend/`. Rerun that after `generate.py`. + Guidance written in one consumer only is guidance the other never gets. Write it here. ## Structure