Files
windmill/frontend/src/lib/components/details/ScriptDetail.svelte
T
Guilhem LemouelandClaude Opus 5 5ea041b4c2 fix: redact an adopted run against the schema its page ran
Redaction only visits the leaves the schema it is given declares, and the page
runs the deployed version as it stands when Run is pressed. A deploy between
this call reading the schema and that press can add a file argument ours never
had, and its bytes then passed unredacted into the persisted card and the
model's result, under the size limit that would have caught a large one.

`adopt` hands over the schema it ran against alongside the job and arguments,
and both redactions use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 02:56:19 -07:00

1366 lines
43 KiB
Svelte

<script lang="ts">
import {
JobService,
ScriptService,
WorkspaceService,
type Script,
type TriggersCount,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
defaultIfEmptyString,
emptyString,
canWrite,
truncateHash,
copyToClipboard,
urlParamsToObject,
extractTagFromSharableHash,
interpolateTag,
isDynamicTag,
isTagTemplate
} from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import {
disableHubStore,
enterpriseLicense,
hubBaseUrlStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
import { onDestroy, setContext, tick, untrack } from 'svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import {
Tabs,
Tab,
TabContent,
Badge,
Alert,
DrawerContent,
Drawer,
Button
} from '$lib/components/common'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import type { PendingRun } from '$lib/components/details/pendingRun'
import DbtRunGraph from '$lib/components/dbt/DbtRunGraph.svelte'
import { goto } from '$lib/navigation'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages'
import { sendUserToast } from '$lib/toast'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import {
Activity,
Archive,
ArchiveRestore,
Eye,
FolderOpen,
GitFork,
Globe2,
History,
Loader2,
Pen,
ChevronUpSquare,
Shield,
Trash,
Play,
ClipboardCopy,
LayoutDashboard,
ChevronDown,
ChevronRight
} from 'lucide-svelte'
import { scriptToHubUrl } from '$lib/hub'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import Popover from '$lib/components/Popover.svelte'
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
import { createRawAppFromScript } from '$lib/components/details/createRawAppFromScript'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import PersistentScriptDrawer from '$lib/components/PersistentScriptDrawer.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import Star from '$lib/components/Star.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import { Highlight } from 'svelte-highlight'
import json from 'svelte-highlight/languages/json'
import { writable } from 'svelte/store'
import Toggle from '$lib/components/Toggle.svelte'
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { slide } from 'svelte/transition'
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { interceptNav } from '$lib/components/details/interceptNav'
import {
setOperatingWorkspace,
useOperatingUser,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import {
buildForkEditUrl,
editInForkAllowed,
editInForkDescription,
editInForkLabel,
onEditInForkClick,
openEditInFork
} from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
import WacDiagram from '$lib/components/graph/WacDiagram.svelte'
import { twMerge } from 'tailwind-merge'
import CiTestResults from '$lib/components/CiTestResults.svelte'
let {
hash: routeHash,
workspace,
searchParams,
locationHash = '',
onNavigate = goto,
active = true,
embedded = false,
pendingRun,
onLoadState
}: {
/** The `[...hash]` route segment: either a script hash or a script path. */
hash: string
workspace: string | undefined
/** Query of the URL this detail was opened at (`dbt_retry_from`). */
searchParams?: URLSearchParams
/** `#`-fragment of that URL, carrying prefilled run args from `Run again`. */
locationHash?: string
onNavigate?: (url: string) => void | Promise<void>
/** Gates the window-level Ctrl+Enter handler, so a hidden instance cannot steal it. */
active?: boolean
/** Rendered inside a page that is not the script's own (an AI session preview tab), whose
* URL this must leave alone and which a cross-workspace link must not navigate away. */
embedded?: boolean
/** A chat tool call confirmed here rather than on its card. See {@link PendingRun}. */
pendingRun?: PendingRun
/** How the load ended, for a host that renders its own state around this page.
* Providing it also suppresses the "could not load" toast: a 404 here is the normal
* state of a not-yet-deployed path, which the host explains in place instead. */
onLoadState?: (state: 'loaded' | 'not_found') => void
} = $props()
// Every workspace-scoped call below and in the drawers this page opens targets
// `workspace`, not the nav store: a session views an item in its own workspace.
setOperatingWorkspace(() => workspace)
// Roles are per-workspace, so every gate below asks about `workspace` rather than about
// the one the browser is navigated to. Unresolved reads as unknown, which `canWrite`
// refuses — the safe answer while a fork's `whoami` is still in flight.
const operatingUser = useOperatingUser()
// A ⌘-click opens these in a new tab, which only the query names the workspace for.
const operatingHref = useOperatingWorkspaceHref()
const actingUser = $derived(operatingUser.current)
let script: Script | undefined = $state()
let topHash: string | undefined = $state()
let isHubScript = $state(false)
// Derived, not assigned during the load: in a fork the acting user is looked up
// asynchronously, and a value read after `getScriptByHash` resolves can land before that
// lookup does. Assigned once, that leaves the page permanently read-only — `canWrite`
// refuses an unknown user — with nothing to recompute it. A hub script is never writable.
const promptForAi = $derived((script?.schema?.prompt_for_ai as string | undefined) ?? '')
const can_write = $derived(
!isHubScript &&
!!script &&
script.workspace_id == workspace &&
canWrite(script.path, script.extra_perms!, actingUser)
)
const editRights = useEditRights()
// The edit actions also need a role that authors scripts, which no operator has.
const canEdit = $derived(can_write && editRights.roleCanAuthor('script'))
let deploymentInProgress = $state(false)
let expandedModuleLocks: Record<string, boolean> = $state({})
let expandedModuleCode: Record<string, boolean> = $state({})
let deploymentJobId: string | undefined = $state(undefined)
let intervalId: number
let shareModal: ShareModal | undefined = $state()
let runForm: RunForm | undefined = $state()
let scheduledForStr: string | undefined = $state(undefined)
let invisible_to_owner: boolean | undefined = $state(undefined)
let overrideTag: string | undefined = $state(undefined)
let overrideTagNote: string | undefined = $state(undefined)
// Tag carried over from 'Run again', pending the dynamic-tag check in loadScript
let carriedTag: string | undefined = undefined
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
let jsonView = $state(false)
let previousHash: string | undefined = $state(undefined)
let isWac = $derived(
script?.content && script?.language ? isWorkflowAsCode(script.content, script.language) : false
)
const triggersCount = writable<TriggersCount | undefined>(undefined)
// Add triggers context store
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
{ type: 'cli', path: '', isDraft: false }
])
)
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState
})
async function deleteScript(hash: string): Promise<void> {
try {
await ScriptService.deleteScriptByHash({ workspace: workspace!, hash })
loadScript(hash)
} catch (err) {
console.error(err)
sendUserToast(`Could not delete this script ${err.body}`, true)
}
}
async function archiveScript(hash: string): Promise<void> {
await ScriptService.archiveScriptByHash({ workspace: workspace!, hash })
loadScript(hash)
}
async function unarchiveScript(hash: string): Promise<void> {
const r = await ScriptService.getScriptByHash({ workspace: workspace!, hash })
const ns = await ScriptService.createScript({
workspace: workspace!,
requestBody: {
...r,
parent_hash: hash,
lock: r.lock
}
})
sendUserToast(`Unarchived script`)
loadScript(ns)
onNavigate(`/scripts/get/${ns}`)
}
async function syncer(): Promise<void> {
if (script?.hash) {
const status = await ScriptService.getScriptDeploymentStatus({
workspace: workspace!,
hash: script?.hash!
})
if (status.lock != undefined || status.lock_error_logs != undefined) {
deploymentInProgress = false
deploymentJobId = undefined
script.lock = status.lock
script.lock_error_logs = status.lock_error_logs
clearInterval(intervalId)
} else if (status.job_id) {
deploymentJobId = status.job_id
}
}
}
async function loadTriggers(path: string): Promise<void> {
await triggersState.fetchTriggers(triggersCount, workspace, path, false, undefined, actingUser)
}
async function loadScript(hash: string): Promise<void> {
// Check if this is a hub script path
if (hash.startsWith('hub/')) {
isHubScript = true
try {
const hubScript = await ScriptService.getHubScriptByPath({ path: hash })
// Create a partial Script object from hub script data
script = {
hash: '',
path: hash,
summary: hubScript.summary ?? '',
description: '',
content: hubScript.content,
created_by: '',
created_at: '',
archived: false,
deleted: false,
is_template: false,
extra_perms: {},
lock: hubScript.lockfile,
language: hubScript.language as Script['language'],
kind: 'script',
starred: false,
schema: hubScript.schema as Script['schema'],
auto_kind: undefined,
has_preprocessor: false
}
return
} catch (e) {
sendUserToast('Could not load hub script: ' + e.body, true)
return
}
}
isHubScript = false
try {
script = await ScriptService.getScriptByHash({
workspace: workspace!,
hash,
withStarredInfo: true,
authed: true
})
} catch {
try {
script = await ScriptService.getScriptByPath({
workspace: workspace!,
path: hash,
withStarredInfo: true
})
hash = script.hash
} catch (e) {
// A path with no deployed script 404s here, which is the normal state of a
// draft the user has not deployed — the host says so rather than the page
// staying blank, so no toast for it. Anything else is a real failure.
if (onLoadState && e?.status === 404) onLoadState('not_found')
else sendUserToast('Could not load script: ' + (e?.body ?? e?.message ?? e), true)
return
}
}
// A carried non-template value equal to the resolution of the script's own dynamic
// tag for these args is not an override but the previous run's pinned resolution:
// drop it so the backend re-resolves from the (possibly edited) args. A differing
// value is a genuine user override and a template re-resolves at push, so both stay.
if (
carriedTag &&
isDynamicTag(script.tag) &&
!isTagTemplate(carriedTag) &&
interpolateTag(script.tag ?? '', workspace!, args) === carriedTag
) {
if (overrideTag === carriedTag) {
overrideTag = undefined
overrideTagNote = `tag ${script.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
}
carriedTag = undefined
}
loadTriggers(script.path)
if (script.path && script.archived) {
const script_by_path = await ScriptService.getScriptByPath({
workspace: workspace!,
path: script.path
}).catch((_) => console.error('this script has no non-archived version'))
if (script_by_path?.hash != script.hash) {
topHash = script_by_path?.hash
}
} else {
topHash = undefined
}
intervalId && clearInterval(intervalId)
deploymentInProgress = script.lock == undefined && script.lock_error_logs == undefined
if (deploymentInProgress) {
intervalId = setInterval(syncer, 500)
}
if (!script.path.startsWith(`u/${actingUser?.username}`) && script.path.split('/').length > 2) {
invisible_to_owner = script.visible_to_runner_only
}
onLoadState?.('loaded')
}
onDestroy(() => {
intervalId && clearInterval(intervalId)
})
let isValid = $state(true)
let runLoading = $state(false)
async function runScript(
scheduledForStr: string | undefined,
args: Record<string, any>,
invisibleToOwner: boolean | undefined,
overrideTag: string | undefined
) {
// Taken before the round trip below: the call this press answers is the one it was
// pressed under, and a call settled meanwhile — declined, then another proposed for
// this same script — must not be answered with a job it never asked for.
const call = pendingRun
try {
runLoading = true
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
let run: string
if (isHubScript) {
run = await JobService.runScriptByPath({
workspace: workspace!,
path: script?.path ?? '',
requestBody: args,
scheduledFor,
invisibleToOwner,
tag: overrideTag,
skipPreprocessor: true
})
} else {
run = await JobService.runScriptByHash({
workspace: workspace!,
hash: script?.hash ?? '',
requestBody: args,
scheduledFor,
invisibleToOwner,
tag: overrideTag,
skipPreprocessor: true
})
}
// The call asked for this run; it follows the job rather than starting one, and
// shows it in place of this page — so navigating here as well would leave two
// surfaces open on the same job.
if (!call?.adopt(run, args, script?.schema ?? {}))
await onNavigate('/run/' + run + '?workspace=' + workspace)
} catch (err) {
sendUserToast(`Could not create job: ${err.body}`, true)
} finally {
// Not only on failure: a navigation that keeps this component mounted — the run
// opens as another preview tab in a session — would leave Run spinning forever.
runLoading = false
}
}
let args: Record<string, any> | undefined = $state(undefined)
// Read once on purpose: these args seed the form, so tracking the fragment would
// overwrite what the user has typed whenever it changes.
let hash = untrack(() => locationHash)
if (hash.length > 1) {
try {
let searchParams = new URLSearchParams(hash.slice(1))
carriedTag = extractTagFromSharableHash(searchParams)
overrideTag = carriedTag
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
k,
JSON.parse(v)
])
args = Object.fromEntries(params)
} catch (e) {
console.error('Was not able to transform hash as args', e)
}
}
// A chat call confirmed here opens its form on what the chat proposed. Read at init like
// the hash above, and for the same reason; the host remounts this page when the call
// changes, so a tab already open on this script still builds its form from the new one.
if (pendingRun) args = { ...untrack(() => pendingRun.proposedArgs) }
// A dbt retry must name the run it resumes, and a job id is not something to
// type from memory: picking `retry` fills the field with the run this caller's
// retry would land on. Filled once per script, so clearing it stays cleared.
let dbtResumableAsked: string | undefined = $state(undefined)
$effect(() => {
const ws = workspace
const path = script?.path
const command = args?.['command'] as Record<string, any> | undefined
if (!ws || !path || script?.language !== 'dbt' || command?.label !== 'retry') return
if (command['dbt_retry_job'] || dbtResumableAsked === path) return
dbtResumableAsked = path
JobService.getDbtResumableForScript({ workspace: ws, path })
.then((held) => {
// The page may show another script, or another workspace, by now:
// filling THIS answer into that form aims its retry at a run of a
// script it is not, which only a reload could undo.
if (workspace !== ws || script?.path !== path) return
const current = untrack(() => args)
const block = current?.['command'] as Record<string, any> | undefined
if (!held || !current || block?.label !== 'retry' || block['dbt_retry_job']) return
args = { ...current, command: { ...block, dbt_retry_job: held } }
if (jsonView) {
runForm?.syncJsonEditor()
}
})
.catch(() => {})
})
// Arrived from a failed dbt run's `Run again`, which prefills the arguments it
// ran with — a whole-project rebuild. Resuming that run instead is usually
// what the reader wants and there is nothing on this form that says so, hence
// the offer. Confirmed against the run rather than trusted from the URL: only
// the latest failure of a script is resumable, and by the time the form opens
// another run may hold it.
let dbtRetryFrom: string | undefined = $state(undefined)
$effect(() => {
const ws = workspace
const from = searchParams?.get('dbt_retry_from') ?? undefined
dbtRetryFrom = undefined
if (!ws || !from || script?.language !== 'dbt') return
JobService.getDbtResumable({ workspace: ws, id: from })
.then((held) => {
if (workspace === ws && held === from) dbtRetryFrom = from
})
.catch(() => {})
})
function useDbtRetry() {
const from = dbtRetryFrom
if (!from) return
// Merged, never replaced: a tag, concurrency key or debounce key may
// interpolate `command.vars`, and the QUEUE reads those at enqueue — long
// before the worker restores the failed run's own arguments. Dropping them
// here routes the retry by a different key than the run it resumes. Same
// as the run page's retry.
args = {
...(args ?? {}),
command: {
...((args?.['command'] as Record<string, any> | undefined) ?? {}),
label: 'retry',
dbt_retry_job: from
}
}
if (jsonView) {
runForm?.syncJsonEditor()
}
}
let moveDrawer: MoveDrawer | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let persistentScriptDrawer: PersistentScriptDrawer | undefined = $state()
let showEditButtons = $state(false)
// The dev workspace's editor is not one the session panel can host, so from a preview tab
// it opens in a new browser tab, as the session editors' own entry does.
function editInFork(e: Event | undefined, itemPath: string) {
if (!embedded) {
return onEditInForkClick(e, 'script', itemPath, { hasHref: true, prodWorkspace: workspace })
}
const m = e as MouseEvent | undefined
if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return
e?.preventDefault()
return openEditInFork('script', itemPath, workspace)
}
function getMainButtons(
script: Script | undefined,
args: object | undefined,
topHash?: string,
can_write?: boolean
) {
const buttons: any = []
if (!topHash && script && !actingUser?.operator && !script.codebase) {
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/scripts/add?template=${script.path}`),
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
unifiedSize: 'md',
variant: 'subtle',
disabled: !showEditButtons,
startIcon: GitFork
}
})
}
if (
script &&
!actingUser?.operator &&
!isCloudHosted() &&
editInForkAllowed(workspace, $userWorkspaces)
) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('script', script.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, script.path),
unifiedSize: 'md',
variant: !showEditButtons ? 'default' : 'subtle',
startIcon: Pen
}
})
}
if (!script) {
return buttons
}
buttons.push({
label: `Runs`,
buttonProps: {
href: operatingHref(`/runs/${script.path}`),
onClick: interceptNav(onNavigate, `/runs/${script.path}`),
unifiedSize: 'md',
variant: 'subtle',
startIcon: Play
}
})
if (!script || !canEdit) {
return buttons
}
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
narrow: 'menu',
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
},
unifiedSize: 'md',
variant: 'subtle',
startIcon: History
}
})
}
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
},
disabled: !showEditButtons,
unifiedSize: 'md',
variant: 'subtle',
startIcon: LayoutDashboard
}
})
if (script?.restart_unless_cancelled ?? false) {
buttons.push({
label: 'Current runs',
buttonProps: {
onClick: () => {
persistentScriptDrawer?.open?.(script)
},
unifiedSize: 'md',
startIcon: Activity,
variant: 'accent'
}
})
}
if (!script.codebase) {
// One destination for both paths: the intercepted click has to carry the version
// the page is showing, or Edit on a historical version opens the current script.
const editUrl = `/scripts/edit/${script.path}${
topHash ? `?hash=${script.hash}&topHash=${topHash}` : ''
}`
buttons.push({
label: 'Edit',
buttonProps: {
href: operatingHref(editUrl),
onClick: interceptNav(onNavigate, editUrl),
unifiedSize: 'md',
startIcon: Pen,
variant: 'accent',
disabled: !can_write || !showEditButtons
}
})
}
}
return buttons
}
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: workspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
function getMenuItems(
script: Script | undefined,
deployUiSettings: WorkspaceDeployUISettings | undefined
) {
if (!script || actingUser?.operator) return []
const menuItems: any = []
if (showEditButtons) {
menuItems.push({
label: 'Move/Rename',
Icon: FolderOpen,
onclick: () => {
moveDrawer?.openDrawer(script?.path ?? '', script?.summary, 'script')
}
})
}
menuItems.push({
label: 'Audit logs',
Icon: Eye,
onclick: () => {
onNavigate(`/audit_logs?resource=${script?.path}`)
}
})
menuItems.push({
label: 'Permissions',
Icon: Shield,
onclick: () => {
shareModal?.openDrawer(script?.path ?? '', 'script')
}
})
if (!$disableHubStore) {
menuItems.push({
label: 'Publish to Hub',
Icon: Globe2,
onclick: () => {
if (!script) return
window.open(scriptToHubUrl(script, $hubBaseUrlStore).toString(), '_blank', 'noopener')
}
})
}
if (isDeployable('script', script?.path ?? '', deployUiSettings)) {
menuItems.push({
label: 'Deploy to staging/prod',
Icon: ChevronUpSquare,
onclick: () => {
deploymentDrawer?.openDrawer(script?.path ?? '', 'script')
}
})
}
if (showEditButtons) {
if (script.archived) {
menuItems.push({
label: 'Unarchive',
Icon: ArchiveRestore,
onclick: async () => {
unarchiveScript(script.hash)
},
color: 'red'
})
} else {
menuItems.push({
label: 'Archive',
Icon: Archive,
onclick: async () => {
archiveScript(script.hash)
},
color: 'red'
})
}
if (actingUser?.is_admin) {
menuItems.push({
label: 'Delete',
Icon: Trash,
onclick: async () => {
deleteScript(script.hash)
},
color: 'red'
})
}
}
return menuItems
}
let versionsDrawerOpen = $state(false)
function onKeyDown(event: KeyboardEvent) {
// Several instances can be mounted at once (a session keeps hidden tabs
// mounted), and each one listens on the window: only the visible one runs.
// `defaultPrevented` keeps anything nearer that already claimed the key — the chat
// composer's send, a drawer's Escape — from also reaching the form.
if (!active || event.defaultPrevented) return
switch (event.key) {
case 'Enter':
if (event.ctrlKey || event.metaKey) {
if (isValid) {
event.preventDefault()
runForm?.run()
} else {
sendUserToast('Please fix errors before running', true)
}
}
break
}
}
let rightPaneSelected = $state('saved_inputs')
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
$effect(() => {
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
if (cliTrigger) {
cliTrigger.extra = {
cliCommand: `wmill script run ${script?.path} -d '${JSON.stringify(args)}'`
}
}
})
let loading = $derived(!script)
// A pinned version rides in the query rather than replacing the route segment, so a
// host keying on the path — a session preview tab — keeps pointing at the script
// while showing one of its old versions.
let requestedHash = $derived(searchParams?.get('version') || routeHash)
$effect(() => {
if (workspace) {
if (previousHash != requestedHash) {
previousHash = requestedHash
untrack(() => loadScript(requestedHash ?? ''))
}
}
})
let mainButtons = $derived(getMainButtons(script, args, topHash, can_write))
</script>
<MoveDrawer
bind:this={moveDrawer}
on:update={async (e) => {
await onNavigate('/scripts/get/' + e.detail + `?workspace=${workspace}`)
loadScript(routeHash ?? '')
}}
/>
<svelte:window onkeydown={onKeyDown} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<PersistentScriptDrawer bind:this={persistentScriptDrawer} />
<ShareModal bind:this={shareModal} />
<Drawer bind:open={versionsDrawerOpen} size="1200px">
<DrawerContent title="Versions History" on:close={() => (versionsDrawerOpen = false)} noPadding>
{#if script}
<ScriptVersionHistory
scriptPath={script.path}
openDetails
on:openDetails={(e) => {
if (script) {
onNavigate(`/scripts/get/${e.detail.version}?workspace=${workspace}`)
}
versionsDrawerOpen = false
}}
/>
{/if}
</DrawerContent>
</Drawer>
{#key script?.hash}
<DetailPageLayout bind:selected={rightPaneSelected} isOperator={actingUser?.operator}>
{#snippet header({ wide }: { wide: boolean })}
<DetailPageHeader
{wide}
ownsPageHeader={!embedded}
{mainButtons}
menuItems={getMenuItems(script, deployUiSettings)}
bind:errorHandlerMuted={
() => script?.ws_error_handler_muted ?? false,
(v) => {
if (script !== undefined) script.ws_error_handler_muted = v
}
}
errorHandlerKind="script"
scriptOrFlowPath={script?.path ?? ''}
tag={script?.tag ?? ''}
labels={script?.labels}
inheritedLabels={script?.inherited_labels}
on:seeTriggers={() => {
rightPaneSelected = 'triggers'
}}
summary={script?.summary}
path={script?.path}
onSaved={can_write
? async (newPath) => {
if (newPath !== script?.path) {
await onNavigate(`/scripts/get/${newPath}?workspace=${workspace}`)
} else {
loadScript(newPath)
}
}
: undefined}
>
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
showDraft={false}
path={script?.path ?? ''}
newItem={false}
isFlow={false}
selected={rightPaneSelected === 'triggers'}
onSelect={async (triggerIndex: number) => {
if (rightPaneSelected !== 'triggers') {
rightPaneSelected = 'triggers'
}
await tick()
triggersState.selectedTriggerIndex = triggerIndex
}}
/>
{/snippet}
{#if workspace && script}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/if}
{#if script?.auto_kind === 'wac'}
<Popover notClickable>
{#snippet text()}
Workflow-as-Code
{/snippet}
<Badge small color="indigo" baseClass="border border-indigo-200">wac</Badge>
</Popover>
{/if}
{#if script?.auto_kind === 'test'}
<Popover notClickable>
{#snippet text()}
CI test script
{/snippet}
<Badge small color="yellow" baseClass="border">CI test</Badge>
</Popover>
{/if}
{#if script?.codebase}
<Badge
>bundle<Tooltip
>This script is deployed as a bundle and can only be deployed from the CLI for now</Tooltip
></Badge
>
{/if}
<OnBehalfOfBadge
onBehalfOf={script?.on_behalf_of}
onBehalfOfEmail={script?.on_behalf_of_email}
kind="script"
/>
{#if script?.priority != undefined}
<div class="hidden md:block">
<Badge color="blue" variant="outlined" size="xs">
{`Priority: ${script.priority}`}
</Badge>
</div>
{/if}
{#if script?.restart_unless_cancelled ?? false}
<button onclick={() => persistentScriptDrawer?.open?.(script)}>
<div class="hidden md:block">
<Badge color="red" variant="outlined" size="xs">Persistent</Badge>
</div>
</button>
{/if}
{#if script?.concurrent_limit != undefined && script.concurrency_time_window_s != undefined}
<div class="hidden md:block">
<Badge color="gray" variant="outlined" size="xs">
{`Concurrency limit: ${script.concurrent_limit} runs every ${script.concurrency_time_window_s}s`}
</Badge>
</div>
{/if}
</DetailPageHeader>
{/snippet}
{#snippet form()}
<div class="px-3">
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
</div>
{#if script}
{@const showsDbtGraph = script.language === 'dbt' && !!script.path}
<div class={twMerge('flex flex-col', isWac || showsDbtGraph ? 'h-full divide-y' : '')}>
<div
class={twMerge(
'p-8 w-full max-w-3xl overflow-y-auto mx-auto flex flex-col relative',
isWac ? 'max-h-1/2' : '',
// The graph takes the rest of the pane, as a flow's does, so the form
// scrolls within its half instead of pushing the models off-screen.
showsDbtGraph ? 'shrink-0 max-h-1/2' : ''
)}
>
{#if script?.path}
<CiTestResults path={script.path} kind="script" />
{/if}
<div class="flex flex-col gap-0.5 mb-1">
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2 my-2">
{#if script.lock_error_logs}
<Alert type="error" title="Deployment failed">
<p>
This script has not been deployed successfully because of the following
errors:
</p>
<LogViewer
content={script.lock_error_logs}
isLoading={false}
tag={undefined}
/>
</Alert>
{/if}
{#if topHash}
<div class="mt-2"></div>
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a
href="/scripts/get/{topHash}?workspace={workspace}"
onclick={interceptNav(onNavigate, `/scripts/get/${topHash}`)}
>Go to the HEAD of this path</a
>
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<Alert type="error" title="Deleted">
<p>The content of this script was deleted (by an admin, no less)</p>
</Alert>
{/if}
</div>
{/if}
{#if !emptyString(script.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(script?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
</div>
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<Badge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={workspace}"
class="underline"
target="_blank">view job</a
>
{/if}
</Badge>
</div>
{/if}
<div class="flex flex-col align-left">
{#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected}
{@const hasSchema =
script.schema && Object.keys(script.schema.properties ?? {}).length > 0}
<div
class="flex flex-row justify-between min-h-12"
transition:slide={{ duration: 150 }}
>
<InputSelectedBadge
onReject={() => {
savedInputsV2?.resetSelected()
}}
{inputSelected}
/>
<!-- Not offered while the form refuses writes: each side of this toggle
mounts what the other unmounts, and a `password` field mints a
variable from the literal it opens on. The form stays on whichever
side it is already showing, read-only. -->
{#if hasSchema && !pendingRun?.planModeActive}
<Toggle
bind:checked={jsonView}
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
/>
{/if}
</div>
{/if}
<!-- Landed here from a failed dbt run's `Run again`, which prefills a
whole-project rebuild. Resuming that run is one click, and the
form alone never says it is possible. -->
{#if dbtRetryFrom && (args?.['command'] as any)?.label !== 'retry'}
<div class="mb-2">
<Alert type="info" size="xs" title="This rebuilds the whole project">
<div class="flex flex-row gap-2 items-center flex-wrap">
<span>
The run you came from failed part-way. <span class="font-mono"
>dbt retry</span
>
rebuilds only its failed and skipped nodes, with the arguments it ran with.
</span>
<Button size="xs" variant="border" color="light" on:click={useDbtRetry}>
Retry that run instead
</Button>
</div>
</Alert>
</div>
{/if}
{#snippet aiAssistant()}
{#if Object.keys(script?.schema?.properties ?? {}).length > 0}
<AIFormAssistant
instructions={promptForAi}
onEditInstructions={canEdit
? () => onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`)
: undefined}
runnableType="script"
path={script?.path}
/>
{/if}
{/snippet}
{#if promptForAi}
{@render aiAssistant()}
{/if}
{#if pendingRun}
<InputSelectedBadge
inputSelected="pending_run"
onReject={() => pendingRun.decline()}
/>
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
bind:overrideTag
{overrideTagNote}
syncArgsToUrl={!embedded}
viewKeybinding
loading={runLoading}
autofocus
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
runRefusal={pendingRun?.planModeActive
? PLAN_MODE_MESSAGES.runFormRefused
: undefined}
bind:args
schedulable={!pendingRun}
bind:this={runForm}
{jsonView}
actions={promptForAi ? undefined : aiAssistant}
/>
</div>
<div class="pt-4 flex flex-row gap-1 w-full justify-end items-center">
{#if !isHubScript}
<span class="text-2xs text-secondary">
Edited <TimeAgo date={script.created_at || ''} /> by {script.created_by ||
'unknown'}
</span>
{/if}
<div class="flex flex-row gap-x-2 flex-wrap items-center">
{#if !isHubScript}
<Badge small color="gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{/if}
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
</div>
</div>
{#if showsDbtGraph}
<!-- The bottom of the pane, the way a flow's graph takes it: a dbt
script's `content` is a descriptor, so what it BUILDS is the thing
to look at. Pinned to the version on screen and given no job, so
this is the project as THIS deploy declared it; a node opens its
SQL and previews its rows with whatever the form above holds. -->
<div class="grow min-h-0">
<DbtRunGraph scriptPath={script.path} scriptHash={script.hash} runArgs={args} fill />
</div>
{:else if isWac && script.content}
<div class="grow min-h-0" style="min-height: 400px;">
<WacDiagram code={script.content} language={script.language ?? ''} />
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet save_inputs()}
{#if args}
<SavedInputsV2
schema={script?.schema}
bind:this={savedInputsV2}
scriptPath={script?.path}
scriptHash={topHash}
{isValid}
{jsonView}
{args}
bind:inputSelected
on:selected_args={(e) => {
const nargs = JSON.parse(JSON.stringify(e.detail))
args = nargs
if (jsonView) {
runForm?.syncJsonEditor()
}
}}
/>
{/if}
{/snippet}
{#snippet triggers()}
{#if script}
<TriggersEditor
{args}
runnableVersion={script.hash}
initialPath={script.path}
currentPath={script.path}
noEditor={true}
newItem={false}
isFlow={false}
schema={script.schema}
isDeployed={true}
noCapture={true}
isEditor={false}
/>
{/if}
{/snippet}
{#snippet scriptRender()}
<div class="h-full">
<Skeleton {loading} layout={[[20]]} />
<Tabs selected="code">
<Tab value="code" label="Code" />
<Tab value="dependencies" label="Lockfile" />
<Tab value="schema" label="Schema" />
{#snippet content()}
{#if script}
<TabContent value="code">
<div class="p-2 w-full">
<HighlightCode
language={script.language}
code={script.content}
className="whitespace-pre-wrap"
/>
</div>
{#if script?.modules}
{#each Object.entries(script.modules) as [modulePath, mod]}
<div class="mt-2 border rounded mx-2">
<button
class="flex items-center gap-1 w-full px-3 py-2 text-sm font-medium text-secondary hover:bg-surface-hover"
onclick={() =>
(expandedModuleCode[modulePath] = !expandedModuleCode[modulePath])}
>
{#if expandedModuleCode[modulePath]}
<ChevronDown size={14} />
{:else}
<ChevronRight size={14} />
{/if}
{modulePath}
</button>
{#if expandedModuleCode[modulePath]}
<div class="p-2 w-full">
<HighlightCode
language={mod.language}
code={mod.content}
className="whitespace-pre-wrap"
/>
</div>
{/if}
</div>
{/each}
{/if}
</TabContent>
<TabContent value="dependencies">
<div>
{#if script?.lock}
<div class="relative overflow-x-auto w-full">
<Button
wrapperClasses="absolute top-2 right-2 z-20"
on:click={() => copyToClipboard(script?.lock)}
color="light"
size="xs2"
startIcon={{
icon: ClipboardCopy
}}
iconOnly
/>
<pre class="bg-surface-secondary text-sm p-2 h-full overflow-auto w-full"
>{script.lock}</pre
>
</div>
{:else}
<p class="bg-surface-secondary text-sm p-2">
There is no lock file for this script
</p>
{/if}
{#if script?.modules}
{@const moduleEntries = Object.entries(script.modules).filter(
([_, m]) => m.lock
)}
{#each moduleEntries as [modulePath, mod]}
<div class="mt-2 border rounded">
<button
class="flex items-center gap-1 w-full px-3 py-2 text-sm font-medium text-secondary hover:bg-surface-hover"
onclick={() =>
(expandedModuleLocks[modulePath] = !expandedModuleLocks[modulePath])}
>
{#if expandedModuleLocks[modulePath]}
<ChevronDown size={14} />
{:else}
<ChevronRight size={14} />
{/if}
{modulePath}
</button>
{#if expandedModuleLocks[modulePath]}
<div class="relative overflow-x-auto w-full">
<Button
wrapperClasses="absolute top-2 right-2 z-20"
on:click={() => copyToClipboard(mod.lock ?? '')}
color="light"
size="xs2"
startIcon={{
icon: ClipboardCopy
}}
iconOnly
/>
<pre
class="bg-surface-secondary text-sm p-2 h-full overflow-auto w-full"
>{mod.lock}</pre
>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</TabContent>
<TabContent value="schema">
<div class="p-1 relative h-full">
<button
onclick={() => copyToClipboard(JSON.stringify(script?.schema, null, 4))}
class="absolute top-2 right-2"
>
<ClipboardCopy size={14} />
</button>
<Highlight language={json} code={JSON.stringify(script?.schema, null, 4)} />
</div>
</TabContent>
{/if}
{/snippet}
</Tabs>
</div>
{#if script?.envs && script.envs.length > 0}
<h3>Static Env Variables</h3>
<ul>
{#each script?.envs as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{/snippet}
</DetailPageLayout>
{/key}