diff --git a/AGENTS.md b/AGENTS.md index 5e0c5885c3..2354ec2078 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **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. - **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 `wmill` client**: `frontend/src/lib/rawAppWmillTs.ts` is injected as a virtual module by all three raw-app bundlers, so the backend one carries a copy at `backend/windmill-api/src/apps_raw_wmill_ts.ts`. After changing it, copy it over (`apps_raw_bundle.rs`'s `test_vendored_wmill_ts_matches_frontend` fails otherwise). ## Dev Environment diff --git a/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json b/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json new file mode 100644 index 0000000000..5d1b35b17d --- /dev/null +++ b/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "files", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941" +} diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index cf04e377d2..f0c77149f6 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool) -> anyho "expected the raw update of a low-code app to be refused" ); + // The source endpoint refuses the same mismatch up front — it must not queue + // a bundle job (which no worker would pick up here) to find that out. + let resp = authed(client().post(format!("{base}/update_raw_source/{low_code_path}"))) + .json(&json!({ "value": { "files": { "/index.tsx": "export {}" } } })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("is a low-code app"), + "expected the source deploy of a low-code app to be refused" + ); + // Metadata-only updates and same-kind deploys still go through. let resp = authed(client().post(format!("{base}/update/{raw_path}"))) .json(&json!({ "summary": "Renamed" })) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7c458a20b9..33d0a6a3b3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12259,7 +12259,7 @@ paths: summary: update app operationId: updateApp x-mcp-tool: true - x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead." + x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those." x-mcp-tool-include-fields: - path - value @@ -12314,6 +12314,74 @@ paths: schema: type: string + /w/{workspace}/apps/update_raw_source/{path}: + post: + summary: update a raw app from its sources, bundling them server-side + operationId: updateAppRawSource + x-mcp-tool: true + x-mcp-instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead." + x-mcp-tool-include-fields: + - path + - value + - summary + - policy + tags: + - app + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + description: raw app sources to bundle and deploy + required: true + content: + application/json: + schema: + type: object + properties: + path: + type: string + summary: + type: string + value: + type: object + description: "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged." + properties: + files: + type: object + additionalProperties: + type: string + runnables: + type: object + data: + type: object + required: [files] + policy: + $ref: "#/components/schemas/Policy" + deployment_message: + type: string + custom_path: + type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." + allow_kind_change: + type: boolean + description: "When true, this deploy may switch the app between low-code and raw. Without it, deploying a value to an app of the other kind is refused so an app is never converted by accident." + responses: + "200": + description: app updated + content: + text/plain: + schema: + type: string + /w/{workspace}/apps/update_raw/{path}: post: summary: update app diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 9bb227748c..81d97db916 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -13,6 +13,7 @@ use crate::job_helpers_oss::{ spawn_storage_usage_recount_floored, }; use crate::{ + apps_raw_bundle, auth::{get_end_user_email, OptTokened}, db::{ApiAuthed, DB}, jobs::RunJobQuery, @@ -113,6 +114,11 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { "/update_raw/{*path}", post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)), ) + .route( + "/update_raw_source/{*path}", + post(update_app_raw_source) + .layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)), + ) .route("/delete/{*path}", delete(delete_app)) .route("/create", post(create_app)) .route( @@ -2346,6 +2352,127 @@ async fn update_app( Ok(format!("app {} updated (npath: {:?})", opath, npath)) } +/// Deploy a raw app from its sources, compiling them on a worker. `update_raw` +/// takes the bundle the caller already built (the editor and the CLI both +/// bundle before calling it); an API client has nothing to build with, so this +/// is the raw-app deploy an MCP agent or a script can actually reach. +async fn update_app_raw_source( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Extension(webhook): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(ns): Json, +) -> Result { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot update apps for security reasons".to_string(), + )); + } + + let path = path.to_path(); + check_scopes(&authed, || format!("apps:write:{}", path))?; + + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( + &w_id, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + + let Some(value) = ns.value.as_ref() else { + return Err(Error::BadRequest( + "value with the app's `files` is required to deploy a raw app".to_string(), + )); + }; + let files: RawAppSourceFiles = serde_json::from_str(value.0.get()) + .map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?; + + // Before the compile, which costs a job: an app of the other kind is refused + // by update_app_internal anyway, and the caller shouldn't wait to find out. + ensure_deployed_kind_matches(&db, &w_id, path, true, ns.allow_kind_change).await?; + + let (js, css) = + apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files) + .await?; + + let opath = path.to_string(); + let (mut tx, npath, id) = + update_app_internal(authed, db, user_db, &w_id, path, true, ns).await?; + store_raw_app_file(&w_id, &id, "js", bytes::Bytes::from(js), &mut tx).await?; + if !css.is_empty() { + store_raw_app_file(&w_id, &id, "css", bytes::Bytes::from(css), &mut tx).await?; + } + tx.commit().await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::UpdateApp { + workspace: w_id.clone(), + old_path: opath.clone(), + new_path: npath.clone(), + }, + ); + + Ok(format!("app {} updated (npath: {:?})", opath, npath)) +} + +/// The part of a raw app's value the bundler needs. +#[derive(Deserialize)] +struct RawAppSourceFiles { + #[serde(default)] + files: HashMap, +} + +fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option) -> Result<()> { + if !deployed_raw_app.is_some_and(|deployed| deployed != raw_app) { + return Ok(()); + } + // Name the folder suffix too: a sync push picks the endpoint from the repo + // layout, so its operator has no endpoint to swap, only a folder. + let (kind, endpoint, folder) = if raw_app { + ("a low-code app", "/apps/update", ".app") + } else { + ("a raw app", "/apps/update_raw", ".raw_app") + }; + Err(Error::BadRequest(format!( + "App {path} is {kind}: deploying a value to it through the other kind's endpoint \ + would convert it and strand its bundle. Deploy it through {endpoint} instead \ + (from a synced repo, from a `{folder}` folder), or set allow_kind_change to \ + convert it on purpose." + ))) +} + +/// The same refusal `update_app_internal` makes, run before the caller pays for +/// a compile. Advisory only — the locked check inside the transaction is what +/// actually holds the invariant. +async fn ensure_deployed_kind_matches( + db: &DB, + w_id: &str, + path: &str, + raw_app: bool, + allow_kind_change: Option, +) -> Result<()> { + if allow_kind_change.unwrap_or(false) { + return Ok(()); + } + let deployed_raw_app = sqlx::query_scalar!( + "SELECT app_version.raw_app FROM app + JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.path = $1 AND app.workspace_id = $2", + path, + w_id + ) + .fetch_optional(db) + .await?; + reject_kind_change(path, raw_app, deployed_raw_app) +} + async fn update_app_raw<'a>( authed: ApiAuthed, Extension(user_db): Extension, @@ -2448,21 +2575,7 @@ async fn update_app_internal<'a>( } None => None, }; - if deployed_raw_app.is_some_and(|deployed| deployed != raw_app) { - // Name the folder suffix too: a sync push picks the endpoint from the - // repo layout, so its operator has no endpoint to swap, only a folder. - let (kind, endpoint, folder) = if raw_app { - ("a low-code app", "/apps/update", ".app") - } else { - ("a raw app", "/apps/update_raw", ".raw_app") - }; - return Err(Error::BadRequest(format!( - "App {path} is {kind}: deploying a value to it through the other kind's endpoint \ - would convert it and strand its bundle. Deploy it through {endpoint} instead \ - (from a synced repo, from a `{folder}` folder), or set allow_kind_change to \ - convert it on purpose." - ))); - } + reject_kind_change(path, raw_app, deployed_raw_app)?; } let mut preserved_on_behalf_of: Option = None; diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs new file mode 100644 index 0000000000..68e903e6bf --- /dev/null +++ b/backend/windmill-api/src/apps_raw_bundle.rs @@ -0,0 +1,195 @@ +//! Server-side bundling of a raw app's sources. +//! +//! A raw app is served as a compiled js/css bundle, so every deploy path has to +//! compile first: the editor bundles in a browser iframe, the CLI bundles with +//! esbuild on the developer's machine. Neither is reachable from a plain API +//! call, which is why `/apps/update_raw` takes the bundle as multipart and why +//! there was no way to deploy a raw app from an API client (an MCP agent, most +//! of all — its only app-write tool was the low-code one, which converted the +//! app instead). +//! +//! This runs the compile as a normal bun job on a worker: no new job kind, no +//! new executor, and the build's logs, timeout, cancellation and attribution are +//! the ones every other job gets. + +use std::collections::HashMap; + +use serde::Deserialize; +use uuid::Uuid; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, + worker::to_raw_value, + DB, +}; +use windmill_queue::{push, PushArgs, PushIsolationLevel}; + +use crate::db::ApiAuthed; + +/// The bundler, as its own file so it stays readable/lintable TypeScript. +const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts"); + +/// The raw-app `wmill` client, vendored from `frontend/src/lib/rawAppWmillTs.ts` +/// because the backend build has no access to the frontend tree. The bundler +/// injects it as a virtual module, exactly as the other two bundlers do. +/// `test_vendored_wmill_ts_matches_frontend` fails if the copies drift. +const RAW_APP_WMILL_TS: &str = include_str!("apps_raw_wmill_ts.ts"); + +/// Cap the compile so a pathological `package.json` can't hold the request open +/// for the full `TIMEOUT_WAIT_RESULT`. +const BUNDLE_TIMEOUT_SECS: i32 = 300; + +#[derive(Deserialize)] +struct BundleResult { + js: String, + css: String, +} + +/// Compile `files` into the js/css a deployed raw app serves. Returns the +/// bundler's own error when the build fails, so the caller sees the compile +/// error rather than a generic failure. +pub async fn bundle_raw_app_sources( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + files: &HashMap, +) -> Result<(String, String)> { + if files.is_empty() { + return Err(Error::BadRequest( + "app value has no `files` to bundle".to_string(), + )); + } + + let shared_ui = shared_ui_files(user_db, authed, w_id).await?; + + let mut args: HashMap> = HashMap::new(); + args.insert("files".to_string(), to_raw_value(files)); + args.insert("wmill_ts".to_string(), to_raw_value(&RAW_APP_WMILL_TS)); + args.insert("shared_ui".to_string(), to_raw_value(&shared_ui)); + + let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let (uuid, tx) = push( + db, + tx, + w_id, + JobPayload::Code(RawCode { + hash: None, + content: BUNDLER_TS.to_string(), + path: Some("bundle raw app".to_string()), + language: ScriptLang::Bun, + lock: None, + concurrency_settings: Default::default(), + debouncing_settings: Default::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }), + PushArgs { args: &args, extra: None }, + authed.display_username(), + &authed.email, + windmill_common::users::username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + authed.username_override.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + Some(BUNDLE_TIMEOUT_SECS), + None, + None, + Some(&authed.clone().into()), + false, + None, + authed.trigger_or_fallback(None), + None, + ) + .await?; + tx.commit().await?; + + wait_for_bundle(db, w_id, uuid, authed).await +} + +async fn wait_for_bundle( + db: &DB, + w_id: &str, + uuid: Uuid, + authed: &ApiAuthed, +) -> Result<(String, String)> { + let (result, success) = windmill_api_jobs::execution::run_wait_result_internal( + db, + uuid, + w_id, + None, + false, + &authed.username, + ) + .await?; + + if !success { + // The job's error is the compile error the caller needs to act on. + return Err(Error::BadRequest(format!( + "raw app bundling failed (job {uuid}): {}", + result.get() + ))); + } + + let bundle: BundleResult = serde_json::from_str(result.get()).map_err(|e| { + Error::internal_err(format!( + "unexpected raw app bundler result (job {uuid}): {e}" + )) + })?; + Ok((bundle.js, bundle.css)) +} + +async fn shared_ui_files( + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, +) -> Result> { + let mut tx = user_db.clone().begin(authed).await?; + let files = sqlx::query_scalar!( + "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1", + w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + Ok(files + .and_then(|f| serde_json::from_value::>(f).ok()) + .unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + /// The vendored copy is what the bundler injects, so a change to the + /// frontend's `wmill` client that doesn't reach it would ship apps compiled + /// against a different client than the editor compiles against. + #[test] + fn test_vendored_wmill_ts_matches_frontend() { + let frontend = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../frontend/src/lib/rawAppWmillTs.ts"); + // Absent in a backend-only checkout (the published crate, a Docker build + // context); there is nothing to compare against then. + let Ok(expected) = std::fs::read_to_string(&frontend) else { + return; + }; + assert_eq!( + expected.replace("\r\n", "\n"), + super::RAW_APP_WMILL_TS.replace("\r\n", "\n"), + "backend/windmill-api/src/apps_raw_wmill_ts.ts is stale — copy \ + frontend/src/lib/rawAppWmillTs.ts over it" + ); + } +} diff --git a/backend/windmill-api/src/apps_raw_bundler.ts b/backend/windmill-api/src/apps_raw_bundler.ts new file mode 100644 index 0000000000..e21d1742cc --- /dev/null +++ b/backend/windmill-api/src/apps_raw_bundler.ts @@ -0,0 +1,95 @@ +/** + * Bundles a raw app's sources into the js/css a deployed raw app serves. Runs as + * a bun job so the compile happens on a worker, not in the API process — see + * `apps_raw_bundle.rs`, which is the only thing that runs it. + * + * Mirrors the two bundlers that already exist (the editor's, in the ui_builder + * iframe, and the CLI's `createBundle`): same entry-point pick, the same virtual + * `wmill` module, `/ui/` resolved against the workspace's shared UI, and + * NODE_ENV pinned to production — without which React ships its dev build and + * the bundle doubles in size. + */ +export async function main( + files: Record, + wmill_ts: string, + shared_ui: Record | undefined +): Promise<{ js: string; css: string }> { + const fs = await import('node:fs/promises') + const path = await import('node:path') + + const dir = path.join(process.cwd(), 'wm_raw_app') + await fs.rm(dir, { recursive: true, force: true }) + + const write = async (rel: string, content: string) => { + const abs = path.join(dir, rel.replace(/^\/+/, '')) + if (!abs.startsWith(dir + path.sep)) { + throw new Error(`file path escapes the build directory: ${rel}`) + } + await fs.mkdir(path.dirname(abs), { recursive: true }) + await fs.writeFile(abs, content) + } + for (const [p, content] of Object.entries(files ?? {})) { + await write(p, content) + } + for (const [p, content] of Object.entries(shared_ui ?? {})) { + await write('ui/' + p.replace(/^\/+/, ''), content) + } + + if (files['/package.json']) { + // --ignore-scripts: the app's dependencies are compiled, never run, so a + // package's lifecycle script has no business executing on the worker. + const install = Bun.spawnSync(['bun', 'install', '--ignore-scripts'], { + cwd: dir, + stdout: 'pipe', + stderr: 'pipe' + }) + if (install.exitCode !== 0) { + throw new Error( + 'bun install failed:\n' + install.stderr.toString() + install.stdout.toString() + ) + } + } + + const entry = ['/index.tsx', '/index.ts', '/index.js'].find((e) => files?.[e]) + if (!entry) { + throw new Error('no entry point: the app needs one of /index.tsx, /index.ts, /index.js') + } + + const wmillPlugin = { + name: 'wmill-virtual', + setup(build: any) { + build.onResolve({ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ }, () => ({ + path: 'wmill-virtual', + namespace: 'wmill-virtual' + })) + build.onLoad({ filter: /.*/, namespace: 'wmill-virtual' }, () => ({ + contents: wmill_ts, + loader: 'ts' + })) + } + } + + const result = await Bun.build({ + entrypoints: [path.join(dir, entry.replace(/^\/+/, ''))], + outdir: path.join(dir, 'dist'), + target: 'browser', + minify: true, + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [wmillPlugin] + }) + if (!result.success) { + throw new Error('bundle failed:\n' + (result.logs ?? []).map((l: any) => String(l)).join('\n')) + } + + let js = '' + let css = '' + for (const out of result.outputs) { + const text = await out.text() + if (out.path.endsWith('.css')) css += text + else if (out.path.endsWith('.js')) js += text + } + if (js === '') { + throw new Error('bundle produced no javascript') + } + return { js, css } +} diff --git a/backend/windmill-api/src/apps_raw_wmill_ts.ts b/backend/windmill-api/src/apps_raw_wmill_ts.ts new file mode 100644 index 0000000000..64c23672c4 --- /dev/null +++ b/backend/windmill-api/src/apps_raw_wmill_ts.ts @@ -0,0 +1,97 @@ +let reqs: Record = {} + +function doRequest(type: string, o: object, extra?: object) { + return new Promise((resolve, reject) => { + const reqId = Math.random().toString(36) + reqs[reqId] = { resolve, reject, ...extra } + const req = { ...o, type, reqId } + parent.postMessage(req, '*') + }) +} + +export const backend = new Proxy( + {}, + { + get(_, runnable_id: string) { + return (v: any) => { + return doRequest('backend', { runnable_id, v }) + } + } + } +) + +export const backendAsync = new Proxy( + {}, + { + get(_, runnable_id: string) { + return (v: any) => { + return doRequest('backendAsync', { runnable_id, v }) + } + } + } +) + +export function waitJob(jobId: string) { + return doRequest('waitJob', { jobId }) +} + +export function getJob(jobId: string) { + return doRequest('getJob', { jobId }) +} + +/** + * Stream job results using SSE. Calls onUpdate for each stream update, + * and resolves with the final result when the job completes. + * @param jobId - The job ID to stream + * @param onUpdate - Callback for stream updates with new_result_stream data + * @returns Promise that resolves with the final job result + */ +export function streamJob( + jobId: string, + onUpdate?: (data: { new_result_stream?: string; stream_offset?: number }) => void +): Promise { + return doRequest('streamJob', { jobId }, { onUpdate }) +} + +window.addEventListener('message', (e) => { + if (e.data.type === 'streamJobUpdate') { + // Handle streaming update + let job = reqs[e.data.reqId] + if (job && job.onUpdate) { + job.onUpdate({ + new_result_stream: e.data.new_result_stream, + stream_offset: e.data.stream_offset + }) + } + } else if (e.data.type === 'streamJobRes') { + // Handle stream completion + let job = reqs[e.data.reqId] + if (job) { + if (e.data.error) { + job.reject(new Error(e.data.result?.stack ?? e.data.result?.message ?? 'Stream error')) + } else { + job.resolve(e.data.result) + } + delete reqs[e.data.reqId] + } + } else if ( + e.data.type === 'backendRes' || + e.data.type === 'backendAsyncRes' || + e.data.type === 'waitJobRes' || + e.data.type === 'getJobRes' + ) { + console.log('Message from parent backend', e.data) + let job = reqs[e.data.reqId] + if (job) { + const result = e.data.result + if (e.data.error) { + job.reject(new Error(result.stack ?? result.message)) + } else { + job.resolve(result) + } + delete reqs[e.data.reqId] + } else { + console.error('No job found for', e.data.reqId) + } + } +}) diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 2cc3c00283..3eea082c5e 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -68,6 +68,7 @@ use windmill_common::error::AppError; mod ai; mod ai_skills; mod apps; +mod apps_raw_bundle; pub use apps::invalidate_app_policy_cache; pub mod args; mod audit; diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index f4f39c2412..27791e9550 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -1113,7 +1113,7 @@ Creates a new version of an existing script when called with the same path and t EndpointTool { name: Cow::Borrowed("updateApp"), description: Cow::Borrowed("update app"), - instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead."), + instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."), path: Cow::Borrowed("/w/{workspace}/apps/update/{path}"), method: Cow::Borrowed("POST"), path_params_schema: Some(serde_json::json!({ @@ -1152,6 +1152,113 @@ Creates a new version of an existing script when called with the same path and t query_field_renames: None, body_field_renames: Some(serde_json::json!({ "path__body": "path" +})), + }, + EndpointTool { + name: Cow::Borrowed("updateAppRawSource"), + description: Cow::Borrowed("update a raw app from its sources, bundling them server-side"), + instructions: Cow::Borrowed("Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead."), + path: Cow::Borrowed("/w/{workspace}/apps/update_raw_source/{path}"), + method: Cow::Borrowed("POST"), + path_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] +})), + query_params_schema: None, + body_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "value": { + "type": "object", + "description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.", + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "runnables": { + "type": "object" + }, + "data": { + "type": "object" + } + }, + "required": [ + "files" + ] + }, + "policy": { + "type": "object", + "properties": { + "triggerables": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "triggerables_v2": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "s3_inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "allowed_s3_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "s3_path": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + } + }, + "execution_mode": { + "type": "string", + "description": "Possible values: viewer, publisher, anonymous" + }, + "on_behalf_of": { + "type": "string" + }, + "on_behalf_of_email": { + "type": "string" + }, + "sandbox": { + "type": "boolean", + "description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n" + } + } + }, + "path__body": { + "type": "string", + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." + } + } +})), + query_field_renames: None, + body_field_renames: Some(serde_json::json!({ + "path__body": "path" })), }, EndpointTool { diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 103969952f..77db10d600 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -1121,7 +1121,7 @@ export const mcpEndpointTools: EndpointTool[] = [ { name: "updateApp", description: "update app", - instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead.", + instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those.", path: "/w/{workspace}/apps/update/{path}", method: "POST", pathParamsSchema: { @@ -1160,6 +1160,113 @@ export const mcpEndpointTools: EndpointTool[] = [ queryFieldRenames: undefined, bodyFieldRenames: { "path__body": "path" +} + }, + { + name: "updateAppRawSource", + description: "update a raw app from its sources, bundling them server-side", + instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead.", + path: "/w/{workspace}/apps/update_raw_source/{path}", + method: "POST", + pathParamsSchema: { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] +}, + queryParamsSchema: undefined, + bodySchema: { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "value": { + "type": "object", + "description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.", + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "runnables": { + "type": "object" + }, + "data": { + "type": "object" + } + }, + "required": [ + "files" + ] + }, + "policy": { + "type": "object", + "properties": { + "triggerables": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "triggerables_v2": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "s3_inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "allowed_s3_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "s3_path": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + } + }, + "execution_mode": { + "type": "string", + "description": "Possible values: viewer, publisher, anonymous" + }, + "on_behalf_of": { + "type": "string" + }, + "on_behalf_of_email": { + "type": "string" + }, + "sandbox": { + "type": "boolean", + "description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n" + } + } + }, + "path__body": { + "type": "string", + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." + } + } +}, + queryFieldRenames: undefined, + bodyFieldRenames: { + "path__body": "path" } }, {