feat: deploy a raw app from its sources, bundling them on a worker

This commit is contained in:
Ruben Fiszel
2026-08-04 09:12:23 +00:00
parent cc9141844a
commit 140a077a43
11 changed files with 837 additions and 18 deletions
+1
View File
@@ -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
@@ -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"
}
@@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool<Postgres>) -> 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" }))
+69 -1
View File
@@ -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/<path> 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
+128 -15
View File
@@ -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<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
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<String, String>,
}
fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option<bool>) -> 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<bool>,
) -> 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<UserDB>,
@@ -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<String> = None;
+195
View File
@@ -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<String, String>,
) -> 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<String, Box<serde_json::value::RawValue>> = 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<HashMap<String, String>> {
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::<HashMap<String, String>>(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"
);
}
}
@@ -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<string, string>,
wmill_ts: string,
shared_ui: Record<string, string> | 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 }
}
@@ -0,0 +1,97 @@
let reqs: Record<string, any> = {}
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<any> {
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)
}
}
})
+1
View File
@@ -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;
@@ -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/<path> 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 {
+108 -1
View File
@@ -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/<path> 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"
}
},
{