From c962d3766b1a250ba960e413556890d5919ebc94 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:28:46 +0200 Subject: [PATCH 01/57] fix: evaluate field conditions and step test inputs in QuickJS (#11487) * fix: evaluate field conditions and step test inputs in QuickJS Co-Authored-By: Claude Opus 5.5 * fix: answer single-quoted field conditions without loading the engine Co-Authored-By: Claude Opus 5.5 * fix: bound expression recursion and ignore leading blank space Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- frontend/package-lock.json | 26 ++ frontend/package.json | 2 + .../lib/components/InputTransformForm.svelte | 8 +- .../lib/components/ModulePreviewForm.svelte | 6 +- frontend/src/lib/components/ModuleTest.svelte | 4 +- frontend/src/lib/components/SchemaForm.svelte | 8 +- .../components/copilot/chat/global/core.ts | 2 + .../flows/propPicker/InputPickerInner.svelte | 5 +- .../src/lib/components/flows/utils.svelte.ts | 25 +- frontend/src/lib/utils.ts | 23 -- frontend/src/lib/utils/quickjsEval.svelte.ts | 249 ++++++++++++++++++ frontend/src/lib/utils/quickjsEval.test.ts | 87 ++++++ 12 files changed, 392 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/utils/quickjsEval.svelte.ts create mode 100644 frontend/src/lib/utils/quickjsEval.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7fa720990a..0334f5e756 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", "@floating-ui/core": "^1.3.1", + "@jitl/quickjs-wasmfile-release-sync": "^0.32.0", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", "@melt-ui/svelte": "^0.86.2", @@ -70,6 +71,7 @@ "p-limit": "^6.1.0", "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", + "quickjs-emscripten-core": "^0.32.0", "quicktype-core": "^23.2.6", "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", @@ -1431,6 +1433,21 @@ "node": ">=18.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -11432,6 +11449,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c0eec4435..04ac888374 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -86,6 +86,7 @@ "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", "@floating-ui/core": "^1.3.1", + "@jitl/quickjs-wasmfile-release-sync": "^0.32.0", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", "@melt-ui/svelte": "^0.86.2", @@ -136,6 +137,7 @@ "p-limit": "^6.1.0", "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", + "quickjs-emscripten-core": "^0.32.0", "quicktype-core": "^23.2.6", "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 8d758ebf71..9f8c77ce77 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -12,7 +12,7 @@ import type { Schema } from '$lib/common' import type { InputCat, DynamicInput as DynamicInputTypes } from '$lib/utils' import { createEventDispatcher, getContext, onDestroy, untrack, type Snippet } from 'svelte' - import { computeShow } from '$lib/utils' + import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import ArgInput from './ArgInput.svelte' import FieldHeader from './FieldHeader.svelte' @@ -479,8 +479,9 @@ contextArgs[key] = otherArgValue }) - const shouldShow = computeShow(argName, schemaProperty.showExpr, contextArgs) - if (shouldShow || hasJavascript) { + const shouldShow = hasJavascript || computeShow(schemaProperty.showExpr, contextArgs) + if (shouldShow === undefined) return + if (shouldShow) { hidden = false } else if (!hidden) { hidden = true @@ -596,6 +597,7 @@ JSON.stringify(schema) JSON.stringify(arg) JSON.stringify(otherArgs) + quickjsSettled() untrack(() => handleFieldVisibility(schema, arg, otherArgs)) }) diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 093661c1e8..31fb46c97a 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -8,6 +8,7 @@ import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import { evalValue } from './flows/utils.svelte' + import { quickjsReady, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import { memoryPropertyFor } from './flows/flowInfers' import type { FlowModule } from '$lib/gen' import type { PickableProperties } from './flows/previousResults' @@ -155,9 +156,12 @@ loadResourceTypes() let initialized = $state(false) + quickjsReady() + // Inputs mounted before the engine can evaluate fill in their defaults, which then read as + // manual edits and win over the evaluated values. $effect.pre(() => { - if (!initialized) { + if (!initialized && quickjsSettled()) { if (stepsInputArgs) { stepsInputArgs?.updateStepArgs(mod.id, flowStateStore.val, flowStore?.val, previewArgs?.val) initialized = true diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index e35ac291c9..41375a04f6 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -21,6 +21,7 @@ import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' import { agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' + import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -67,7 +68,8 @@ // that follow each flow edit stay quiet, so without this a failing expression is silently // `undefined` in what the run is built from. Manually edited args are preserved across the // refresh by `initializeFromSchema`. - export function runTestWithStepArgs() { + export async function runTestWithStepArgs() { + await quickjsReady() stepsInputArgs?.updateStepArgs( mod.id, flowStateStore.val, diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 872ed9711e..d05c3cce72 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -4,7 +4,8 @@ const bubble = createBubbler() import type { Schema } from '$lib/common' import { VariableService } from '$lib/gen' - import { allTrue, computeShow, type DynamicInput } from '$lib/utils' + import { allTrue, type DynamicInput } from '$lib/utils' + import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import { Button } from './common' import ItemPicker from './ItemPicker.svelte' import VariableEditor from './VariableEditor.svelte' @@ -232,7 +233,9 @@ continue } if (prop?.showExpr) { - if (computeShow(x.value, prop.showExpr, args)) { + const show = computeShow(prop.showExpr, args) + if (show === undefined) continue + if (show) { hidden[x.value] = false } else if (!hidden[x.value]) { hidden[x.value] = true @@ -277,6 +280,7 @@ for (const key in args) { args[key] } + quickjsSettled() } untrack(() => handleHiddenFields(schema, args ?? {})) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 603c0abe36..68c7be1342 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -51,6 +51,7 @@ import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' import { evalValue } from '$lib/components/flows/utils.svelte' +import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, @@ -5951,6 +5952,7 @@ async function agentStepRunForm( .sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity)) const evaluated: Record = {} + await quickjsReady() for (const key of keys) { const value = evalValue(key, module, undefined, false) if (value !== undefined) evaluated[key] = value diff --git a/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte b/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte index 1ed9be896a..b643c62f8b 100644 --- a/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte +++ b/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte @@ -4,6 +4,7 @@ import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte' import { twMerge } from 'tailwind-merge' import { DollarSign, Pencil, RefreshCw, SquareFunction } from 'lucide-svelte' + import { quickjsReady } from '$lib/utils/quickjsEval.svelte' interface Props { inputTransform: Record | undefined @@ -17,7 +18,9 @@ getContext('FlowEditorContext') || {} onMount(() => { - stepsInputArgs?.updateStepArgs(id, flowStateStore?.val, flowStore?.val, previewArgs?.val) + quickjsReady().then(() => + stepsInputArgs?.updateStepArgs(id, flowStateStore?.val, flowStore?.val, previewArgs?.val) + ) }) const input = $derived(stepsInputArgs?.getStepArgs(id)) diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 7104deb382..3efb4b30f0 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -12,6 +12,7 @@ import { import { workspaceStore } from '$lib/stores' import { cleanExpr, emptySchema } from '$lib/utils' import { unescapeTemplateBackticks } from '$lib/utils/templateLiteral' +import { evalSandboxed } from '$lib/utils/quickjsEval.svelte' import { get } from 'svelte/store' import type { FlowModuleState } from './flowState' import { type PickableProperties, dfs } from './previousResults' @@ -23,25 +24,6 @@ import type { ExtendedOpenFlow } from './types' import type { GraphModuleState } from '../graph' import type { ModulesTestStates } from '../modulesTest.svelte' -function create_context_function_template(eval_string: string, context: Record) { - return ` -return function (context) { -"use strict"; -${ - Object.keys(context).length > 0 - ? `let ${Object.keys(context).map((key) => ` ${key} = context['${key}']`)};` - : `` -} -return ${eval_string} -}` -} - -function make_context_evaluator(eval_string, context): (context) => any { - let template = create_context_function_template(eval_string, context) - let functor = Function(template) - return functor() -} - export function evalValue( k: string, mod: FlowModule, @@ -56,11 +38,10 @@ export function evalValue( v = t.value } else if (t?.type == 'javascript') { try { - let context = { + v = evalSandboxed(t.expr, { flow_input: pickableProperties?.flow_input, results: pickableProperties?.priorIds - } - v = make_context_evaluator(t.expr, context)(context) + }) } catch (e) { if (showError) { sendUserToast(`Error evaluating ${k}: ${e.message}`, true) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 23dbacea21..b771b4966e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1500,29 +1500,6 @@ export function orderedJsonStringify(obj: any, space?: string | number) { return JSON.stringify(obj, (Array.from(allKeys) as string[]).sort(), space) } -function evalJs(expr: string) { - let template = ` -return function (fields) { -"use strict"; -return ${expr.startsWith('return ') ? expr.substring(7) : expr} -} -` - let functor = Function(template) - return functor() -} -export function computeShow(argName: string, expr: string | undefined, args: any) { - if (expr) { - try { - let r = evalJs(expr)(args ?? {}) - return r - } catch (e) { - console.error(`Impossible to eval ${expr}:`, e) - return true - } - } - return true -} - function urlizeTokenInternal(token: string, formatter: 'html' | 'md'): string { if (token.startsWith('http://') || token.startsWith('https://')) { if (formatter == 'html') { diff --git a/frontend/src/lib/utils/quickjsEval.svelte.ts b/frontend/src/lib/utils/quickjsEval.svelte.ts new file mode 100644 index 0000000000..d7ae2002af --- /dev/null +++ b/frontend/src/lib/utils/quickjsEval.svelte.ts @@ -0,0 +1,249 @@ +/** + * Runs the JS expressions a schema or flow carries (a field's `showExpr`, a step input's test + * prefill). They are written by whoever can edit the item but run in the browser of whoever + * opens it, so they must not run in the page: there they would act with the viewer's session. + * + * They run in QuickJS compiled to WebAssembly, a separate engine with no page, storage, cookie or + * network to reach. Each evaluation gets a fresh runtime, bounded in time and memory. Values cross + * as JSON both ways. + */ +import type { QuickJSWASMModule } from 'quickjs-emscripten-core' + +const TIMEOUT_MS = 1000 +// WebAssembly memory never shrinks, so one greedy expression would keep the engine's peak forever. +const MEMORY_LIMIT = 256 * 1024 * 1024 +// The engine runs on the page's own stack. Past this, it raises a catchable "stack overflow"; +// without it, the page's stack overflows mid-step and leaves the runtime unfreeable. 192 KB already +// lets deeply nested JSON through to the page's limit. +const STACK_LIMIT = 128 * 1024 + +let engine = $state.raw() +let failed = $state(false) +let loading: Promise | undefined + +/** Resolves once expressions can be evaluated; starts loading the engine on first call. */ +export function quickjsReady(): Promise { + loading ??= Promise.all([ + import('quickjs-emscripten-core'), + import('@jitl/quickjs-wasmfile-release-sync'), + import('@jitl/quickjs-wasmfile-release-sync/wasm?url') + ]) + .then(([core, variant, wasm]) => + core.newQuickJSWASMModuleFromVariant( + // Outside a browser (tests) the URL is not fetchable; the variant reads its own file. + import.meta.env.SSR + ? variant.default + : core.newVariant(variant.default, { wasmLocation: wasm.default }) + ) + ) + .then( + (m) => (engine = m), + (e) => { + console.error('Could not load the expression engine', e) + failed = true + } + ) + return loading +} + +/** Whether `evalSandboxed` can answer; reactive. Starts no load: `quickjsReady` does. */ +export function quickjsSettled(): boolean { + return engine !== undefined || failed +} + +// Copying into the engine costs about 10ms per MB on the page thread, on every evaluation. +const MAX_TEXT = 1024 * 1024 +const MAX_COPY = 5 * 1024 * 1024 + +const IDENT = '[A-Za-z_$][\\w$]*' +// Only escapes JSON accepts: `literal` reads the match with `JSON.parse`, and `"\x41"` is left to +// the engine. +const STRING = String.raw`(?:"(?:[^"\\\u0000-\u001f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*"|'(?:[^'\\\u0000-\u001f]|\\['"\\/bfnrt]|\\u[0-9a-fA-F]{4})*')` +const KEY = `\\s*(?:\\.\\s*(${IDENT})|\\[\\s*(${STRING})\\s*\\])` +const ANY_KEY = `\\s*(?:\\.\\s*${IDENT}|\\[\\s*${STRING}\\s*\\])` +const LOOKUP = new RegExp( + `^\\s*(${IDENT})((?:${ANY_KEY})*)\\s*` + + `(?:(===|!==|==|!=)\\s*(${STRING}|-?(?:0|[1-9]\\d*)(?:\\.\\d+)?|true|false|null)\\s*)?;?\\s*$` +) + +/** What `expr` returns. A leading line break would otherwise follow `return` and end the + * statement, while `LOOKUP` skips it: the two paths would disagree. */ +function bodyOf(expr: string): string { + return (expr.startsWith('return ') ? expr.slice(7) : expr).trimStart() +} + +/** A literal `LOOKUP` matched; a single-quoted string is rewritten as JSON first. */ +function literal(text: string): unknown { + if (text[0] !== "'") return JSON.parse(text) + const body = text + .slice(1, -1) + .replace(/\\'|\\.|"/g, (m) => (m === "\\'" ? "'" : m === '"' ? '\\"' : m)) + return JSON.parse(`"${body}"`) +} + +/** + * Answers `name.key["key"]…`, optionally compared to a JSON literal, straight from `scope`: reading + * data and comparing it to a constant runs no code, so it needs no sandbox and copies nothing. + * Anything else, including a key that is not the data's own, is left to the engine. + */ +function cheapAnswer(body: string, scope: Record): { value: unknown } | undefined { + // `LOOKUP` backtracks quadratically on long malformed input, and runs before the engine's time + // limit: an author could freeze the viewer's page. Real conditions are far shorter. + if (body.length > 1000) return undefined + const m = LOOKUP.exec(body) + if (!m || !Object.hasOwn(scope, m[1])) return undefined + let value = scope[m[1]] + for (const k of m[2].matchAll(new RegExp(KEY, 'g'))) { + const key = k[1] ?? (literal(k[2]) as string) + if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return undefined + value = value[key] + } + if (!m[3]) return { value } + if (value !== null && typeof value === 'object') return undefined + const other = literal(m[4]) + const equal = m[3].length === 3 ? value === other : value == other + return { value: m[3].startsWith('=') ? equal : !equal } +} + +/** Text over `MAX_TEXT`, such as an uploaded file, crosses as this instead of its content. */ +function tooLarge(text: string) { + return { tooLarge: true, length: text.length, size: `${(text.length / 2 ** 20).toFixed(1)} MB` } +} + +/** What is left of `budget` once `value` is copied in; stops counting below zero. */ +function remaining(value: unknown, budget: number, placeholders: boolean): number { + if (typeof value === 'string') { + return budget - (placeholders && value.length > MAX_TEXT ? 64 : value.length + 2) + } + if (value === null || typeof value !== 'object') return budget - 8 + for (const key in value) { + budget = remaining(value[key], budget - key.length - 4, placeholders) + if (budget < 0) break + } + return budget +} + +/** `value` as JSON for the engine, refused past `MAX_COPY`. */ +function copyIn(value: unknown, name: string, placeholders: boolean): string { + if (remaining(value, MAX_COPY, placeholders) < 0) { + throw new Error(`${name} is over 5 MB, too large to evaluate in the browser: enter it by hand`) + } + return ( + JSON.stringify(value, (_, v) => + placeholders && typeof v === 'string' && v.length > MAX_TEXT ? tooLarge(v) : v + ) ?? 'undefined' + ) +} + +// Each name an object holds becomes a read-only shell that copies an entry in when it is read, as +// the backend's `results` proxy does: a large step result or upload costs only the expressions +// that read it, and what an expression reads is never guessed from its text. +const SHELLS = `"use strict" +// JSON has no undefined, so the host sends it as this word. +const __decode = (json) => (json === 'undefined' ? undefined : JSON.parse(json)) +function __shell(name) { + const keys = JSON.parse(__keys(name)) + if (keys === null) return __decode(__whole(name)) + const loaded = new Map() + const read = (k) => { + if (!loaded.has(k)) loaded.set(k, __decode(__entry(name, k))) + return loaded.get(k) + } + return new Proxy({}, { + get: (_, k) => (typeof k === 'string' && keys.includes(k) ? read(k) : Object.prototype[k]), + has: (_, k) => keys.includes(k) || k in Object.prototype, + ownKeys: () => keys, + getOwnPropertyDescriptor: (_, k) => + keys.includes(k) ? { get: () => read(k), enumerable: true, configurable: true } : undefined, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false + }) +} +` + +/** + * Evaluates `expr` with the keys of `scope` as its only names. Throws on any error, and while the + * engine is loading: read it in an effect, which re-runs once the engine is in. `placeholders` + * swaps text over 1 MB for its size, for callers that only take the answer's truthiness: a step + * input would keep the placeholder as its value. + */ +export function evalSandboxed( + expr: string, + scope: Record, + placeholders = false +): any { + const body = bodyOf(expr) + const cheap = cheapAnswer(body, scope) + if (cheap) return cheap.value + quickjsReady() + if (!engine) { + throw new Error(`The expression engine ${failed ? 'failed to load' : 'is still loading'}`) + } + const rt = engine.newRuntime() + rt.setMemoryLimit(MEMORY_LIMIT) + rt.setMaxStackSize(STACK_LIMIT) + const deadline = Date.now() + TIMEOUT_MS + rt.setInterruptHandler(() => Date.now() > deadline) + const vm = rt.newContext() + try { + const host: Record string> = { + __keys: (name: string) => { + const v = scope[name] + return v && typeof v === 'object' && !Array.isArray(v) + ? JSON.stringify(Object.keys(v)) + : 'null' + }, + __whole: (name: string) => copyIn(scope[name], name, placeholders), + __entry: (name: string, key: string) => + copyIn((scope[name] as Record)[key], `${name}.${key}`, placeholders) + } + for (const [fnName, fn] of Object.entries(host)) { + // The interrupt handler runs only between engine steps, not during these copies. + const handle = vm.newFunction(fnName, (...args) => { + if (Date.now() > deadline) throw new Error('Interrupted') + return vm.newString(fn(...args.map((a) => vm.getString(a)))) + }) + vm.setProp(vm.global, fnName, handle) + handle.dispose() + } + const names = Object.keys(scope) + const code = `${SHELLS} +${names.map((n) => `const ${n} = __shell(${JSON.stringify(n)})`).join('\n')} +;(() => { +return ${body} +})()` + const result = vm.unwrapResult(vm.evalCode(code)) + try { + return vm.dump(result) + } finally { + result.dispose() + } + } finally { + // A runtime interrupted by the page's own stack overflow fails its leak check here, and that + // error would replace the one the expression raised. Its memory is lost either way. + try { + vm.dispose() + rt.dispose() + } catch {} + } +} + +/** + * Whether a field with `expr` as its `showExpr` is shown for `args`, or undefined while the engine + * loads. Undefined means decide nothing: hiding a field deletes its value. + */ +export function computeShow(expr: string | undefined, args: any): boolean | undefined { + if (!expr) return true + const scope = { fields: args ?? {} } + const cheap = cheapAnswer(bodyOf(expr), scope) + if (cheap) return Boolean(cheap.value) + quickjsReady() + if (!quickjsSettled()) return undefined + try { + return Boolean(evalSandboxed(expr, scope, true)) + } catch (e) { + console.error(`Impossible to eval ${expr}:`, e) + return true + } +} diff --git a/frontend/src/lib/utils/quickjsEval.test.ts b/frontend/src/lib/utils/quickjsEval.test.ts new file mode 100644 index 0000000000..505ecc1b6e --- /dev/null +++ b/frontend/src/lib/utils/quickjsEval.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { computeShow, evalSandboxed, quickjsReady } from './quickjsEval.svelte' + +describe('quickjsEval', () => { + it('answers plain lookups and comparisons without the engine', () => { + const results = { 'my step': { rows: [1, 2] }, a: { mode: 'x' } } + expect(computeShow('fields.mode === "advanced"', { mode: 'advanced' })).toBe(true) + expect(computeShow('return fields.n != 0', { n: 0 })).toBe(false) + expect(evalSandboxed('results["my step"].rows', { results })).toBe(results['my step'].rows) + expect(evalSandboxed('results.a.mode == "x"', { results })).toBe(true) + expect(computeShow("fields.output_type !== 'image'", { output_type: 'image' })).toBe(false) + expect(computeShow(`fields['q'] === 'it\\'s "x"'`, { q: `it's "x"` })).toBe(true) + const started = performance.now() + expect(computeShow('fields' + ' '.repeat(30_000) + '!', {})).toBeUndefined() + expect(performance.now() - started).toBeLessThan(50) + }) + + it('decides nothing until the engine is in', async () => { + expect(computeShow('fields.a + 1 === 2', { a: 1 })).toBeUndefined() + expect(computeShow('fields.constructor', { a: 1 })).toBeUndefined() + await quickjsReady() + expect(computeShow('fields.a + 1 === 2', { a: 1 })).toBe(true) + expect(computeShow('\nfields.a + 1 === 2', { a: 1 })).toBe(true) + expect(computeShow('fields.x === "\\x41"', { x: 'A' })).toBe(true) + expect(computeShow('fields["\\x41"] === 1', { A: 1 })).toBe(true) + expect(computeShow('fields.', {})).toBe(true) + expect(computeShow('fields.n === 01', { n: 2 })).toBe(true) + }) + + it('reaches nothing of the page and stops runaway expressions', async () => { + await quickjsReady() + const scope = { fields: {} } + expect(evalSandboxed('typeof window + typeof document + typeof fetch', scope)).toBe( + 'undefinedundefinedundefined' + ) + expect(() => evalSandboxed('(() => { while (true) {} })()', scope)).toThrow() + expect(evalSandboxed('1 + 1', scope)).toBe(2) + expect(evalSandboxed('1 + 1', {})).toBe(2) + expect(() => + evalSandboxed('(() => { function f() { return f() } return f() })()', scope) + ).toThrow(/stack overflow/) + const rows = Array.from({ length: 110_000 }, (_, i) => `item-${i}-xxxxxx`) + const started = performance.now() + expect(() => + evalSandboxed('(() => { for (let i = 0; i < 300; i++) __entry("r", "rows") })()', { + r: { rows } + }) + ).toThrow() + expect(performance.now() - started).toBeLessThan(3000) + }) + + it('copies in only the entries an expression reads', async () => { + await quickjsReady() + const results = { + a: { x: 1 }, + b: { y: 2 }, + big: { rows: Array.from({ length: 300_000 }, (_, i) => ({ i, s: 'xxxxxxxxxx' })) } + } + expect(evalSandboxed('`${results.a.x}`', { results })).toBe('1') + expect(evalSandboxed('((k) => results[k].y)("b")', { results })).toBe(2) + expect(evalSandboxed('Object.keys(results).length', { results })).toBe(3) + expect(evalSandboxed('({ ...small }).b.y', { small: { a: results.a, b: results.b } })).toBe(2) + expect(evalSandboxed('results.hasOwnProperty("a") && "a" in results', { results })).toBe(true) + expect(evalSandboxed('JSON.stringify({ a: results.a, b: results.b })', { results })).toBe( + '{"a":{"x":1},"b":{"y":2}}' + ) + expect(() => evalSandboxed('results.big.rows.length + 0', { results })).toThrow( + /results.big is over 5 MB/ + ) + expect(() => evalSandboxed('(results.a = 1)', { results })).toThrow() + expect(evalSandboxed('"gpt-" + 4', { flow_input: undefined, results: undefined })).toBe('gpt-4') + expect( + evalSandboxed('fields.optional ?? "fallback"', { fields: { optional: undefined } }) + ).toBe('fallback') + }) + + it('stands in for large text only where the answer is a yes or no', async () => { + await quickjsReady() + const file = 'x'.repeat(2 * 2 ** 20) + expect(computeShow('fields.file.tooLarge && fields.file.length > 0', { file })).toBe(true) + expect(evalSandboxed('flow_input.file || ""', { flow_input: { file } })).toBe(file) + const huge = 'x'.repeat(6 * 2 ** 20) + expect(() => evalSandboxed('flow_input.file || ""', { flow_input: { file: huge } })).toThrow( + /flow_input.file is over 5 MB/ + ) + }) +}) From f7c7d7ffd874533200b96823db891cf6b7114f85 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 5 Oct 2026 12:33:01 +0200 Subject: [PATCH 02/57] refactor: use the session tab strip for workflow sdk module tabs (#11522) * refactor: use the session tab strip for workflow sdk module tabs Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep module deletion on the explicit button and tab order view-only Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: use the design-system button for module deletion Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let modified keys through file tabs and keep keyboard rename off the tab Co-Authored-By: Claude Opus 5.5 (1M context) * fix: route modified enter past the dnd zone so page shortcuts fire from draggable tabs Co-Authored-By: Claude Opus 5.5 (1M context) * fix: forward modified enter from dnd item wrappers too Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/ScriptEditor.svelte | 154 ++++++++++-------- .../common/tabs/DraggableTabs.svelte | 22 ++- 2 files changed, 103 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index e3215aa9cf..1cabcf8265 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -81,6 +81,7 @@ import type { ScriptEditorWhitelabelCustomUi } from './custom_ui' import Tabs from './common/tabs/Tabs.svelte' import Tab from './common/tabs/Tab.svelte' + import DraggableTabs, { type TabItem } from './common/tabs/DraggableTabs.svelte' import { slide } from 'svelte/transition' import CaptureTable from '$lib/components/triggers/CaptureTable.svelte' import CaptureButton from './triggers/CaptureButton.svelte' @@ -407,6 +408,30 @@ let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + // A module path always ends in a file extension, so this id can't collide with one. + const MAIN_FILE_TAB_ID = '__main__' + // Tab order is view state only: `modules` is stored as JSONB, which re-sorts its keys on + // deploy, so an order written into it would not survive. + let moduleTabOrder: string[] = $state([]) + let fileTabs = $derived.by(() => { + const paths = Object.keys(modules ?? {}) + const rank = new Map(paths.map((p, i) => [p, i + moduleTabOrder.length])) + moduleTabOrder.forEach((p, i) => rank.has(p) && rank.set(p, i)) + return [ + { id: MAIN_FILE_TAB_ID, label: mainFileName, closable: false, pinned: 'left' }, + // Not closable through the strip: closing deletes the module's code, so it stays on + // the explicit × rather than Delete/Backspace and middle-click. + ...paths + .sort((a, b) => rank.get(a)! - rank.get(b)!) + .map((modulePath) => ({ + id: modulePath, + label: modulePath, + title: modulePath, + closable: false + })) + ] + }) + let modulePathInput = $state('') let showAddModulePopover = $state(false) let modulePathInputEl: HTMLInputElement | undefined = $state(undefined) @@ -561,6 +586,7 @@ delete modules[modulePath] delete moduleTestState[modulePath] modules = { ...modules } + moduleTabOrder = moduleTabOrder.filter((p) => p !== modulePath) } function validateRenameModulePath(newPath: string, oldPath: string): string { @@ -591,6 +617,11 @@ return 'path' in canonical && canonical.path === oldPath } + function prefillRename(modulePath: string) { + renameModuleInput = modulePath + renameModuleError = '' + } + function renameModule(oldPath: string) { if (!renameModuleInput.trim()) return const canonical = canonicalModulePath(renameModuleInput) @@ -611,6 +642,7 @@ delete modules[oldPath] modules[newPath] = { ...mod, language: newLang ?? mod.language } modules = { ...modules } + moduleTabOrder = moduleTabOrder.map((p) => (p === oldPath ? newPath : p)) if (moduleTestState[oldPath]) { moduleTestState[newPath] = moduleTestState[oldPath] delete moduleTestState[oldPath] @@ -2563,93 +2595,71 @@ {#snippet editorContent()}
{#if supportsModules} -
(id === MAIN_FILE_TAB_ID ? switchToMain() : switchToModule(id))} + onReorder={(next) => (moduleTabOrder = next.map((t) => t.id))} + class="shrink-0 border-b border-light bg-surface-secondary/50" > - - {#each Object.keys(modules ?? {}) as modulePath} -
- -
+ {#snippet tabAccessory(tab)} + {#if tab.id !== MAIN_FILE_TAB_ID} + + e.stopPropagation()}> prefillRename(tab.id), + onkeydown: (e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && prefillRename(tab.id) + }} > {#snippet trigger()} { - e.stopPropagation() - renameModuleInput = modulePath - renameModuleError = '' - }} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - renameModuleInput = modulePath - renameModuleError = '' - } - }} + class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center" > - + {/snippet} {#snippet content({ close })} - {@render renameModuleForm(modulePath, close)} + {@render renameModuleForm(tab.id, close)} {/snippet} - { - e.stopPropagation() - removeModule(modulePath) - }} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - removeModule(modulePath) - } - }} - > - - -
-
- {/each} - - {#snippet trigger()} - - - {/snippet} - {#snippet content({ close })} - {@render addModuleForm(close)} - {/snippet} - -
+
- {/if} - {/each} + ... + + ...... + + {row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'} + + {:else} +
+ {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
+ {/if} + {/each} + + - - Where dbt projects in this workspace run. A project names a warehouse by name in its descriptor (profile.warehouse) and reaches - {DEFAULT_WAREHOUSE} when it names none, so a project carries no - connection of its own. The name is also what its tables are keyed on in the asset graph (dbt://{DEFAULT_WAREHOUSE}/schema/table), so two projects on one warehouse share their nodes. Each entry points either at one of - Windmill's own connection resources, whose fields are translated into a dbt target, or at a - dbt_profile resource, which carries a - profiles.yml target as it is and so reaches any adapter dbt has. Configuring - one here is what makes it available: anyone who may run a dbt script builds with it and reads its models, - without being granted the resource, the same bargain workspace object storage makes. - +profile.warehouse in its descriptor, and gets ${DEFAULT_WAREHOUSE} when it names none. Point each warehouse at a Windmill connection resource, or at a dbt_profile resource holding a profiles.yml target for any other adapter. Anyone who can run a dbt script builds with these warehouses without being granted the resource.`} + link="https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt" +/> @@ -154,14 +146,10 @@ class="min-w-24" /> - - +
+ +
From 71901852fb3cc40515d6675a0a88cd158e942d56 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 7 Oct 2026 11:57:03 +0200 Subject: [PATCH 43/57] feat: set up a new app in one modal over its AI session, fix its deploy (#11582) * fix: set up a new app before starting with AI and follow its deploy Co-Authored-By: Claude Opus 5.5 (1M context) * feat: set up a new app in one modal over its ai session Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schema creation progress and put the new app modal over the page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: say when no worker can create a new app's schema instead of waiting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the summary from the new app modal Co-Authored-By: Claude Opus 5.5 (1M context) * feat: list existing tables only under an existing schema, explore from the schema row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: start a new app from two cards, with ai recommended Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/common/ViewportOverlays.svelte | 10 + .../components/common/overlayHost.svelte.ts | 6 + .../copilot/chat/global/core.test.ts | 1 + .../components/copilot/chat/global/core.ts | 28 +- .../raw_apps/RawAppDataTableDrawer.svelte | 9 +- .../raw_apps/RawAppTemplatePicker.svelte | 661 ++++++++++-------- .../components/sessions/PreviewTabHost.svelte | 16 +- .../sessions/RawAppEditorView.svelte | 93 ++- .../sessions/SessionEditorTarget.svelte | 10 +- .../sessions/sessionPreviewTabs.svelte.ts | 20 + .../sessions/sessionPreviewTabs.test.ts | 15 + .../sessions/sessionRuntime.svelte.ts | 47 +- .../sessions/sessionSwitch.svelte.ts | 20 +- .../(root)/(logged)/apps_raw/add/+page.ts | 37 +- .../apps_raw/edit/[...path]/+page.svelte | 56 +- 15 files changed, 639 insertions(+), 390 deletions(-) create mode 100644 frontend/src/lib/components/common/ViewportOverlays.svelte diff --git a/frontend/src/lib/components/common/ViewportOverlays.svelte b/frontend/src/lib/components/common/ViewportOverlays.svelte new file mode 100644 index 0000000000..59dc2e55b6 --- /dev/null +++ b/frontend/src/lib/components/common/ViewportOverlays.svelte @@ -0,0 +1,10 @@ + + +{@render children?.()} diff --git a/frontend/src/lib/components/common/overlayHost.svelte.ts b/frontend/src/lib/components/common/overlayHost.svelte.ts index e556b51e67..824e239519 100644 --- a/frontend/src/lib/components/common/overlayHost.svelte.ts +++ b/frontend/src/lib/components/common/overlayHost.svelte.ts @@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) { setContext(OVERLAY_HOST_KEY, host) } +/** Hand this subtree's overlays back to the viewport, for one that speaks for the whole + * page rather than the pane it happens to be opened from. */ +export function clearOverlayHost() { + setContext(OVERLAY_HOST_KEY, undefined) +} + export function getOverlayHost(): OverlayHostContext | undefined { return getContext(OVERLAY_HOST_KEY) } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index dd89a1fbec..692c78bfb8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4709,6 +4709,7 @@ describe('global AI tools', () => { expect(onDeployed).toHaveBeenCalledWith({ sessionId: 'sess-123', kind: 'raw_app', + storagePath: 'f/apps/report', path: 'f/apps/report' }) } finally { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e75f15d1eb..41bbad5abe 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -5038,6 +5038,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise void @@ -8651,24 +8654,29 @@ async function deployDraft( // synthetic storage key never exists deployed, so the entry would otherwise // stop matching anything after the draft is gone. const deployedKind = itemKindFor(type, triggerKind) + const draftStoragePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) if (deployedKind) { - toolCallbacks.onItemDeployed?.( - deployedKind, - getGlobalDraftStoragePath(workspace, type, path, triggerKind), - deployedPath - ) + toolCallbacks.onItemDeployed?.(deployedKind, draftStoragePath, deployedPath) } - // Reload the session preview if it's open on the deployed item. Map the - // deploy type to the preview kind — a raw app deploys under 'app' but the - // preview addresses it as 'raw_app'; non-previewable types map to undefined. + // Reload the session preview if it's open on the deployed item, which its editor + // knows by the draft's storage path. Map the deploy type to the preview kind — a + // raw app deploys under 'app' but the preview addresses it as 'raw_app'; + // non-previewable types map to undefined. const previewKindByType: Partial> = { script: 'script', flow: 'flow', app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) + if (kind) { + deployedInSessionHandler?.({ + sessionId, + kind, + storagePath: draftStoragePath, + path: deployedPath + }) + } toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -8942,8 +8950,6 @@ export function prepareGlobalUserMessage( content += `path: ${activeEditor.path}\n` content += `isLiveDraft: true\n` if (activeEditor.isNew) { - // The template only lives in the open editor until the first edit saves it, so - // init_app would see no draft and overwrite it with a second app. content += 'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one.\n' } diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 7ccf89a275..af0af7ed5b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -256,11 +256,14 @@ selectedRole = undefined } - export function openDrawer() { + /** Opens on `at` when given, a data table and optionally one of its schemas. */ + export function openDrawer(at?: { datatable: string; schema?: string }) { resetSelection() - selectedSchemaKey = undefined + selectedSchemaKey = at?.schema selectedTableKey = undefined - selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0]) + selectDatatable( + at?.datatable ?? (datatables.current.includes('main') ? 'main' : datatables.current[0]) + ) expand = false open = true } diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index 27ebdbda85..0928ddce7a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -6,13 +6,13 @@ List, Ban, ExternalLinkIcon, - Loader2, Code, - MessagesSquare, - WandSparkles + Database, + Loader2 } from 'lucide-svelte' - import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - import type { Policy } from '$lib/gen' + import { WorkerService, type Policy } from '$lib/gen' + import MissingWorkerTagAlert from '$lib/components/jobs/MissingWorkerTagAlert.svelte' + import { hasWorkerForTag, queuedWithoutWorkerMessage } from '$lib/components/jobs/missingWorker' import { superadmin, userStore } from '$lib/stores' import { base } from '$lib/base' import { sendUserToast } from '$lib/toast' @@ -22,8 +22,7 @@ import Select from '$lib/components/select/Select.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { Alert } from '$lib/components/common' - import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { Alert, Badge } from '$lib/components/common' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { react18Template, react19Template, svelte5Template } from './templates' @@ -52,11 +51,10 @@ files: Record runnables: Record data: RawAppData - summary: string policy: Policy } - /** `ai` hands the new app to the AI right away; `code` opens the editor on the setup's choices. */ + /** Where the set-up app opens: `ai` hands it to the AI, `code` opens it in the editor. */ export type RawAppBuildMode = 'ai' | 'code' let { @@ -68,18 +66,16 @@ } = $props() const templates = [ - { name: 'React 19', icon: 'tsx', files: react19Template }, + { name: 'React 19', icon: 'tsx', files: react19Template, recommended: true }, { name: 'React 18', icon: 'tsx', files: react18Template }, { name: 'Svelte 5', icon: 'svelte', files: svelte5Template } ] - let step = $state<'choice' | 'setup'>('choice') let selectedTemplateIndex = $state(0) let selectedDatatable = $state(undefined) let schemaMode = $state<'none' | 'new' | 'existing'>('new') let selectedSchema = $state(undefined) let newSchemaName = $state('') - let appSummary = $state('') let preWhitelistedTables = $state([]) /** The role each pre-whitelisted table's data table was browsed as. */ let preWhitelistedRoles = $state>({}) @@ -271,12 +267,7 @@ // would announce AI as unconfigured while it is merely unknown. Gate on the // config describing opWs, and load it here so the claim owns its own evidence. const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) - // Say where the card leads: the route hands the app to a fresh AI session for - // everyone who has one, and opens the docked chat for the rest. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) - // The AI starts on the default data table and its role, so both must have answered. - const dataSettled = $derived(hasNoDatatables || (rolesSettled && accessSettled)) $effect(() => { if (open && opWs && !aiConfigLoaded) { @@ -285,9 +276,11 @@ }) // With AI turned off for the workspace there is nothing to choose between. - const shownStep = $derived($copilotInfo.workspaceDisabled ? 'setup' : step) - const choiceCardClasses = - 'h-full flex-col items-start justify-start gap-3 rounded-lg p-6 text-left whitespace-normal' + const aiOffered = $derived(!$copilotInfo.workspaceDisabled) + + const canStart = $derived( + !!templates[selectedTemplateIndex] && !newSchemaAlreadyExists && rolesSettled && accessSettled + ) function appPolicy(): Policy { return { @@ -299,60 +292,103 @@ } } - /** The AI builds on the default template and the default data table: it picks the framework - * and creates the tables it needs itself. */ - function buildWithAI() { - const datatable = usableDatatable - const role = rolesAnswered ? effectiveRole : undefined - open = false - onStart( - { - files: templates[0].files, - runnables: {}, - data: { - tables: [], - datatable, - schema: undefined, - roles: datatable && role ? { [datatable]: role } : undefined - }, - summary: '', - policy: appPolicy() - }, - 'ai' + const startCardClasses = + 'relative h-full flex-col items-start justify-start gap-1.5 rounded-lg p-3 text-left whitespace-normal' + + async function createSchema(input: { + workspace: string + datatable: string + schema: string + role: string | undefined + migrationRole: string | undefined + }) { + try { + const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') + const dbOps = dbSchemaOpsWithPreviewScripts({ + workspace: input.workspace, + input: { + type: 'database', + resourceType: 'postgresql', + resourcePath: `datatable://${input.datatable}`, + role: input.role, + migrationRole: input.migrationRole + } + }) + await dbOps.onCreateSchema({ schema: input.schema }) + } catch (e) { + console.error('Failed to create schema:', e) + sendUserToast(`Failed to create schema ${input.schema}: ${e}`, true) + } + } + + // The start that is in flight, and the schema it is waiting on: creating one is a job, + // which can sit in the queue long enough for a silent modal to look dead. + let starting = $state<{ mode: RawAppBuildMode; schema?: string } | undefined>(undefined) + + const SCHEMA_JOB_TAG = 'postgresql' + + /** Whether a worker can pick up the job that creates a schema. Only a definite "no" + * counts: with per-workspace default tags the job runs on a variant of the tag, and a + * failed lookup establishes nothing. */ + async function schemaJobsServed(workspace: string): Promise { + try { + if (await WorkerService.isDefaultTagsPerWorkspace()) return true + return await hasWorkerForTag(workspace, SCHEMA_JOB_TAG) + } catch { + return true + } + } + + // Existing tables go with an existing schema: under the other modes the list is not shown, + // and what is not shown is not saved. + const tablesToUse = $derived(schemaMode === 'existing' ? preWhitelistedTables : []) + + function exploreDatatable() { + dataTableDrawer?.openDrawer( + selectedDatatable + ? { + datatable: selectedDatatable, + schema: schemaMode === 'existing' ? selectedSchema : undefined + } + : undefined ) } - async function start() { + async function start(mode: RawAppBuildMode) { + if (starting) return const template = templates[selectedTemplateIndex] if (schemaMode === 'new' && newSchemaName && usableDatatable && opWs) { - try { - const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') - const dbOps = dbSchemaOpsWithPreviewScripts({ - workspace: opWs, - input: { - type: 'database', - resourceType: 'postgresql', - resourcePath: `datatable://${usableDatatable}`, - role: effectiveRole, - migrationRole: defaultMigrationRole( - usableDatatable, - roles.current.permissioned, - roles.current.defaultRole - ) - } - }) - await dbOps.onCreateSchema({ schema: newSchemaName }) - } catch (e) { - console.error('Failed to create schema:', e) - sendUserToast(`Failed to create schema: ${e}`, true) + starting = { mode, schema: newSchemaName } + const creation = createSchema({ + workspace: opWs, + datatable: usableDatatable, + schema: newSchemaName, + role: effectiveRole, + migrationRole: defaultMigrationRole( + usableDatatable, + roles.current.permissioned, + roles.current.defaultRole + ) + }) + if (await schemaJobsServed(opWs)) { + await creation + } else { + // The job stays queued for as long as no worker serves its tag: waiting on it + // would hold the app back for nothing, so say why the schema is missing instead. + sendUserToast( + `Schema "${newSchemaName}" is not created yet. ${queuedWithoutWorkerMessage(SCHEMA_JOB_TAG)}`, + true + ) } } - const formattedTables = preWhitelistedTables.map(formatDataTableRef) + starting = undefined + + const formattedTables = tablesToUse.map(formatDataTableRef) const keepsDatatable = usableDatatable !== undefined // The roles shown, for the data tables the app ends up using. - const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable)) + const usedDatatables = new Set(tablesToUse.map((t) => t.datatable)) if (keepsDatatable) usedDatatables.add(usableDatatable!) const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt)) const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined @@ -371,10 +407,9 @@ files: template.files, runnables: {}, data, - summary: appSummary.trim(), policy: appPolicy() }, - 'code' + mode ) } @@ -385,240 +420,181 @@ Modal closes its own UI but the picker's `open` prop stays true, so the route's `templatePicker → false` watcher never fires and autosave stays suspended after the dismissal. --> - - {#if shownStep === 'choice'} -
-
- - + +
+
+

Framework

+
+ {#each templates as t, i} + + {/each}
-

You can always switch later.

- {#if aiConfigLoaded && !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. +
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended.
{#if $userStore?.is_admin} - Configure AI in + Configure datatables in workspace settings - - {#if $superadmin} - or - instance settings - - {/if} to enable this feature. - {:else if $superadmin} - Configure AI in - instance settings to enable this feature. {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. {/if}
- {/if} -
- {:else} -
-
-

Summary

- -
- -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable - this feature. - {/if} -
- {:else} -
-
- Default settings for new tables -
-
-
- -
+ {:else} +
+ +
+ Default settings for new tables +
+
+
+ +
+ ({ value: r, label: r }))} + bind:value={() => selectedRole, pickRole} + clearable={false} + placeholder="Role" size="sm" class="w-40" /> - {#if showRolePicker} - - as - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists + {rolesUnknown + ? 'could not read its roles' + : noUsableRole + ? 'no role you can use' + : 'could not reach it'}; the app is created without a default data + table + {/if}
+
+ Schema +
+
+ + {#snippet children({ item })} + + + + {/snippet} + +
+