Files
windmill/frontend/src/lib/components/ScriptBuilder.svelte
T
0249bb94ac refactor: harmonize flow and script settings layout (#12013)
* refactor: harmonize flow and script settings layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the priority input open while it is edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep settings rows open while edited and name the deploying user for non-admins

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write a setting before its row opens and show its real state while edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: slide settings rows open to their full height

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: remount a settings row's fields when its toggle turns it on

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: put the AI prompt behind a settings toggle

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

* feat: summarize active runtime and permission settings on the metadata tab

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

* refactor: move the active settings chips into the settings header

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

* fix: bring back the active setting icons on the settings tabs

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-10-27 01:24:09 +01:00

2323 lines
80 KiB
Svelte

<script lang="ts">
import {
ScriptService,
type Script,
type NewScript,
type TriggersCount,
PostgresTriggerService,
CaptureService,
type ScriptLang,
WorkerService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
import {
initialCode,
canHavePreprocessor,
getPreprocessorFullCode,
getMainFunctionPattern
} from '$lib/script_helpers'
import { isWorkflowAsCode } from './graph/wacToFlow'
import AIFormSettings from './copilot/AIFormSettings.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core'
import OpenInSessionButton, {
type OpenInSessionSource
} from './sessions/OpenInSessionButton.svelte'
import {
defaultScripts,
enterpriseLicense,
usedTriggerKinds,
userStore,
userWorkspaces,
workerTags,
workspaceStore
} from '$lib/stores'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import {
emptySchema,
emptyString,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import Path from './Path.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import { notifyContractWarnings } from './assets/AssetGraph/schemaContracts'
import ScriptEditor from './ScriptEditor.svelte'
import DbtEditor from './dbt/DbtEditor.svelte'
import { findModulePathClash } from './scriptModulePath'
import { Alert, Button, Drawer, Tab, TabContent, Tabs } from './common'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import type { SupportedLanguage } from '$lib/common'
import Tooltip from './Tooltip.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
Bug,
CheckCircle,
Code,
DiffIcon,
EllipsisVertical,
Network,
Rocket,
Settings,
Shuffle,
Tag,
X
} from 'lucide-svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { parsePipelineAnnotations } from './assets/AssetGraph/parsePipelineAnnotations'
import DropdownV2 from './DropdownV2.svelte'
import { getLocalSetting, storeLocalSetting, type Item } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import Awareness from './Awareness.svelte'
import Popover from './Popover.svelte'
import JobTokenScopesSetting from './JobTokenScopesSetting.svelte'
import ScriptSchema from './ScriptSchema.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import type Editor from './Editor.svelte'
import WorkerTagPicker from './WorkerTagPicker.svelte'
import MetadataGen from './copilot/MetadataGen.svelte'
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack, type Snippet } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import LabelsInput from './LabelsInput.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftChangesConfirmationModal from './common/confirmationModal/DraftChangesConfirmationModal.svelte'
import PerpetualRunsDeployModal from './scripts/PerpetualRunsDeployModal.svelte'
import {
loadPerpetualRunsAtPath,
stopPerpetualRuns,
type PerpetualRunsAtPath
} from './scripts/perpetualRuns'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
import WorkerTagSelect from './WorkerTagSelect.svelte'
import type { ButtonType } from './common/button/model'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import type { OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
import RunOnBehalfOfSetting from './itemSettings/RunOnBehalfOfSetting.svelte'
import MuteErrorHandlerSetting from './itemSettings/MuteErrorHandlerSetting.svelte'
import SettingGroup from './itemSettings/SettingGroup.svelte'
import { keepsDeployedOnBehalfOf } from './itemSettings/onBehalfOf'
import ScriptRuntimeSettings from './itemSettings/ScriptRuntimeSettings.svelte'
import InvisibleRunsSetting from './itemSettings/InvisibleRunsSetting.svelte'
import SettingIcons from './itemSettings/SettingIcons.svelte'
import ActiveSettingChips, { revealSetting } from './itemSettings/ActiveSettingChips.svelte'
import { getActivePermissionBadges, getActiveScriptSettingsBadges } from './scriptSettings'
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf'
const triggerLock = useTriggerLock()
const editRights = useEditRights()
let {
script = $bindable(),
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
onTakeLatest = undefined,
draftBaseHash = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
template = $bindable('script'),
initialArgs = {},
lockedLanguage = false,
showMeta = false,
neverShowMeta = false,
diffDrawer = undefined,
savedScript = $bindable(undefined),
searchParams = new URLSearchParams(),
disableHistoryChange = false,
customUi = {},
savedPrimarySchedule = undefined,
functionExports = undefined,
children,
onDeploy,
onDeployError,
onSeeDetails,
onNavigate,
onTestJob,
disableAi,
testPanelCollapsed = false,
initialPathChosen = false,
onResetToDeployed,
loadedFromDraft = false,
othersDraftsCount = 0,
onOpenOthersDrafts,
condensedHeader = false,
ownsPageHeader = false
}: ScriptBuilderProps = $props()
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
// In the page header the buttons ride a 44px band, which takes the same size.
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedScript,
modifiedValue: {
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
}
}
// used for new scripts for captures
const fakeInitialPath =
'u/' +
($userStore?.username?.includes('@')
? $userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: $userStore?.username) +
'/' +
generateRandomString(12)
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
// Top-bar responsive collapse — container width, not viewport. Collapse the
// right group (hide the ~200px tag select, icon-only Diff/Settings) before the
// full group crowds the path into heavy truncation; ~900 is where the path
// keeps a usable width given the right group's natural ~440px.
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
)
// In the page header no fold drops a button's label: what does not fit goes into the menu. The
// second fold holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The third fold leaves the autosave indicator as its icon, so the AI session button still fits
// right before Deploy on a phone.
const narrowTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 3)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
// editor's global store; the sessions preview overrides it to the session's
// (forked) workspace, so an embedded editor acts on the session's fork rather
// than the navigation workspace ($workspaceStore, which stays put). indicatorPath
// is the matching draft path (URL path full-page, session target in preview).
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace)
const indicatorPath = $derived(autosavePath ?? userDraftPath)
// The shared `workerTags` store caches tags for the navigation workspace. A
// session editor deploys to `opWorkspace` (a fork), so it keeps a local list to
// gate/populate the tag picker without reading or clobbering the shared cache.
const usesLocalTags = $derived(opWorkspace != undefined && opWorkspace !== $workspaceStore)
let localWorkerTags = $state<string[] | undefined>(undefined)
const scriptWorkerTags = $derived(usesLocalTags ? localWorkerTags : $workerTags)
function getCompactMenuItems(): Item[] {
const hasTags = (scriptWorkerTags?.length ?? 0) > 0
const isDraftOnly = savedScript?.no_deployed === true
return [
...(phoneTopbar && customUi?.topBar?.diff != false
? [
{
displayName: 'Diff',
icon: DiffIcon,
disabled: !savedScript || !diffDrawer || isDraftOnly,
action: () => openDiffDrawer()
}
]
: []),
...(phoneTopbar && customUi?.topBar?.settings != false
? [{ displayName: 'Settings', icon: Settings, action: () => (metadataOpen = true) }]
: []),
...(customUi?.topBar?.tagEdit != false && hasTags
? [
{
displayName: 'Worker tag',
icon: Tag,
action: () => {
selectedTab = 'runtime'
metadataOpen = true
}
}
]
: [])
]
}
let open: boolean = $state(false) // Is confirmation modal open
let args: Record<string, any> = $state(untrack(() => initialArgs)) // Test args input
let selectedInputTab: 'main' | 'preprocessor' | 'diagram' = $state('main')
let hasPreprocessor = $state(false)
let preserveOnBehalfOf = $state(false)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
function keepDeployedOnBehalfOf() {
if (
!keepsDeployedOnBehalfOf(
actingUser,
onBehalfOfChoice,
script.on_behalf_of_email,
savedScript?.on_behalf_of_email
)
)
return
onBehalfOfChoice = 'target'
script.on_behalf_of_email = savedScript?.on_behalf_of_email
script.on_behalf_of = savedScript?.on_behalf_of
preserveOnBehalfOf = true
}
const isNewScript = $derived(initialPath == '' || savedScript?.no_deployed === true)
let metadataOpen = $state(
!untrack(() => neverShowMeta) &&
(untrack(() => showMeta) ||
untrack(() => searchParams).get('metadata_open') == 'true' ||
(initialPath == '' &&
untrack(() => searchParams).get('state') == undefined &&
untrack(() => searchParams).get('collab') == undefined))
)
let editor: Editor | undefined = $state(undefined)
// A dbt script gets its own editor: the artifact is a project bundle — a file
// tree, a descriptor, run arguments and a model graph — not a body of code.
// Both answer to the same handful of calls below, so everything around them
// is unchanged.
let scriptEditor: ScriptEditor | DbtEditor | undefined = $state(undefined)
let isDbt = $derived(script.language === 'dbt')
// dbt hides Generated UI, but the initialiser picks a tab before the language is
// known, so `ui` can be selected. Fall back only to an ENABLED tab — the other
// TabContents are not gated on their disable flags — and otherwise stay on `ui`,
// whose content IS gated for dbt, so nothing renders rather than something hidden.
$effect(() => {
if (isDbt && selectedTab === 'ui') {
const first = (
[
['metadata', customUi?.settingsPanel?.disableMetadata],
['runtime', customUi?.settingsPanel?.disableRuntime],
['permissions', customUi?.settingsPanel?.disableRuntime],
['triggers', customUi?.settingsPanel?.disableTriggers]
] as const
).find(([, disabled]) => disabled !== true)?.[0]
if (first) selectedTab = first
}
})
// The version whose stored graph the dbt editor draws until a refresh replaces
// it. A script never deployed has none, and `NewScript` carries no hash.
let deployedScriptHash = $derived(
savedScript && 'hash' in savedScript ? (savedScript.hash as string) : undefined
)
let captureTable: CaptureTable | undefined = $state(undefined)
let wacExportDrawer: WacExportDrawer | undefined = $state(undefined)
// Draft triggers confirmation modal
let draftTriggersModalOpen = $state(false)
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
let perpetualRunsToConfirm: PerpetualRunsAtPath | undefined = $state(undefined)
let confirmPerpetualRunsCallback: () => void = () => {}
async function stopPerpetualRunsFromModal(): Promise<boolean> {
try {
await stopPerpetualRuns(opWorkspace!, initialPath)
return true
} catch (error) {
sendUserToast(`Could not stop the runs of this script: ${error.body ?? error.message}`, true)
return false
}
}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(
untrack(() => savedPrimarySchedule)
) // keep for legacy
const triggersCount = writable<TriggersCount | undefined>(
untrack(() => savedPrimarySchedule)
? {
schedule_count: 1,
primary_schedule: { schedule: untrack(() => savedPrimarySchedule)!.cron }
}
: undefined
)
const simplifiedPoll = writable(false)
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
onMount(() => {
if (functionExports) {
console.log('functionExports set')
functionExports({
setPreviewArgs: (args: Record<string, any>) => {
scriptEditor?.setArgs(args)
},
runPreview: async () => await scriptEditor?.runTest(),
setCode: (code: string, language?: Script['language']) => {
if (language) {
script.language = language
}
editor?.setCode(code)
},
getCode: () => editor?.getCode() ?? ''
})
}
})
async function loadTriggers() {
if (!initialPath) {
return
}
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: opWorkspace!,
path: initialPath
})
await triggersState.fetchTriggers(
triggersCount,
opWorkspace,
initialPath,
false,
$primaryScheduleStore,
$userStore
)
}
// Add triggers context store
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
])
)
const captureOn = writable<boolean | undefined>(undefined)
const showCaptureHint = writable<boolean | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll,
showCaptureHint: showCaptureHint,
triggersState
})
const enterpriseLangs = ['mssql', 'oracledb']
// Languages the pipeline editor treats as warehouse/dataset transforms —
// the ones where a `-- pipeline` annotation is a natural next step.
const pipelineHintLangs = ['duckdb', 'postgresql', 'bigquery', 'snowflake', 'mysql', 'mssql']
const pipelineHintDismissed = useLocalStorageValue(
'pipelineScriptHintDismissed',
false,
'boolean'
)
let showPipelineHint = $derived(
!pipelineHintDismissed.val &&
(script.kind === 'script' || script.kind === undefined) &&
pipelineHintLangs.includes(script.language ?? '') &&
!parsePipelineAnnotations(script.content ?? '').inPipeline
)
export function setCode(code: string): void {
editor?.setCode(code)
}
const scriptKindOptions: {
value: Script['kind']
title: string
Icon: any
desc?: string
documentationLink?: string
}[] = [
{
value: 'script',
title: 'Action',
Icon: Code
},
{
value: 'trigger',
title: 'Trigger',
desc: 'First module of flows to trigger them based on external changes. These kind of scripts are usually running on a schedule to periodically look for changes.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_trigger',
Icon: Rocket
},
{
value: 'approval',
title: 'Approval',
desc: 'Send notifications externally to ask for approval to continue a flow.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_approval',
Icon: CheckCircle
},
{
value: 'failure',
title: 'Error Handler',
desc: 'Handle errors in flows after all retry attempts have been exhausted.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_error_handler',
Icon: Bug
},
{
value: 'preprocessor',
title: 'Preprocessor',
desc: 'Transform incoming requests before they are passed to the main entrypoint.',
documentationLink: 'https://www.windmill.dev/docs/core_concepts/preprocessors',
Icon: Shuffle
}
]
let pathError = $state('')
let loadingSave = $state(false)
// Lifts the route's `?new_draft=true` `stopSync` suspension, but only after the
// stores-gated bind:path cascade (and, for an empty seed, `initContent` via
// `markContentReady`) settles — resuming earlier posts the seed/auto-generated
// path as the user's first "edit". `restarted` keeps re-entry idempotent.
function scheduleRestartSync(
path: string,
opts?: { waitForContent?: boolean }
): { markContentReady: () => void } {
let contentReady = !opts?.waitForContent
let storesReady = !!($userStore && $workspaceStore)
let restarted = false
async function tryRestart() {
if (restarted || !contentReady || !storesReady) return
// 500ms covers the bind:path cascade even on cold reload; two ticks
// weren't enough (bind:path fired ~100ms after restart, posting an edit).
await new Promise((r) => setTimeout(r, 500))
if (restarted) return
restarted = true
UserDraft.restartSync('script', path)
}
if (!storesReady) {
$effect(() => {
if ($userStore && $workspaceStore) {
storesReady = true
untrack(() => void tryRestart())
}
})
}
void tryRestart()
return {
markContentReady() {
contentReady = true
void tryRestart()
}
}
}
if (script.content == '') {
// Suspend autosave around the bootstrap mutations: seeding the template
// content is a programmatic write, not the user's first edit. The handle
// keys on `userDraftPath` (URL path), not the editor-displayed `initialPath`.
UserDraft.stopSync('script', userDraftPath)
if (template === 'wac_python') {
script.modules = {
'helper.py': {
content: 'def main(a: str) -> str:\n return f"hello {a}"\n',
language: 'python3'
}
}
} else if (template === 'wac_typescript') {
script.modules = {
'helper.ts': {
content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n',
language: 'bun'
}
}
} else if (script.language === 'dbt') {
seedDbtProject()
}
const restarter = scheduleRestartSync(userDraftPath, { waitForContent: true })
initContent(script.language, script.kind, template).finally(() => restarter.markContentReady())
} else if (userDraftPath && untrack(() => searchParams).get('new_draft') == 'true') {
// Pre-filled new-draft seed (fork "Copy of X", hub fork, URL/YAML import): no
// template to seed, but the route still suspended autosave — lift it or the
// draft never persists (autosave stays dead for the session).
scheduleRestartSync(userDraftPath)
}
async function isTemplateScript() {
const params = new URLSearchParams(window.location.search)
const templateId = params.get('id')
if (templateId === null) {
return undefined
}
try {
const templateScript = await PostgresTriggerService.getTemplateScript({
workspace: opWorkspace!,
id: templateId
})
return templateScript
} catch (error) {
sendUserToast(
'An error occurred when trying to load your template script, please try again later',
true
)
}
}
async function initContent(
language: SupportedLanguage,
kind: Script['kind'] | undefined,
template:
| 'pgsql'
| 'mysql'
| 'script'
| 'docker'
| 'powershell'
| 'bunnative'
| 'claudesandbox'
| 'pisandbox'
| 'wac_python'
| 'wac_typescript'
| 'ci_test_bun'
| 'ci_test_python'
) {
scriptEditor?.disableCollaboration()
// Seed synchronously so a Deploy before the async template fetch resolves
// doesn't run `inferArgs` on empty content and toast "Could not parse code".
script.content = initialCode(language, kind, template, false)
const templateScript = await isTemplateScript()
if (templateScript) {
script.content = initialCode(language, kind, template, true)
}
if (templateScript) {
script.content += '\r\n' + templateScript
}
scriptEditor?.inferSchema(script.content, { nlang: language, resetArgs: true })
if (script.content != editor?.getCode()) {
setCode(script.content)
}
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
scriptEditor?.flushModuleState()
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
try {
if (initialPath && initialPath != '') {
// The row's path, not `initialPath`: that is the typed path, where a
// staged rename has nothing deployed, and a missing head would deploy
// on a base that already has a child.
actual_parent_hash = (
await ScriptService.getScriptLatestVersion({
workspace: opWorkspace!,
path: userDraftPath || initialPath
})
)?.script_hash
}
} catch (error) {
//
}
// Usually when we create new script, we put current hash as a parent_hash
// But if we specify parent_hash that is already used, than we get error
// In order to fix it we make sure that client's understanding of parent_hash
// is aligns with understanding of backend.
//
// A draft with no base (one predating `draft.base`) is compared on the head this
// editor loaded, as the flow and raw-app guards are: comparing `undefined` would
// open the confirmation on every deploy, telling the user a version landed while
// they were editing when none had.
const baseHash = script.parent_hash ?? deployedScriptHash
if (actual_parent_hash == undefined || baseHash == actual_parent_hash) {
// Handle directly
await editScript(stay, (script.parent_hash ?? actual_parent_hash)!, deployMsg)
} else {
// Fetch entire script, since we need it to show Diff
await syncWithDeployed()
if (
deployedValue &&
script &&
orderedJsonStringify({ ...deployedValue, hash: undefined }) ===
orderedJsonStringify(
replaceFalseWithUndefined({ ...script, hash: undefined, parent_hash: undefined })
)
) {
await editScript(stay, actual_parent_hash, deployMsg)
} else {
// Handle through confirmation modal
confirmCallback = async () => {
open = false
if (actual_parent_hash) {
await editScript(stay, actual_parent_hash, deployMsg)
} else {
sendUserToast('Could not fetch latest version of the script', true)
}
}
// Open confirmation modal
open = true
}
}
}
async function syncWithDeployed(opening?: number) {
const latestScript = await ScriptService.getScriptByPath({
workspace: opWorkspace!,
// The draft row's own path, not `initialPath` — that one tracks the path
// the user has typed, so after someone renames the item it still names
// the old location and this would compare against the archived row left
// there. Resolving by the row is what lets the diff show the rename.
path: userDraftPath || initialPath
})
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedValue = replaceFalseWithUndefined({
...latestScript,
workspace_id: undefined,
created_at: undefined,
created_by: undefined,
extra_perms: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined
})
deployedBy = latestScript.created_by
}
async function editScript(
stay: boolean,
parentHash: string,
deploymentMsg?: string,
triggersToDeploy?: Trigger[],
perpetualRunsConfirmed?: boolean
): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await editScript(
stay,
parentHash,
deploymentMsg,
triggersToDeploy,
perpetualRunsConfirmed
)
}
return
}
}
// Runs are restarted on a newer version at their own path, so a deploy that renames the
// script leaves them running the version they have.
if (
!perpetualRunsConfirmed &&
script.restart_unless_cancelled &&
initialPath &&
script.path === initialPath
) {
loadingSave = true
const runs = await loadPerpetualRunsAtPath(opWorkspace!, initialPath, script.schema)
loadingSave = false
if (runs) {
confirmPerpetualRunsCallback = () => {
perpetualRunsToConfirm = undefined
editScript(stay, parentHash, deploymentMsg, triggersToDeploy, true)
}
perpetualRunsToConfirm = runs
return
}
}
loadingSave = true
try {
// Legacy drafts can carry `schema: {}` (no `properties`), which trips
// `inferArgs` on `JSON.stringify(schema.properties)` and toasts "Could
// not parse code". Backfill an empty schema so it parses.
if (!script.schema || !(script.schema as any).properties) {
script.schema = emptySchema()
}
try {
const result = await inferArgs(
script.language,
script.content,
script.schema as any,
script.kind === 'preprocessor' ? 'preprocessor' : undefined
)
if (script.kind === 'preprocessor') {
script.auto_kind = undefined
script.has_preprocessor = undefined
} else {
script.auto_kind = result?.auto_kind || undefined
script.has_preprocessor = result?.has_preprocessor || undefined
}
} catch (error) {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
const newHash = await ScriptService.createScript({
workspace: opWorkspace!,
requestBody: {
path: script.path,
summary: script.summary,
description: script.description ?? '',
content: script.content,
parent_hash: parentHash,
schema: script.schema,
is_template: script.is_template,
language: script.language,
kind: script.kind,
tag: script.tag,
envs: script.envs,
dedicated_worker: script.dedicated_worker,
concurrent_limit: script.concurrent_limit,
concurrency_time_window_s: script.concurrency_time_window_s,
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
debounce_args_to_accumulate:
script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0
? script.debounce_args_to_accumulate
: undefined,
max_total_debouncing_time: script.max_total_debouncing_time,
max_total_debounces_amount: script.max_total_debounces_amount,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
delete_after_secs: script.delete_after_secs,
timeout: script.timeout,
concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key,
visible_to_runner_only: script.visible_to_runner_only,
auto_kind: script.auto_kind,
has_preprocessor: script.has_preprocessor,
deployment_message: deploymentMsg || undefined,
on_behalf_of_email: script.on_behalf_of_email,
on_behalf_of: script.on_behalf_of,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
assets: script.assets,
modules: script.modules,
labels: script.labels,
// Sent explicitly: an omitted field keeps the deployed value.
job_token_scopes: script.job_token_scopes ?? null
}
})
// New/updated path now exists server-side — drop the autocomplete
// cache so it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(opWorkspace!)
// Authoritative save-time schema-contract check (pipelines gap #2b):
// warn-only, post-commit so a self-produced target resolves to the
// content just deployed. Fire-and-forget — must never gate the deploy.
notifyContractWarnings(opWorkspace!, script.language, script.content)
// Gated for operators without `manage_triggers`, who cannot have captures to move.
if (!initialPath && !$triggerLock) {
await CaptureService.moveCapturesAndConfigs({
workspace: opWorkspace!,
path: fakeInitialPath,
requestBody: {
new_path: script.path
},
runnableKind: 'script'
})
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds,
script.path,
true
)
}
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript))
setDraftTriggers([])
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
// editor in place rather than navigating to the deployed item.
const stayHere =
stay ||
(script.auto_kind === 'lib' &&
script.kind !== 'preprocessor' &&
!isWorkflowAsCode(script.content, script.language))
if (stayHere) {
// Re-pin parent_hash so the next deploy's conflict check is against
// the version we just wrote.
script.parent_hash = newHash
}
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
// a "Deploy & Stay here" or lib-script deploy.
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
} catch (error) {
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
}
loadingSave = false
}
// Ctrl/Cmd+S forces an immediate flush of the pending autosave. Flush Monaco
// + `tick()` first so the last keystrokes reach the bindable before the
// syncer flushes. No toast — the AutosaveIndicator narrates the result, and
// `flush` never rejects (postSave routes errors to the failures map).
export async function saveDraft(): Promise<void> {
if (!opWorkspace || !userDraftPath) return
editor?.flushPendingChanges()
await tick()
await UserDraftDbSyncer.flush({
workspace: opWorkspace,
itemKind: 'script',
path: userDraftPath
})
}
// Materialize a brand-new script's draft before the session preview loads it by
// path — an untouched new script never autosaved, so forcePersist is the only
// thing that creates the row. Gated to never-deployed: forcePersist skips the
// discardIf baseline, safe only when there is none.
async function persistDraftForSession(): Promise<void> {
await saveDraft()
if (opWorkspace && userDraftPath && savedScript?.no_deployed === true) {
await UserDraft.forcePersist('script', userDraftPath, { workspace: opWorkspace })
}
}
const sessionOpen: OpenInSessionSource | undefined = $derived(
userDraftPath
? {
// URL draft path the editor loads/saves by, not the friendly
// `script.path` (a new script's has no row → "not found").
target: { kind: 'script', path: userDraftPath },
workspaceId: opWorkspace ?? undefined,
beforeOpen: persistDraftForSession
}
: undefined
)
// The header's button, gated like the code editor's AI entry point it stands in for.
function headerSessionOpen(): OpenInSessionSource | undefined {
if (disableAi || customUi?.editorBar?.aiGen == false) return undefined
if (!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(script?.language ?? '')) return undefined
return sessionOpen
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Export — don't make sense: the session always stays put and is already
// scoped to a fork. `Exit & See details` is the exception: the session hosts the
// details page itself, so it switches the tab instead of leaving. Diff is exposed
// as a standalone top-bar button (rendered independently of the pane), not here.
const inSessionPane = !!getContext('aiChatManager')
/** Names the version on the deployed side of the diff. Without it the reader is
* shown two panes and told nothing about what the left one is — which matters
* most in the stale-draft case, where the whole question is "whose version am I
* about to overwrite". */
function deployedVersionLabel(deployed: any): string | undefined {
if (!deployed?.hash) return undefined
// `deployedBy` is captured in `syncWithDeployed` before it strips `created_by`.
// Only used when the version list is unavailable; otherwise the picker labels it.
return `Deployed${deployedBy ? ` by ${deployedBy}` : ''} · latest`
}
/** Deployed versions to offer in the diff picker, newest first. Best-effort: a
* failure here costs the picker, not the diff, so the drawer still opens on the
* head. `deployment_msg` is all the history endpoint carries besides the hash. */
/** Throws: the drawer says so and lets the reader ask for the same page again. */
async function fetchVersionPage(headHash: string | undefined, page: number) {
if (!opWorkspace || !userDraftPath) return undefined
{
const history = await ScriptService.getScriptHistoryByPath({
workspace: opWorkspace,
path: userDraftPath,
page,
perPage: VERSION_PAGE_SIZE
})
// The hash identifies the version — it is what the API and the CLI speak — and
// who deployed it drops to the subtitle. No ordinal: the list arrives a page at
// a time, so a number counted within one would rename versions as more load.
return history.map((h) => {
const isHead = h.script_hash === headHash
const detail = [
h.created_by,
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
h.deployment_msg
].filter(Boolean)
return {
id: h.script_hash,
label: `${h.script_hash.slice(0, 8)}${isHead ? ' · latest' : ''}`,
subtitle: detail.length ? detail.join(' · ') : undefined,
isHead
}
})
}
}
/** The first page, best-effort: losing it costs the picker, not the diff. */
async function deployedVersionOptions(headHash: string | undefined) {
try {
return await fetchVersionPage(headHash, 1)
} catch {
return undefined
}
}
/** Hands the drawer the next page each time the reader asks for one. The page number
* belongs to this item's history, so it lives here — and only moves once a page has
* actually arrived, or a failed request would skip it. */
function moreVersionsLoader(headHash: string | undefined) {
let loaded = 1
return async () => {
const page = await fetchVersionPage(headHash, loaded + 1)
loaded += 1
return page
}
}
/** The opening this editor claimed last. A path change remounts this editor while the
* drawer stays mounted, so its teardown hands that opening back rather than leaving
* the drawer on the item the user left. */
let lastOpening: number | undefined = undefined
onDestroy(() => {
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
})
export async function openDiffDrawer() {
if (!savedScript) {
return
}
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
await syncWithDeployed(opening)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const deployed = deployedValue ?? savedScript
// `script` comes from the full DB row (since #9351), so it carries `lock`/`extra_perms`
// while the deployed side is trimmed in `syncWithDeployed` — null them here to match
// that strip. They stay in the shared `cleanValueProperties` so version-to-version and
// folder workspace/fork diffs still surface lockfile / sharing-permission changes.
const current = {
...script,
lock: undefined,
extra_perms: undefined,
draft_triggers: currentDraftTriggers
}
if (current.assets && !current.assets.length) delete current.assets
// Blanking the drawer belongs to the opening that will fill it.
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer(opening)
const headHash = (deployed as { hash?: string } | undefined)?.hash
const versions = await deployedVersionOptions(headHash)
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff(
{
mode: 'normal',
deployed,
deployedLabel: deployedVersionLabel(deployed),
versions,
loadMoreVersions: moreVersionsLoader(headHash),
onTakeLatest,
draftBase: draftBaseHash,
deployedHead: headHash,
loadVersion: async (hash) => {
const v = await ScriptService.getScriptByHash({ workspace: opWorkspace!, hash })
return replaceFalseWithUndefined({
...v,
workspace_id: undefined,
created_at: undefined,
created_by: undefined,
extra_perms: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined
})
},
draft: savedScript['draft'],
current
},
opening
)
}
function computeDropdownItems(
initialPath: string,
savedScript: ((Script | NewScript) & { no_deployed?: boolean }) | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
...(!inSessionPane
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces)
? [
{
label: editInForkLabel(opWorkspace, $userWorkspaces),
onClick: () => {
openEditInFork('script', initialPath, opWorkspace)
}
}
]
: [])
]
: []),
...(savedScript?.no_deployed !== true && script.kind === 'script' && !script.auto_kind
? [
{
label: 'Exit & See details',
onClick: () => {
onSeeDetails?.({ path: initialPath })
}
}
]
: []),
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
? [
{
label: 'Export as YAML/JSON',
onClick: () => {
wacExportDrawer?.open(script)
}
}
]
: [])
]
: []
if (
!inSessionPane &&
dropdownItems.length === 0 &&
isWorkflowAsCode(script.content, script.language)
) {
dropdownItems = [
{
label: 'Export as YAML/JSON',
onClick: () => {
wacExportDrawer?.open(script)
}
}
]
}
return dropdownItems.length > 0 ? dropdownItems : undefined
}
function onKeyDown(event: KeyboardEvent) {
// Lowercased so Caps Lock (which yields `S`) still saves. Shift excluded:
// Ctrl+Shift+S must reach the browser/OS.
switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) {
case 's':
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
saveDraft()
event.preventDefault()
}
break
}
}
// Monaco swallows the keydown, so a code editor with focus never reaches the
// window handler above; Editor/SimpleEditor/TemplateEditor re-broadcast it
// (untyped event, hence the manual listener).
$effect(() => {
window.addEventListener('wm-monaco-save-shortcut', saveDraft)
return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft)
})
let path: Path | undefined = $state(undefined)
// Seed "path is already chosen" so the summary→path auto-slug (which only
// runs for new scripts with initialPath == '') doesn't clobber a path the
// caller pre-assigned. The session preview opens AI-created scripts as new
// (empty initialPath) but with a path the AI already picked.
let dirtyPath = $state(initialPathChosen)
let selectedTab: 'metadata' | 'runtime' | 'permissions' | 'ui' | 'triggers' = $state(
(() => {
if (customUi?.settingsPanel?.disableMetadata !== true) {
// first option: either no custom UI or metadata is enabled
return 'metadata'
}
if (customUi?.settingsPanel?.disableRuntime !== true) {
return 'runtime'
}
if (customUi?.settingsPanel?.disableGeneratedUi !== true) {
return 'ui'
}
if (customUi?.settingsPanel?.disableTriggers !== true) {
return 'triggers'
}
return 'metadata'
})()
)
let runtimeBadges = $derived(getActiveScriptSettingsBadges(script))
let permissionBadges = $derived(getActivePermissionBadges(script))
let settingsContent: HTMLElement | undefined = $state(undefined)
async function openSetting(tab: string, setting: string) {
selectedTab = tab as typeof selectedTab
await tick()
revealSetting(settingsContent, setting)
}
setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true)
function langToLanguage(lang: SupportedLanguage | 'docker' | 'bunnative'): SupportedLanguage {
if (lang == 'docker') {
return 'bash'
}
if (lang == 'bunnative') {
return 'bun'
}
return lang
}
async function applyArgs(e) {
selectedInputTab = e.detail.kind
metadataOpen = false
if (scriptEditor) {
scriptEditor.updateArgs(e.detail.args ?? {})
}
}
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
captureOn.set(true)
}
function addPreprocessor(e?: { detail?: { args: Record<string, any> } }) {
const code = editor?.getCode()
if (code) {
const preprocessorCode = getPreprocessorFullCode(script.language, false)
const mainPattern = getMainFunctionPattern(script.language)
const mainIndex = code.indexOf(mainPattern)
if (mainIndex === -1) {
editor?.setCode(code + preprocessorCode)
} else {
editor?.setCode(
code.slice(0, mainIndex) + preprocessorCode + '\n' + code.slice(mainIndex) + '\n\n'
)
}
}
selectedInputTab = 'preprocessor'
// Apply provided args to the preprocessor
if (e?.detail?.args && Object.keys(e.detail.args).length > 0) {
args = { ...args, ...e.detail.args }
}
}
function handleDeployTrigger(_trigger: Trigger) {}
// A dbt script's modules ARE its dbt project, and the runtime refuses one
// without a `dbt_project.yml`. Seeded from BOTH entry points — the empty-script
// bootstrap and the language picker — because reaching dbt by switching an
// existing draft otherwise produces a script that cannot deploy or run.
// Existing modules are left alone: switching away and back must not discard a
// project the user has already grown.
function seedDbtProject() {
// Keyed on the project file rather than on "has any modules at all": a
// draft that grew modules under another language carries none of what dbt
// needs, and the worker refuses a bundle with no `dbt_project.yml` — so
// that draft reached dbt in a state it could neither deploy nor run.
// Matched on the canonical path: a bundle pushed with `./dbt_project.yml`
// already has the project file, and seeding a second spelling of it is the
// two-keys-one-file collision the editor's add-file checks refuse.
if (findModulePathClash(script.modules, 'dbt_project.yml')) return
script.modules = {
'dbt_project.yml': {
content:
'name: my_dbt_project\nversion: "1.0"\nprofile: my_dbt_project\nmodels:\n my_dbt_project:\n +materialized: view\n',
language: 'dbt'
},
'models/example.sql': {
content: 'select 1 as id\n',
language: 'dbt'
},
// Last, so anything already written wins: the previous language's
// helper files are inert to dbt and are the user's to remove.
...(script.modules ?? {})
}
}
/// Shown once, the first time dbt is chosen: the moment of choosing is when
/// "this is alpha and its details will move" is worth knowing, rather than
/// after a project has been built on it.
const DBT_ALPHA_SEEN = 'dbt_alpha_ack'
let dbtAlphaOpen = $state(false)
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
if (lang === 'dbt' && getLocalSetting(DBT_ALPHA_SEEN) !== 'true') {
dbtAlphaOpen = true
storeLocalSetting(DBT_ALPHA_SEEN, 'true')
}
if (lang == 'docker') {
template = 'docker'
} else if (lang == 'bunnative') {
template = 'bunnative'
} else {
template = 'script'
}
let language = langToLanguage(lang)
if (language === 'dbt') {
// A project bundle only ever runs as an action, and the selector that
// would set this back is hidden for dbt — so a script arriving here as a
// trigger or approval would keep a kind it cannot fill and drop out of
// the action pickers, with nothing on screen to repair it.
script.kind = 'script'
}
//
initContent(language, script.kind, template)
script.language = language
if (language === 'dbt') {
seedDbtProject()
}
}
function onSummaryChange(value: string) {
if (initialPath == '' && value?.length > 0 && !dirtyPath) {
path?.setName(
value
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}
$effect(() => {
initialPath != '' && untrack(() => loadTriggers())
})
let langs = $derived(
processLangs(script.language, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages))
.map((l) => [defaultScriptLanguages[l], l])
.filter((x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x[1]))
.filter((x) => {
if (customUi?.settingsPanel?.metadata?.languages === undefined) {
return true
}
return customUi.settingsPanel.metadata.languages.includes(x[1] as SupportedLanguage)
}) as [string, SupportedLanguage | 'docker' | 'bunnative'][]
)
$effect(() => {
;['collab', 'path'].forEach((x) => {
if (searchParams.get(x)) {
searchParams.delete(x)
}
})
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
// localStorage autosave that explicitly snapshotted triggersState; the
// switch to a unified UserDraft handle dropped that bridge.
$effect(() => {
readFieldsRecursively(triggersState.triggers)
script.draft_triggers = triggersState.getDraftTriggersSnapshot()
})
loadWorkerTags()
async function loadWorkerTags() {
if (usesLocalTags) {
if (!localWorkerTags) {
localWorkerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! })
}
} else if (!$workerTags) {
$workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! })
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
{@render children?.()}
{#snippet muteSetting()}
{#if customUi?.settingsPanel?.metadata?.disableMute !== true}
<SettingGroup label="Error handling" class="flex flex-col gap-6">
<MuteErrorHandlerSetting
kind="script"
path={script.path}
bind:muted={script.ws_error_handler_muted}
/>
</SettingGroup>
{/if}
{/snippet}
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
bind:deployedValue
currentValue={script}
/>
<DraftChangesConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
<PerpetualRunsDeployModal
runs={perpetualRunsToConfirm}
onStop={stopPerpetualRunsFromModal}
onConfirmed={() => confirmPerpetualRunsCallback()}
onCanceled={() => (perpetualRunsToConfirm = undefined)}
/>
{#if editRights.roleCanAuthor('script')}
<Drawer
placement="right"
bind:open={metadataOpen}
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
>
<DrawerContent
noPadding
title="Settings"
on:close={() => (metadataOpen = false)}
aiId="script-builder-settings"
aiDescription="Script builder settings"
>
{#snippet titleExtra()}
{#if customUi?.settingsPanel?.disableRuntime !== true}
<ActiveSettingChips
groups={[
{ tab: 'runtime', badges: runtimeBadges },
{ tab: 'permissions', badges: permissionBadges }
]}
onopen={openSetting}
/>
{/if}
{/snippet}
<!-- svelte-ignore a11y_autofocus -->
<div class="flex flex-col h-full">
<Tabs bind:selected={selectedTab} wrapperClass="flex-none w-full">
{#if customUi?.settingsPanel?.disableMetadata !== true}
<Tab
value="metadata"
aiId="script-builder-metadata"
aiDescription="Metadata settings"
label="Metadata"
/>
{/if}
{#if customUi?.settingsPanel?.disableRuntime !== true}
<Tab
value="runtime"
aiId="script-builder-runtime"
aiDescription="Runtime settings"
label="Runtime"
>
{#snippet extra()}
<SettingIcons badges={runtimeBadges} />
{/snippet}
</Tab>
<Tab
value="permissions"
aiId="script-builder-permissions"
aiDescription="Permissions settings: run on behalf of, runs visibility, job token"
label="Permissions"
>
{#snippet extra()}
<SettingIcons badges={permissionBadges} />
{/snippet}
</Tab>
{/if}
<!-- Not for dbt: its run form is derived from the descriptor server-side
(`dbt_arg_schema`), so anything refined here is overwritten by the
next deploy. -->
{#if customUi?.settingsPanel?.disableGeneratedUi !== true && !isDbt}
<Tab
value="ui"
aiId="script-builder-ui"
aiDescription="Generated UI settings"
label="Generated UI"
>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/json_schema_and_parsing"
>
The arguments are synced with the main signature but you may refine the parts that
cannot be inferred from the type directly.
</Tooltip>
{/snippet}
</Tab>
{/if}
{#if customUi?.settingsPanel?.disableTriggers !== true}
<Tab
value="triggers"
aiId="script-builder-triggers"
aiDescription="Triggers settings"
label="Triggers"
>
{#snippet extra()}
<Tooltip documentationLink="https://www.windmill.dev/docs/getting_started/triggers">
Configure how this script will be triggered.
</Tooltip>
{/snippet}
</Tab>
{/if}
{#snippet content()}
<div class="min-h-0 grow overflow-y-auto" bind:this={settingsContent}>
<TabContent value="metadata">
<div class="flex flex-col gap-8 p-4 pb-8">
<div class="flex flex-col gap-6">
<Label label="Summary">
<MetadataGen
aiId="create-script-summary-input"
aiDescription="Summary / Title of the new script"
bind:content={script.summary}
code={script.content}
promptConfigName="summary"
generateOnAppear
onChange={() => onSummaryChange(script.summary)}
elementProps={{
type: 'text',
placeholder: 'Short summary to be displayed when listed'
}}
/>
</Label>
<LabelsInput bind:labels={script.labels} class="-mt-4" />
<Label label="Path">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path"
>
The unique identifier of the script in the workspace that defines
permissions
</Tooltip>
{/snippet}
<Path
bind:this={path}
bind:error={pathError}
bind:path={script.path}
bind:dirty={dirtyPath}
{initialPath}
autofocus={false}
namePlaceholder="script"
kind="script"
workspaceOverride={opWorkspace}
/>
{#if initialPath && script.path && script.path !== initialPath}
<Alert
type="info"
size="xs"
title="Deploy the script to make the path change effective."
class="mt-2"
/>
{/if}
</Label>
<Label label="Description">
<MetadataGen
bind:content={script.description}
code={script.content}
promptConfigName="description"
elementType="textarea"
elementProps={{
placeholder: 'What this script does and how to use it'
}}
/>
</Label>
{#if script.schema && !disableAi && !customUi?.settingsPanel?.metadata?.disableAiFilling}
<AIFormSettings
bind:prompt={script.schema.prompt_for_ai as string | undefined}
type="script"
/>
{/if}
</div>
{#if !customUi?.settingsPanel?.metadata?.languages || customUi?.settingsPanel?.metadata?.languages?.length > 1}
<Section label="Language">
{#snippet action()}
<DefaultScripts />
{/snippet}
{#if lockedLanguage}
<div class="text-xs text-secondary mb-2">
As a forked script, the language '{script.language}' cannot be modified.
</div>
{/if}
<div class=" grid grid-cols-3 gap-2">
{#each langs as [label, lang] (lang)}
{@const isPicked =
(lang == script.language &&
template != 'bunnative' &&
template != 'docker' &&
template != 'claudesandbox' &&
template != 'pisandbox') ||
(template == 'bunnative' && lang == 'bunnative') ||
(template == 'docker' && lang == 'docker') ||
((template == 'claudesandbox' || template == 'pisandbox') &&
lang == 'bun')}
<Popover
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
>
<Button
aiId={`create-script-language-button-${lang}`}
aiDescription={`Choose ${lang} as the language of the script`}
unifiedSize="lg"
variant="default"
selected={isPicked}
btnClasses={isPicked ? '' : 'm-[1px]'}
on:click={() => onScriptLanguageTrigger(lang)}
disabled={lockedLanguage ||
(enterpriseLangs.includes(lang) && !$enterpriseLicense) ||
(script.kind == 'preprocessor' && !canHavePreprocessor(lang))}
startIcon={{
icon: LanguageIcon,
props: { lang }
} as ButtonType.Icon}
>
<span class="truncate">{label}</span>
{#if lang === 'dbt'}
<Badge color="orange" verySmall baseClass="ml-1">alpha</Badge>
{/if}
</Button>
{#snippet text()}
{label} is only available with an enterprise license
{/snippet}
</Popover>
{/each}
</div>
<div class="flex items-center gap-2 mt-4 flex-wrap">
<span class="text-2xs text-secondary">Template</span>
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'claudesandbox', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'claudesandbox'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
Claude Sandbox
</Button>
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'pisandbox', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'pisandbox'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
Pi Sandbox
</Button>
<span title="Workflow-as-Code">
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'bun', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'wac_typescript'
script.language = 'bun'
script.modules = {
'helper.ts': {
content:
'export function main(a: string): string {\n return `hello ${a}`\n}\n',
language: 'bun'
}
}
initContent('bun', script.kind, template)
}}
>
WAC TypeScript
</Button>
</span>
<span title="Workflow-as-Code">
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'python3', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'wac_python'
script.language = 'python3'
script.modules = {
'helper.py': {
content: 'def main(a: str) -> str:\n return f"hello {a}"\n',
language: 'python3'
}
}
initContent('python3', script.kind, template)
}}
>
WAC Python
</Button>
</span>
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'bun', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'ci_test_bun'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
CI Test TypeScript
</Button>
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'python3', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'ci_test_python'
script.language = 'python3'
initContent('python3', script.kind, template)
}}
>
CI Test Python
</Button>
</div>
</Section>
{/if}
<!-- Not for dbt: each kind tags a script for a role in a flow that a project
bundle cannot fill (approval, trigger, preprocessor), and the runtime only
ever runs it as an action. -->
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt}
<Section label="Script kind">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/script_kinds"
>
Tag this script's purpose within flows such that it is available as the
corresponding action.
</Tooltip>
{/snippet}
<!-- A script with no kind yet runs as an action, so the group is fed 'script'
rather than undefined: the toggle group reports no change out of
undefined, which would swallow the first pick. Two-way so the
highlight can never claim a kind the script does not have. -->
<ToggleButtonGroup
bind:selected={
() => script.kind ?? 'script',
(kind) => {
// Load-bearing: any write to script.kind echoes back through the
// group, and initContent replaces the editor content outright.
if (kind === (script.kind ?? 'script')) return
template = 'script'
script.kind = kind as Script['kind']
initContent(script.language, script.kind, template)
}
}
>
{#snippet children({ item })}
{#each scriptKindOptions as { value, title, desc, documentationLink, Icon }}
<ToggleButton
label={title}
{value}
tooltip={desc}
{documentationLink}
icon={Icon}
showTooltipIcon={Boolean(desc)}
{item}
/>
{/each}
{/snippet}
</ToggleButtonGroup>
</Section>
{/if}
{#if customUi?.settingsPanel?.disableRuntime}
<Section label="Worker group tag">
<WorkerTagPicker
bind:tag={script.tag}
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
{@render muteSetting()}
{/if}
</div>
</TabContent>
<TabContent value="runtime">
<div class="flex flex-col gap-8 p-4 pb-8" {@attach keepDeployedOnBehalfOf}>
<ScriptRuntimeSettings
bind:script
tagPlaceholder={customUi?.tagSelectPlaceholder}
defaultTimeout={customUi?.defaultTimeout}
/>
{@render muteSetting()}
</div>
</TabContent>
<TabContent value="permissions">
<div class="flex flex-col gap-6 p-4 pb-8">
<RunOnBehalfOfSetting
kind="script"
bind:email={script.on_behalf_of_email}
bind:permissionedAs={script.on_behalf_of}
bind:preserve={preserveOnBehalfOf}
bind:choice={onBehalfOfChoice}
savedEmail={savedScript?.on_behalf_of_email}
savedPermissionedAs={savedScript?.on_behalf_of}
targetWorkspace={opWorkspace ?? ''}
isNew={isNewScript}
/>
<InvisibleRunsSetting
kind="script"
bind:visibleToRunnerOnly={script.visible_to_runner_only}
/>
<JobTokenScopesSetting bind:value={script.job_token_scopes} kind="script" />
</div>
</TabContent>
{#if !isDbt}
<TabContent value="ui" class="h-full p-4">
<ScriptSchema
bind:schema={script.schema}
customUi={customUi?.settingsPanel?.metadata?.editableSchemaForm}
workspace={opWorkspace}
/>
</TabContent>
{/if}
<TabContent value="triggers" class="h-full">
<TriggersEditor
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
on:exitTriggers={() => {
captureTable?.loadCaptures(true)
}}
currentPath={script.path}
{initialPath}
{fakeInitialPath}
noEditor={true}
newItem={initialPath == ''}
isFlow={false}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(script.language)}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && savedScript?.no_deployed !== true}
schema={script.schema}
runnableVersion={deployedScriptHash}
onDeployTrigger={handleDeployTrigger}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
</div>
{/snippet}
</Tabs>
</div>
</DrawerContent>
</Drawer>
<div class="flex flex-col h-full">
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the script's path and
summary are the breadcrumb's, and its buttons are the header's actions. -->
<!-- `separator="none"`: the toolbar right below the bar draws its own `border-b`, and the
bar's edge would be a second line on top of it. -->
<PageHeaderContent
item={{
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'script',
// `script?`: this object is built inside a getter the band evaluates from its own
// tree, and `script` is a bindable prop. Deploying a new script clears the draft the
// route binds here and drops the `{#if}` around this editor in one flush, which
// invalidates the band before this component is torn down.
path: pathSnapshot ?? script?.path,
summary: script?.summary,
summaryContent: scriptSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
pathTrigger:
customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false
? scriptPathTrigger
: undefined
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
sessionSource={headerSessionOpen()}
actionFolds={3}
separator="none"
/>
{:else}
<div
bind:clientWidth={topbarWidth}
class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}"
>
<div class="flex gap-2 lg:gap-2 w-full items-center">
<div class="flex flex-row items-center gap-2 min-w-0 shrink">
{@render languageButton(condensedHeader ? 18 : 24)}
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{@render autosaveIndicator()}
{#if !condensedHeader}
{@render settingsBadges()}
{/if}
</div>
<!-- Separator -->
<div class="flex-1"></div>
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
<!-- Separator -->
<div class="flex-1"></div>
{@render rightButtons()}
</div>
</div>
{/if}
{#snippet scriptSummary()}
<!-- Everything the band pulls from this editor is gated on `script`. It is a bindable prop,
and these snippets render from the band's tree, where nothing orders the route clearing
that prop against this component's teardown: deploying a new script discards the draft
bound here and drops the `{#if}` around this editor in the same flush. -->
{#if script}
<!-- Not edited in place: clicking the name opens the summary and the path together, so
the band reads as a name rather than a form. `title` for one it truncates. -->
<div class="flex items-center gap-1 min-w-0">
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString(script.summary)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={script.summary}
>{emptyString(script.summary) ? 'Add a summary...' : script.summary}</span
>
{/snippet}
{#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false}
<PathEditPopover
label={summaryText}
bind:summary={script.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="script"
workspaceId={autosaveWorkspace}
/>
{:else}
{@render summaryText()}
{/if}
</div>
{/if}
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment: anchored there it
opens under the path, and `focusField` puts the cursor in the field that half of the
name belongs to. The bindings are written out again because `bind:` cannot be spread;
both instances bind the same slots, and only one is ever open. -->
{#snippet scriptPathTrigger(pathLabel: Snippet, triggerClass: string)}
<!-- Gated like the summary beside it: `script` is a bindable prop the route can clear in
the same flush that drops this editor, and this renders from the band's tree. The
`{:else}` is not decoration: the band hands the segment over the moment a page offers
a `pathTrigger`, so a gate that drew nothing would take the path off the trail. -->
{#if script}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary={script.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="script"
workspaceId={autosaveWorkspace}
/>
{:else}
<span class={triggerClass}>{@render pathLabel()}</span>
{/if}
{/snippet}
{#snippet scriptMarks()}
{#if script}
<!-- Concurrency, cache, a dedicated worker: settings that hold whatever the editor shows,
so they belong with the script's name. The language icon the editor's own bar carried
does not come along — the code under it says which language this is. -->
{@render settingsBadges('pl-1.5')}
{/if}
{/snippet}
{#snippet scriptHeaderActions()}
{#if script}
<!-- Saving state and other people's drafts: the editor's own bar carries them beside the
path, so the header has to carry them when it owns the bar — without them a
full-page editor saves with no word either way. -->
{@render autosaveIndicator()}
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
{@render rightButtons()}
{/if}
{/snippet}
{#snippet languageButton(size: number)}
<button
disabled={customUi?.topBar?.settings == false}
class="shrink-0"
title="Settings"
onclick={async () => {
metadataOpen = true
}}
>
<LanguageIcon lang={script.language} {size} />
</button>
{/snippet}
{#snippet autosaveIndicator()}
{#if opWorkspace}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="script"
path={indicatorPath}
draftOnly={savedScript?.no_deployed === true}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
compact={narrowTopbar}
/>
{/if}
{/snippet}
{#snippet settingsBadges(className?: string)}
{@const canOpenRuntime =
customUi?.topBar?.settings != false && customUi?.settingsPanel?.disableRuntime !== true}
<ScriptSettingsBadges
class={className}
settings={script}
onclick={canOpenRuntime
? () => {
selectedTab = 'runtime'
metadataOpen = true
}
: undefined}
/>
{/snippet}
{#snippet rightButtons()}
{#snippet settingsButton()}
{#if customUi?.topBar?.settings != false}
<Button
aiId="script-builder-settings"
aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI."
variant="default"
unifiedSize={headerBtnSize}
on:click={() => (metadataOpen = true)}
startIcon={{ icon: Settings }}
iconOnly={compactTopbar && !ownsPageHeader}
title="Settings"
>
<span> Settings </span>
</Button>
{/if}
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = savedScript?.no_deployed === true}
{@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly}
{@const diffTitle = isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
never shows on hover. pointer-events-none on the button lets the hover
reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize={headerBtnSize}
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar && !ownsPageHeader}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize={headerBtnSize}
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
iconOnly
title="More"
/>
{/snippet}
</DropdownV2>
{#if !phoneTopbar}
{@render diffButton()}
{@render settingsButton()}
{/if}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if scriptWorkerTags}
{#if scriptWorkerTags?.length ?? 0 > 0}
<div class="max-w-[200px]">
<WorkerTagSelect
nullTag={script.language}
placeholder={customUi?.tagSelectPlaceholder}
bind:tag={script.tag}
size={headerBtnSize}
workspaceId={opWorkspace}
/>
</div>
{/if}
{/if}
{/if}
{@render settingsButton()}
{/if}
{#if ownsPageHeader}
<OpenInSessionButton source={headerSessionOpen()} />
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
unifiedSize={headerBtnSize}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
{/snippet}
{#if showPipelineHint}
<div
class="flex items-center gap-2 px-4 py-1 border-b bg-surface-secondary text-xs text-secondary"
>
<Network size={14} class="shrink-0 text-tertiary" />
<span class="truncate">
This script can become a data pipeline step: annotate it with
<code class="font-mono text-2xs bg-surface px-1 py-0.5 rounded border">-- pipeline</code>
and
<code class="font-mono text-2xs bg-surface px-1 py-0.5 rounded border">
-- materialize
</code>
to materialize its result, or build it in the
<a href="/pipeline" class="text-blue-500 hover:underline">pipeline editor</a>.
<a
href="https://www.windmill.dev/docs/pipelines"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline"
>
Learn more
</a>
</span>
<div class="ml-auto shrink-0">
<Button
iconOnly
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: X }}
title="Dismiss pipelines hint"
onclick={() => (pipelineHintDismissed.val = true)}
/>
</div>
</div>
{/if}
{#if isDbt}
<DbtEditor
workspaceOverride={opWorkspace}
{customUi}
{onTestJob}
on:format={() => saveDraft()}
on:saveDraft={() => saveDraft()}
bind:editor
bind:this={scriptEditor}
bind:schema={script.schema}
path={script.path}
bind:code={script.content}
timeout={script.timeout}
tag={script.tag}
deployedHash={deployedScriptHash}
bind:args
bind:modules={script.modules}
/>
{:else}
<ScriptEditor
{disableAi}
previewOnBehalfOf={previewOnBehalfOf(
actingUser,
script.on_behalf_of,
script.on_behalf_of_email
)}
workspaceOverride={opWorkspace}
{sessionOpen}
bind:selectedTab={selectedInputTab}
{customUi}
{onTestJob}
collabMode
edit={initialPath != ''}
on:format={() => {
saveDraft()
}}
on:saveDraft={() => {
saveDraft()
}}
on:openTriggers={openTriggers}
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
bind:editor
bind:this={scriptEditor}
bind:schema={script.schema}
path={script.path}
stablePathForCaptures={initialPath || fakeInitialPath}
bind:code={script.content}
lang={script.language}
timeout={script.timeout}
kind={script.kind}
autoKind={script.auto_kind}
{template}
tag={script.tag}
lastSavedCode={savedScript?.content}
lastDeployedCode={savedScript?.content}
bind:args
bind:hasPreprocessor
bind:captureTable
bind:assets={script.assets}
bind:modules={script.modules}
enablePreprocessorSnippet
{testPanelCollapsed}
/>
{/if}
</div>
{:else}
Script Builder not available to operators
{/if}
<Modal title="dbt on Windmill is in alpha" bind:open={dbtAlphaOpen} cancelText="Got it">
<div class="flex flex-col gap-3 text-sm text-secondary">
<p>
A dbt script is a whole dbt project: the files are the script's module bundle, the content is
a <span class="font-mono text-xs">wm_dbt.yaml</span> descriptor, and the models become
<span class="font-mono text-xs">dbt://</span> assets in the graph.
</p>
<p>
Expect rough edges, and expect details to move: the descriptor's fields, what a run returns,
and how the graph is stored are all still settling.
</p>
<p>
Deploying and running work today, and an existing project needs no changes —
<span class="font-mono text-xs">cp -r</span> it into the script's folder and push. Nothing here
is load-bearing for other languages.
</p>
<p class="text-xs">
<a
href="https://www.windmill.dev/docs/getting_started/scripts_quickstart"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline">Docs</a
>
· report anything surprising, it is the most useful thing at this stage.
</p>
</div>
</Modal>
<WacExportDrawer bind:this={wacExportDrawer} />