feat: give app builder operators app tools in AI sessions (#11716)

* feat: give app builder operators app tools in AI sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: start builders' session apps from the builder starter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note the session app editor conforms builder app policies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-10-13 13:23:20 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 20a33f1141
commit 133613c1ad
14 changed files with 278 additions and 108 deletions
+2 -2
View File
@@ -111,8 +111,8 @@ rule added for deploys must hold for drafts too. Where the two differ, `write` d
fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips
`triggerables_v2` and must not name another user's run identity. Every frontend writer of a
builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded
from someone else's app breaks these rules: the editor and the AI chat do. The AI Sessions raw-app
editor, open to operators only behind a per-browser opt-in, does not, so its autosaves are refused.
from someone else's app breaks these rules: the raw-app editor component does, which covers both
the standalone editor and the AI Sessions one, and so does the AI chat.
The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs
the bundle same-origin with the session of whoever opens it. A builder's draft is therefore
@@ -67,15 +67,16 @@
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
undefined
)
const editRights = useEditRights()
const canWriteApp = $derived(
!!appPerms && editRights.canEditItem('app', appPerms.path, appPerms.extraPerms)
)
/** Raw vs low-code, read from the app itself rather than from the route:
* both kinds render here and either route serves either kind (links to a raw
* app point at /apps/get all over the app), so only the app can say which
* editor the Edit button must open. */
let isRawApp = $state(false)
const editRights = useEditRights()
const canWriteApp = $derived(
!!appPerms &&
editRights.canEditItem(isRawApp ? 'raw_app' : 'app', appPerms.path, appPerms.extraPerms)
)
/** The app's execution mode, from the same lite read as the permissions and for the same
* reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays
* undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is
@@ -55,6 +55,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte'
import { quickjsReady } from '$lib/utils/quickjsEval.svelte'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import {
BUILDER_FRAMEWORK_TEMPLATES,
FRAMEWORK_TEMPLATES,
STARTER_RUNNABLES,
type FrameworkKey
@@ -221,6 +222,7 @@ import {
NONE,
RUN_FLOW_PREVIEW,
RUN_PREVIEW,
WRITE_APP_DRAFT,
WRITE_DRAFT,
WRITE_FLOW_DRAFT,
type SessionAccess,
@@ -1206,16 +1208,20 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({
)
})
/** A builder operator: the flow editor, and the deployed page for everything else. */
const openPreviewFlowEditorSchema = openPreviewSchema.extend({
kind: openPreviewNoPipelineKind,
mode: z
.enum(['edit', 'view'])
.optional()
.describe(
'Which side to show. "edit" opens the flow editor and works for flows only: the only editor you can open in this workspace. Pass "view" for a script or app, the deployed page with its run form, triggers and past runs.'
)
})
/** A builder operator: the editors of the kinds they compose, and the deployed page for
* everything else. */
const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) =>
openPreviewSchema.extend({
kind: openPreviewNoPipelineKind,
mode: z
.enum(['edit', 'view'])
.optional()
.describe(
flows && apps
? 'Which side to show. "edit" opens the flow or app editor and works for flows and raw apps only: the only editors you can open in this workspace. Pass "view" for a script, the deployed page with its run form, triggers and past runs.'
: `Which side to show. "edit" opens the ${flows ? 'flow' : 'app'} editor and works for ${flows ? 'flows' : 'raw apps'} only: the only editor you can open in this workspace. Pass "view" for a script or ${flows ? 'app' : 'flow'}, the deployed page with its run form, triggers and past runs.`
)
})
const OPEN_PREVIEW_DESCRIPTION =
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
@@ -1339,6 +1345,8 @@ const initAppSchema = z.object({
)
})
const INIT_APP_DESCRIPTION = 'Initialize a draft raw app from a framework template.'
// Mirrors the backend VALID_FOLDER_NAME check so an invalid name fails before the
// network round-trip (the server enforces the same rule).
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
@@ -1427,10 +1435,16 @@ const buildGlobalSystemPrompt = (
// rule cannot outlive the tool it describes. An unresolved profile keeps every block.
const canWriteDraft = !access || access.has('write_draft')
const canWriteFlowDraft = !access || access.has('write_flow_draft')
// A builder operator: flow drafts and nothing else. Blocks about drafting in general
// follow `canWriteAnyDraft`; blocks naming scripts, apps or the other kinds keep `canWriteDraft`.
const flowsOnly = canWriteFlowDraft && !canWriteDraft
const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft
const canWriteAppDraft = !access || access.has('write_app_draft')
// A builder operator: drafts of the kinds the workspace lets them compose, and nothing else.
// Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the
// other kinds keep `canWriteDraft`.
const buildsFlows = canWriteFlowDraft && !canWriteDraft
const buildsApps = canWriteAppDraft && !canWriteDraft
const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft
const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter(
(k): k is string => !!k
)
const canRunPreview = !access || access.has('run_preview')
const canManageSchedules = !access || access.has('manage_schedules')
const canManageTriggers = !access || access.has('manage_triggers')
@@ -1484,9 +1498,12 @@ The current user's workspace username is "${username}".${instanceLine}
${
canWriteDraft
? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.'
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${flowsOnly ? '' : 'flows, '}scripts, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${flowsOnly ? 'scripts and apps' : 'scripts, flows and apps'} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
flowsOnly,
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
buildsFlows,
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.'
)}${when(
buildsApps,
`\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.`
)}`
}${when(
canWriteAnyDraft,
@@ -1549,7 +1566,7 @@ ${pipelineBullet}`
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.`
)}${when(
flowsOnly && (!access || access.has('run_flow_preview')),
buildsFlows && (!access || access.has('run_flow_preview')),
`
- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.`
)}
@@ -1577,9 +1594,11 @@ ${pipelineBullet}`
!canWriteDraft,
`
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${
flowsOnly
? 'The flow editor is the only editor you can open here: after writing or editing a flow draft, show it with open_preview(kind="flow", path, mode="edit").'
: 'You cannot open item editors in this workspace, so it only ever opens the deployed page.'
buildsFlows && buildsApps
? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").'
: buildsFlows || buildsApps
? `The ${buildsFlows ? 'flow' : 'app'} editor is the only editor you can open here: after writing or editing ${buildsFlows ? 'a flow' : 'an app'} draft, show it with open_preview(kind="${buildsFlows ? 'flow' : 'raw_app'}", path, mode="edit").`
: 'You cannot open item editors in this workspace, so it only ever opens the deployed page.'
}`
)}${when(
canWriteDraft,
@@ -1620,12 +1639,16 @@ Flows:
Raw apps:
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when(
canWriteDraft,
canWriteAppDraft,
`
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app's data.
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${
buildsApps
? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.'
: 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.'
}
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
)}
@@ -2441,7 +2464,10 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
${getScriptPrompt(selected)}`
}
async function getFlowInstructions(workspace: string | undefined): Promise<string> {
async function getFlowInstructions(
workspace: string | undefined,
composeOnly: boolean
): Promise<string> {
const aiAgentProviders = formatAiAgentProvidersPrompt(
await getAiAgentProviderCatalog(workspace),
{
@@ -2457,15 +2483,22 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin
- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.
${
composeOnly
? '- Compose only path modules (`script` / `flow`) that reference items already deployed in the workspace. Rawscript modules and hub scripts are refused for this user.'
: `- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.`
}
## Groups and notes
- Add \`groups\` to any non-trivial flow without being asked; "Organizing Flows: Groups and Notes" in the reference below gives the fields and the rules the editor enforces. Pass \`null\` to \`write_flow\` to clear them, and the same for \`notes\`.
- With \`patch_flow_json\`, edit \`groups\` and \`notes\` the same way as any other field — they appear as top-level keys in the compact flow value.
## Compact view: how rawscript bodies surface in tool I/O
${
composeOnly
? ''
: `## Compact view: how rawscript bodies surface in tool I/O
- \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script.<moduleId>"\` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.
- Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools:
@@ -2476,15 +2509,16 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments are **non-compact** flow modules: inline each rawscript body directly in \`content\` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a \`write_flow\` call comes back with a JSON parse error — create that module with **empty content** (\`"content": ""\`) and fill it with \`set_flow_module_code(path, module_id, code)\`, whose \`code\` is a plain argument with no nested escaping. \`write_flow\` reports which modules still have empty bodies so you know what to fill.
- When overwriting an **existing** flow, set \`"content": "inline_script.<moduleId>"\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.
${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''}
`
}${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''}
# Windmill flow authoring reference
${getFlowPrompt()}`
${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline code (rawscript steps, inline AI agent tools) and hub scripts: the server refuses them. Compose only path steps that reference deployed scripts and flows.\n\n' : ''}${getFlowPrompt()}`
}
type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number]
function getAppInstructions(language?: ScriptLang): string {
function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string {
// getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with
// it: telling the model to re-fetch a reference it is already holding wastes a turn, and
// naming the wrong SDK contradicts the text right below the sentence.
@@ -2496,21 +2530,40 @@ function getAppInstructions(language?: ScriptLang): string {
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${
composeOnly
? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.'
: ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.'
} \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
${
composeOnly
? '- `init_app` starts from a static page with no backend runnables.'
: `- \`init_app\` seeds the starter inline runnables ${Object.keys(STARTER_RUNNABLES)
.map((k) => `\`${k}\``)
.join(
', '
)} so the demo tour works on first render. Replace or remove them once you start building real backend runnables.`
}
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.
${
composeOnly
? '- Every backend runnable is a path runnable (`script` / `flow`) referencing an item already deployed in the workspace, created with `write_app_runnable` and removed with `delete_app_runnable`. Inline and hub runnables are refused for this user.'
: `- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).`
}
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.${
composeOnly
? ''
: `
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.`
}
- When a path runnable points at a draft (see "Draft code vs deployed code" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.
${sdkLine}
- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
${composeOnly ? '' : `${sdkLine}\n`}- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
- Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file; large files are truncated to a head slice, so pass \`offset\`/\`limit\` to page through the rest rather than re-reading the whole file.
- To find where a symbol or string lives across the app, call \`search_app\` (greps every frontend file and inline runnable, returns matching \`file:line\` rows) instead of reading files one by one — then \`read_app_file\` only the ranges you need. The loop is list (\`read_workspace_item\`) → locate (\`search_app\`) → inspect (\`read_app_file\` with \`offset\`/\`limit\`).
# Windmill raw app authoring reference
${getRawAppPrompt(language)}`
${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline runnables, hub scripts and the data setup: the server refuses inline and hub runnables. Every runnable is a path runnable referencing a deployed script or flow.\n\n' : ''}${getRawAppPrompt(language)}`
}
function getResourceInstructions(): string {
@@ -2552,17 +2605,18 @@ function getPipelineInstructions(): string {
function getInstructions(
subject: InstructionSubject,
language: ScriptLang | undefined,
workspace: string | undefined
workspace: string | undefined,
composeOnly: boolean
): string | Promise<string> {
switch (subject) {
case 'script':
return getScriptInstructions(language)
case 'flow':
return getFlowInstructions(workspace)
return getFlowInstructions(workspace, composeOnly)
case 'resource':
return getResourceInstructions()
case 'app':
return getAppInstructions(language)
return getAppInstructions(language, composeOnly)
case 'datatable':
return getDatatableInstructions(language)
case 'pipeline':
@@ -2607,7 +2661,11 @@ export function getSessionContextPromptSection(
// Concatenated onto an already capability-gated prompt, so it has to honour the same
// profile rather than assume the gating happened upstream.
const canDeploy = !access || access.has('deploy')
const canWriteDraft = !access || access.has('write_draft') || access.has('write_flow_draft')
const canWriteDraft =
!access ||
access.has('write_draft') ||
access.has('write_flow_draft') ||
access.has('write_app_draft')
const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview')
// The kinds no deploy rule reaches, unless the workspace withdrew them from this user.
const unruledKinds = [
@@ -3505,10 +3563,15 @@ export const globalTools: SessionTool<{}>[] = [
const canAuthor =
!access ||
access.has('write_draft') ||
(parsed.subject === 'flow' && access.has('write_flow_draft'))
(parsed.subject === 'flow' && access.has('write_flow_draft')) ||
(parsed.subject === 'app' && access.has('write_app_draft'))
if (!canAuthor) {
const message = access?.has('write_flow_draft')
? 'This session can only compose flows, so flow is the one subject with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.'
const composed = [
access?.has('write_flow_draft') && 'flow',
access?.has('write_app_draft') && 'app'
].filter((k): k is string => !!k)
const message = composed.length
? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.`
: 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.'
toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' })
return message
@@ -3518,7 +3581,10 @@ export const globalTools: SessionTool<{}>[] = [
? `${parsed.subject} (${parsed.language})`
: parsed.subject
toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` })
return getInstructions(parsed.subject, parsed.language, ctx.workspace)
// Past the gate without `write_draft` means a builder: guidance for inline code would
// steer the model at writes the server refuses and tools it was not given.
const composeOnly = !!access && !access.has('write_draft')
return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly)
}
},
createSearchHubScriptsTool(false),
@@ -4221,12 +4287,12 @@ export const globalTools: SessionTool<{}>[] = [
// turns on the kind. Each kind's handler, reached through `deployDraft`'s switch:
requires: NONE,
kindRequires: {
// scripts.rs `create_script_internal` and apps.rs `create_app_raw`/`update_app_raw`
// refuse operators, flows.rs `create_flow`/`update_flow` those without builder
// rights; all run the rules.
// scripts.rs `create_script_internal` refuses operators, flows.rs `create_flow`/
// `update_flow` and apps.rs `create_app_raw`/`update_app_raw` those without the
// matching builder right; all run the rules.
script: ['deploy', 'manage_code'],
flow: ['deploy', 'manage_flows'],
app: ['deploy', 'manage_code'],
app: ['deploy', 'manage_apps'],
resource: DEPLOY,
variable: DEPLOY,
// No deploy rules here, but `gate_operator_writes` on their routers.
@@ -4254,7 +4320,7 @@ export const globalTools: SessionTool<{}>[] = [
kindRequires: {
script: WRITE_DRAFT,
flow: WRITE_FLOW_DRAFT,
app: WRITE_DRAFT,
app: WRITE_APP_DRAFT,
resource: WRITE_DRAFT,
variable: WRITE_DRAFT,
schedule: WRITE_DRAFT,
@@ -4304,10 +4370,10 @@ export const globalTools: SessionTool<{}>[] = [
kindRequires: {
// scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives.
script: ['admin'],
// apps.rs `delete_app` refuses operators, flows.rs `delete_flow_by_path` those
// without builder rights.
// flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators without
// the matching builder right.
flow: ['deploy', 'manage_flows'],
app: ['deploy', 'manage_code'],
app: ['deploy', 'manage_apps'],
resource: DEPLOY,
variable: DEPLOY,
schedule: ['manage_schedules'],
@@ -4452,13 +4518,15 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
def: createToolDef(
initAppSchema,
'init_app',
'Initialize a draft raw app from a framework template.',
{ strict: false }
),
requires: WRITE_APP_DRAFT,
def: createToolDef(initAppSchema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }),
// A builder has no data setup: it needs SQL and inline runnables.
schemaFor: async (helpers) => {
const access = (helpers as GlobalToolHelpers | undefined)?.access
const schema =
access && !access.has('write_draft') ? initAppSchema.omit({ data: true }) : initAppSchema
return createToolDef(schema, 'init_app', INIT_APP_DESCRIPTION, { strict: false })
},
showDetails: true,
showFade: true,
fn: async (ctx) => {
@@ -4493,7 +4561,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
writeAppFileSchema,
'write_app_file',
@@ -4508,7 +4576,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
deleteAppFileSchema,
'delete_app_file',
@@ -4520,7 +4588,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
patchAppFileSchema,
'patch_app_file',
@@ -4535,7 +4603,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
writeAppRunnableSchema,
'write_app_runnable',
@@ -4551,7 +4619,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
deleteAppRunnableSchema,
'delete_app_runnable',
@@ -4589,8 +4657,14 @@ export const globalTools: SessionTool<{}>[] = [
if (canOpenEditor(access, 'script')) {
return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
}
return canOpenEditor(access, 'flow')
? createToolDef(openPreviewFlowEditorSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
const flows = canOpenEditor(access, 'flow')
const apps = canOpenEditor(access, 'raw_app')
return flows || apps
? createToolDef(
openPreviewBuilderEditorSchema(flows, apps),
'open_preview',
OPEN_PREVIEW_DESCRIPTION
)
: createToolDef(
openPreviewViewOnlySchema,
'open_preview',
@@ -4947,7 +5021,7 @@ async function openSessionPreview(
// a model asking outside the enum can't act outside it either.
if (mode === 'edit' && !canOpenEditor(access, args.kind)) {
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'flow' ? 'flow' : 'item'} editors in this workspace.`
return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'raw_app' ? 'app' : args.kind} editors in this workspace.`
}
// The draft capability is workspace-wide and says nothing about this path, so an edit
// that survived it is still checked against the item's own permissions.
@@ -6707,11 +6781,12 @@ async function initApp(
content: `Saving app "${path}" draft (${framework} template)…`
})
const template = FRAMEWORK_TEMPLATES[framework]
// A builder can save no inline runnable, so they start from a static page instead of the tour.
const builder = operatorBuilderAppsIn(workspace)
const value: AppDraftValue = {
summary,
files: { ...template },
runnables: structuredClone(STARTER_RUNNABLES),
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] },
runnables: builder ? {} : structuredClone(STARTER_RUNNABLES),
...(data && {
data: {
tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)),
@@ -6724,7 +6799,7 @@ async function initApp(
const result = await saveAppDraft(workspace, path, value)
return finishAppDraftWrite(result, ctx, () => ({
content: `Saved app "${path}" draft (${framework})`,
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}`
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). ${builder ? 'The template is a static page with no runnables: replace its UI, and add only deployed scripts and flows, by path.' : `The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable).`} Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}`
}))
}
@@ -47,12 +47,14 @@ describe('resolveSessionAccess', () => {
const caps = await capabilitiesFor({})
expect([...caps].sort()).toEqual([
'deploy',
'manage_apps',
'manage_code',
'manage_flows',
'manage_schedules',
'manage_triggers',
'run_flow_preview',
'run_preview',
'write_app_draft',
'write_draft',
'write_flow_draft'
])
@@ -82,6 +84,7 @@ describe('resolveSessionAccess', () => {
'deploy',
'manage_schedules',
'manage_triggers',
'write_app_draft',
'write_draft',
'write_flow_draft'
])
@@ -118,6 +121,23 @@ describe('resolveSessionAccess', () => {
])
})
// apps.rs and drafts.rs let an app builder through; nothing runs an app preview server-side.
it('gives an app builder operator the app capabilities alone', () => {
const caps = capabilitiesForRole({
isAdmin: false,
operator: true,
deployRulesPass: true,
operatorSettings: { builder_apps: true }
})
expect([...caps].sort()).toEqual([
'deploy',
'manage_apps',
'manage_schedules',
'manage_triggers',
'write_app_draft'
])
})
// A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it.
it('withholds schedule and trigger writes only where the workspace set them to false', () => {
const caps = capabilitiesForRole({
@@ -8,17 +8,19 @@ import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
type OperatorRights = Pick<
NonNullable<OperatorSettings>,
'builder_flows' | 'manage_schedules' | 'manage_triggers'
'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers'
>
const ALL_CAPABILITIES: SessionCapability[] = [
'write_draft',
'write_flow_draft',
'write_app_draft',
'run_preview',
'run_flow_preview',
'deploy',
'manage_code',
'manage_flows',
'manage_apps',
'manage_schedules',
'manage_triggers',
'admin'
@@ -54,6 +56,9 @@ export function capabilitiesForRole(role: {
if (roleCanDraft('flow', user, role.operatorSettings)) {
capabilities.add('write_flow_draft')
}
if (roleCanDraft('raw_app', user, role.operatorSettings)) {
capabilities.add('write_app_draft')
}
if (authorsCode) {
capabilities.add('run_preview')
capabilities.add('manage_code')
@@ -62,6 +67,9 @@ export function capabilitiesForRole(role: {
capabilities.add('run_flow_preview')
capabilities.add('manage_flows')
}
if (roleCanAuthor('raw_app', user, role.operatorSettings)) {
capabilities.add('manage_apps')
}
if (roleCanAuthor('schedule', user, role.operatorSettings)) {
capabilities.add('manage_schedules')
}
@@ -80,6 +80,8 @@ const REACHABLE_PROFILES = [
[
undefined,
{ builder_flows: true },
{ builder_apps: true },
{ builder_flows: true, builder_apps: true },
{ manage_schedules: false, manage_triggers: false }
].map((operatorSettings) => {
const access = capabilitiesForRole({
@@ -137,10 +139,12 @@ describe('session tool policies', () => {
const developerCaps: SessionCapability[] = [
'write_draft',
'write_flow_draft',
'write_app_draft',
'run_preview',
'run_flow_preview',
'manage_code',
'manage_flows',
'manage_apps',
'manage_schedules',
'manage_triggers'
]
@@ -167,6 +171,17 @@ describe('session tool policies', () => {
const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers'])
expect(kindsOf('deploy_workspace_item', builder)).toContain('flow')
expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script')
expect(kindsOf('deploy_workspace_item', builder)).not.toContain('app')
// An app builder gets the app kind alone, and rebases its own stale app drafts.
const appBuilder = accessWith(['deploy', 'manage_apps', 'write_app_draft'])
expect(kindsOf('deploy_workspace_item', appBuilder)).toContain('app')
expect(kindsOf('deploy_workspace_item', appBuilder)).not.toContain('flow')
expect(kindsOf('rebase_draft', appBuilder)).toEqual(['app'])
// A session left with no kind must not be offered an empty enum.
expect(shippedSessionTools(operator).map((t) => t.def.function.name)).not.toContain(
'rebase_draft'
)
// Deleting a script is admin-only, where deploying one is not.
const developer = accessWith([...developerCaps, 'deploy'])
@@ -216,9 +231,9 @@ describe('session tool policies', () => {
// ships to every profile with guidance written around the draft tools by name.
it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')!
const call = (access: SessionAccess) =>
const call = (access: SessionAccess, subject = 'script') =>
tool.fn({
args: { subject: 'script', language: 'bun' },
args: { subject, language: 'bun' },
workspace: 'ws',
helpers: { access },
toolId: 't1',
@@ -231,6 +246,30 @@ describe('session tool policies', () => {
expect(withheld.filter((n) => restricted.includes(n))).toEqual([])
// The gate is the whole test, so pin that it is not simply refusing everyone.
expect(await call(fullSessionAccess())).toContain('write_script')
// A builder gets the guide for their kind, rewritten without the inline-code tools.
for (const [capability, subject, used] of [
['write_flow_draft', 'flow', 'patch_flow_json'],
['write_app_draft', 'app', 'write_app_runnable']
] as const) {
const builder = accessWith(['deploy', capability])
const guide = await call(builder, subject)
expect(guide).toContain(used)
// The shared reference below still teaches inline code, so the guide must say to skip it.
expect(guide).toContain('skip what it says')
expect(withheldNames(builder).filter((n) => guide.includes(n))).toEqual([])
}
})
// A data setup needs SQL and inline runnables, neither of which a builder has.
it('offers a builder no init_app data setup', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'init_app')!
const properties = async (access: SessionAccess) =>
((await tool.schemaFor!({ access })).function.parameters as any).properties
const builder = await properties(accessWith(['deploy', 'write_app_draft']))
const full = await properties(fullSessionAccess())
expect(builder.data).toBeUndefined()
expect(full.data).toBeDefined()
})
// A full-access profile must gate nothing at all: the text has to match the ungated
@@ -24,13 +24,16 @@ export function filterSessionTools<T extends FilterableTool>(
access: SessionAccess | undefined
): T[] {
if (!access) return tools
return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access))
return tools
.filter((t) => sessionToolAllowed(t, access))
.flatMap((t) => narrowKinds(t, access) ?? [])
}
/** Returns the tool itself when every kind is allowed, so full access ships the same bytes:
* tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the
* defs are module singletons shared by every session. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T {
* defs are module singletons shared by every session. `undefined` when no kind is left: an
* empty enum would offer a tool no call can satisfy. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T | undefined {
const kindRequires = tool.kindRequires
if (!kindRequires) return tool
const parameters = tool.def.function.parameters as {
@@ -40,6 +43,7 @@ function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess):
// A kind with no entry is withheld, as a tool with no `requires` is.
const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access))
if (allowed.length === kinds.length) return tool
if (allowed.length === 0) return undefined
const def = structuredClone(tool.def)
;(def.function.parameters as typeof parameters).properties.type.enum = allowed
return { ...tool, def }
@@ -15,6 +15,8 @@ export type SessionCapability =
/** Passes `require_can_write_path` for a flow draft, which an operator also passes where the
* workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */
| 'write_flow_draft'
/** The same for a raw app draft, under `builder_apps`. */
| 'write_app_draft'
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
| 'run_preview'
/** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an
@@ -22,12 +24,14 @@ export type SessionCapability =
| 'run_flow_preview'
/** Passes `check_deploy_rules`. */
| 'deploy'
/** Passes the operator refusal the script and app handlers make before creating or
/** Passes the operator refusal the script handlers make before creating or
* deleting one. The same condition as `run_preview` today, but another handler family's
* check, so each follows its own gate if the two ever diverge. */
| 'manage_code'
/** Passes the flow handlers' `check_operator_can_build_flows`. */
| 'manage_flows'
/** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */
| 'manage_apps'
/** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose
* workspace set `operator_settings.manage_schedules` to false. */
| 'manage_schedules'
@@ -50,6 +54,7 @@ export type SessionToolPolicy = readonly SessionCapability[]
export const NONE: SessionToolPolicy = []
export const WRITE_DRAFT: SessionToolPolicy = ['write_draft']
export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft']
export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft']
export const RUN_PREVIEW: SessionToolPolicy = ['run_preview']
export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview']
export const DEPLOY: SessionToolPolicy = ['deploy']
@@ -62,7 +67,9 @@ export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline'
* resolved yet, or not a session) answers true, as the toolset filter does. */
export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean {
if (!access) return true
return access.has(kind === 'flow' ? 'write_flow_draft' : 'write_draft')
return access.has(
kind === 'flow' ? 'write_flow_draft' : kind === 'raw_app' ? 'write_app_draft' : 'write_draft'
)
}
/**
@@ -88,8 +88,11 @@
import { randomSecret, randomUUID } from '$lib/utils/uuid'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { conformBuilderAppPolicy } from './builderAppPolicy'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
interface Props {
files?: Record<string, string>
@@ -413,6 +416,11 @@
if (url) URL.revokeObjectURL(url)
}
})
// The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user
// deployed carries, so the editor conforms the policy as soon as it opens.
$effect(() => {
if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore)
})
// An app with sandbox isolation previews the way it runs once deployed: in the
// served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce
// in its URL authenticates its `windmill:ready`, as in `RawAppPreview`.
@@ -23,6 +23,7 @@
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { operatorBuilderAppsIn } from '$lib/operatorWriteRights'
import type {
RawAppRuntimeLogRequester,
RawAppRunsProvider,
@@ -97,7 +98,10 @@
const setUp = {
...val,
files: { ...result.files },
runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) },
runnables: {
...result.runnables,
...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES))
},
data: result.data,
policy: result.policy
}
@@ -1,6 +1,7 @@
import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import { get } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import { roleCanAuthor } from '$lib/editRights'
import {
checkDeployPermission,
checkDeployRules,
@@ -231,9 +232,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
// button disables with a reason instead of failing on click. `ok` defaults
// true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
// `checkDeployPermission` refuses every operator, but flows.rs lets one with the
// workspace's `builder_flows` right deploy flows, under the rules alone.
let builderFlowPerm = $state<DeployPermission | undefined>(undefined)
// `checkDeployPermission` refuses every operator, but flows.rs and apps.rs let one with the
// workspace's matching builder right deploy that kind, under the rules alone.
let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined)
let deployPermFetchedFor = ''
$effect(() => {
const ws = getArgs().workspaceId
@@ -243,22 +244,24 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
// Reset to fail-open for the new workspace and drop a stale resolution
// (a slower fetch for the previous workspace must not gate this one).
deployPerm = { ok: true }
builderFlowPerm = undefined
builder = undefined
void checkDeployPermission(ws).then(async (perm) => {
if (deployPermFetchedFor !== ws) return
deployPerm = perm
const builder =
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings?.builder_flows === true
if (perm.refusedBy === 'operator' && builder) {
const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
const kinds = (['flow', 'raw_app'] as const).filter((k) =>
roleCanAuthor(k, { operator: true }, settings)
)
if (perm.refusedBy === 'operator' && kinds.length) {
const rules = await checkDeployRules(ws)
if (deployPermFetchedFor === ws) builderFlowPerm = rules
if (deployPermFetchedFor === ws) builder = { perm: rules, kinds }
}
})
})
})
function permissionFor(kind: Kind): DeployPermission {
if (kind === 'flow' && builderFlowPerm) return deployPermissionForKind(builderFlowPerm, kind)
if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind)
return deployPermissionForKind(deployPerm, kind)
}
@@ -23,8 +23,8 @@ export function useSessionPermissions(workspace: () => string | undefined) {
/** Whether this user can save the drafts the `kind` editor writes. */
canOpenEditor: (kind: string) => rights.roleCanDraft(kind),
/** The same, and this item's own permissions let them write it. */
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'path' | 'extraPerms'>) =>
rights.canDraftItem(item.kind, item.path, item.extraPerms),
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'raw_app' | 'path' | 'extraPerms'>) =>
rights.canDraftItem(item.raw_app ? 'raw_app' : item.kind, item.path, item.extraPerms),
/** Whether the page loads for this user rather than refusing them. */
pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry)
}
@@ -30,7 +30,12 @@ type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
import type { HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates'
import {
BUILDER_FRAMEWORK_TEMPLATES,
react19Template,
STARTER_RUNNABLES
} from '$lib/components/raw_apps/templates'
import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights'
import { random_adj } from '$lib/components/random_positive_adjetive'
import { copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
@@ -1046,10 +1051,13 @@ function createRuntime(session: Session): SessionRuntime {
} catch (err) {
if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) {
const user = get(userStore)
// A builder can save no inline runnable, so the regular starter could never be saved.
await workspaceListLoaded()
const builder = operatorBuilderAppsIn(workspace)
saved.val = undefined
store.val = {
files: { ...react19Template },
runnables: structuredClone(STARTER_RUNNABLES),
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) },
runnables: builder ? {} : structuredClone(STARTER_RUNNABLES),
data: { ...DEFAULT_DATA },
policy: {
on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`,
@@ -5,7 +5,6 @@
import { AppService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { useOperatorBuilderApps, workspaceListLoaded } from '$lib/operatorWriteRights'
import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy'
import { readFieldsRecursively } from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
@@ -538,12 +537,6 @@
return $operatorBuilderApps ? {} : structuredClone(STARTER_RUNNABLES)
}
// The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user
// deployed carries, so the editor conforms the policy as soon as it opens.
$effect(() => {
if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore)
})
async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
const picked = {
files: { ...result.files },