feat: data upload opens on its inputs alone, works in sessions, with a prominent Upload data button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 15:11:29 +02:00
co-authored by Claude Opus 5.5
parent e96f6bcfae
commit 9e5982769a
10 changed files with 313 additions and 122 deletions
@@ -1523,6 +1523,9 @@
} catch (e) {
console.log('error initializing services', e)
}
// Unmounted while the services loaded: the container is detached, and
// creating the editor on it throws.
if (destroyed || !divEl?.isConnected) return
// vscode.languages.registerDefinitionProvider('*', {
// provideDefinition(document, position, token) {
+34 -37
View File
@@ -2068,43 +2068,15 @@
</div>
{/if}
{#if customUi?.previewPanel?.hideArgs}
<!-- Compact preview layout used by the pipeline editor:
no args column (the script is known to take no
inputs), LogPanel takes the full width, with a
small Test/Cancel button at the top-left of the
preview band. The earlier `-translate-y-1/2`
"float onto the editor" version was clipped in
Firefox (transform + overflow: visible interaction
on the parent splitpane Pane), so the button is
now positioned inside the panel — `top-1` keeps
it visually pinned to the top edge without
relying on cross-browser overflow behaviour. -->
<div class="relative h-full pt-9 flex flex-col">
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
<!-- Right-side affordances when we're displaying a *completed*
job (either the user just ran a test, or the on-mount
last-run loader populated the panel). The job-id link
opens the full run page in a new tab; the dispatch
button mounts a popover that shows what downstream
jobs this run triggered (rendered only when the run
actually dispatched anything). -->
<div class="absolute top-1 right-2 z-10 flex items-center gap-2">
<a
class="text-3xs text-blue-600 hover:underline font-mono"
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
target="_blank"
rel="noopener noreferrer"
title="Open this run"
>
{testJob.id.slice(0, 8)}… ↗
</a>
<DispatchEventsButton
workspace={testJob.workspace_id ?? opWs}
jobId={testJob.id}
/>
</div>
{/if}
<div class="absolute top-1 left-2 z-10">
<!-- Compact preview layout used by the pipeline editor: no args
column (most pipeline scripts take no inputs) and a full-width
LogPanel. The controls sit in normal flow rather than floating
onto the editor band, which Firefox clipped (transform +
overflow: visible on the parent splitpane Pane). -->
<div class="h-full flex flex-col">
<!-- Test sits at the top right, the last run's link under it. -->
<div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1">
<div>
{#if testIsLoading}
{@render cancelTestButton('sm', 'shadow-md')}
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun}
@@ -2238,6 +2210,31 @@
{@render runTestButton('sm', 'shadow-md')}
{/if}
</div>
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
<!-- Right-side affordances when we're displaying a *completed*
job (either the user just ran a test, or the on-mount
last-run loader populated the panel). The job-id link
opens the full run page in a new tab; the dispatch
button mounts a popover that shows what downstream
jobs this run triggered (rendered only when the run
actually dispatched anything). -->
<div class="flex items-center gap-2">
<a
class="text-3xs text-blue-600 hover:underline font-mono"
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
target="_blank"
rel="noopener noreferrer"
title="Open this run"
>
{testJob.id.slice(0, 8)}… ↗
</a>
<DispatchEventsButton
workspace={testJob.workspace_id ?? opWs}
jobId={testJob.id}
/>
</div>
{/if}
</div>
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
<div
class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3"
@@ -1325,6 +1325,7 @@
label: string
/** Nothing to show: the edges into the node already say it runs on writes. */
hidden?: boolean
kind?: string
nodeId?: string
missing?: boolean
draft?: boolean
@@ -1352,6 +1353,7 @@
: undefined
return {
label,
kind,
nodeId: t.nodeId,
missing,
draft: t.data?.draft,
@@ -299,6 +299,12 @@
// can persist a data-upload entry's staged input (drives node readiness).
// `isValid` is the full-schema validity, not just "file present".
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
// Show only the open script's inputs and runs (a data-upload click), not
// its editor. `onRunInputs` runs it with them, drafts included, and
// `onShowScript` leaves this view for the script.
inputsOnly?: boolean
onRunInputs?: (path: string, args: Record<string, any>) => Promise<string | undefined>
onShowScript?: () => void
}
let {
selection,
@@ -351,7 +357,10 @@
onRunByPath,
onRunCascadeByPath,
runFormInitialArgs,
onRunFormArgsChange
onRunFormArgsChange,
inputsOnly = false,
onRunInputs,
onShowScript
}: Props = $props()
let readOnly = $derived(mode !== 'edit')
@@ -433,7 +442,7 @@
// PipelineScriptView renders instead of the test panel.
let viewReadyForTarget = $derived.by(
() =>
readOnly &&
(readOnly || inputsOnly) &&
runTargetPath !== undefined &&
script?.path === runTargetPath &&
(isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath))
@@ -574,7 +583,9 @@
let args = $state<Record<string, any>>({})
let argsSeedPath: string | undefined = undefined
$effect.pre(() => {
const p = script?.path
// Re-seeded on leaving the inputs-only view too, so the file picked there
// shows in the editor's test form.
const p = script?.path === undefined ? undefined : `${script.path}|${inputsOnly}`
if (p === argsSeedPath) return
argsSeedPath = p
args = runFormInitialArgs ? structuredClone($state.snapshot(runFormInitialArgs)) : {}
@@ -584,7 +595,8 @@
// required field (not just the S3 file) is satisfied.
let runFormIsValid = $state(true)
$effect(() => {
if (readOnly || !script) return
// Inputs-only mounts no ScriptEditor: PipelineScriptView reports its own args.
if (readOnly || inputsOnly || !script) return
onRunFormArgsChange?.(script.path, $state.snapshot(args), runFormIsValid)
})
@@ -1333,6 +1345,26 @@
<div class="p-3 text-xs text-red-500">
Failed to load: {scriptRes.error.message}
</div>
{:else if script && inputsOnly}
{#key script.path}
<PipelineScriptView
{script}
{isDraft}
canRun
runsDrafts
inputsOnly
{onShowScript}
onRun={onRunInputs}
{runsRefreshKey}
{runsPendingJobId}
initialArgs={runFormInitialArgs}
onArgsChange={onRunFormArgsChange}
onRunCompleted={() => {
previewRefreshKey += 1
onRunCompleted?.()
}}
/>
{/key}
{:else if script && readOnly}
<!-- Read-only modes: no Monaco/ScriptEditor (operators are
backend-blocked from previews anyway) — highlighted source,
@@ -24,8 +24,10 @@
Pencil,
CircleSlash,
Trash2,
Upload,
XCircle
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import type { ScriptLang } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast, type Item } from '$lib/utils'
@@ -48,6 +50,8 @@
/** What starts a producing script, as its chip shows it. */
export type TriggerChipData = {
label: string
/** The native trigger kind; a data upload renders as a call to action. */
kind?: string
/** No chip: the node's incoming edges already show what starts it. */
hidden?: boolean
missing?: boolean
@@ -517,7 +521,29 @@
trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer',
trigger?.selected && CHIP_SELECTED
)}
{#if trigger?.onOpen}
{#if trigger?.kind === 'data_upload' && trigger.onOpen}
<!-- The one trigger that needs the user: nothing runs until a file is
provided, so it reads as the node's main action rather than a chip. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="flex min-w-0"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Upload }}
title={`Upload the data ${triggerScript} runs on`}
onClick={(e) => {
e?.stopPropagation()
trigger.onOpen?.()
}}
>
Upload data
</Button>
</span>
{:else if trigger?.onOpen}
<button
type="button"
class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')}
@@ -95,6 +95,9 @@
onRunCascadeByPath,
runFormInitialArgs,
onRunFormArgsChange,
inputsOnly = false,
onRunInputs,
onShowScript,
readyDataUploadPaths,
resolveLocalScript,
localScriptsVersion,
@@ -213,6 +216,10 @@
// callback to persist them as they change — see the page's dataUploadArgs.
runFormInitialArgs?: Record<string, any>
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
/** See AssetGraphDetailsPane's `inputsOnly`. */
inputsOnly?: boolean
onRunInputs?: (path: string, args: Record<string, any>) => Promise<string | undefined>
onShowScript?: () => void
// Data-upload entry scripts whose staged upload is ready (green node).
readyDataUploadPaths?: Set<string>
/** Local-dev (`/pipeline_dev`): resolve a node to its working-tree content
@@ -329,6 +336,13 @@
onSelect(s)
if (opts?.open && s && panelMode === 'modal') panelModalOpen = true
}
// A data-upload node's click asks for its run form; in modal mode nothing shows
// it unless the modal opens too.
$effect(() => {
if (focusUploadSignal > 0 && untrack(() => panelMode) === 'modal') {
untrack(() => (panelModalOpen = true))
}
})
const showNodeHint = $derived(panelMode === 'modal' && !panelModalOpen)
const nodeHintText = $derived(
effectiveSelection
@@ -656,6 +670,9 @@
{onRunCascadeByPath}
{runFormInitialArgs}
{onRunFormArgsChange}
{inputsOnly}
{onRunInputs}
{onShowScript}
{resolveLocalScript}
{localScriptsVersion}
selection={activeDraft ? undefined : editor.selection}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { AssetKind, Script } from '$lib/gen'
import { CalendarClock, Loader2, Play, Upload, Zap } from 'lucide-svelte'
import { CalendarClock, Code2, Loader2, Play, Upload, Zap } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import PipelineRunForm from './PipelineRunForm.svelte'
@@ -44,6 +44,12 @@
// "green / ready" state and seeds the whole-pipeline run. `isValid` is the
// full-schema validity (all required fields, not just the S3 file).
onArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
// Just the run form and the runs, without the source: what a data-upload
// click asks for. `onShowScript` switches to the script itself.
inputsOnly?: boolean
onShowScript?: () => void
// `onRun` previews a draft's own content, so a draft can run too.
runsDrafts?: boolean
}
let {
@@ -57,8 +63,12 @@
runsPendingJobId,
onRunCompleted,
initialArgs,
onArgsChange
onArgsChange,
inputsOnly = false,
onShowScript,
runsDrafts = false
}: Props = $props()
let runBlockedByDraft = $derived(isDraft && !runsDrafts)
// Seed once from the persisted args (see initialArgs). Cloned so the run
// form mutates its own copy, not the page's stored snapshot.
@@ -139,7 +149,7 @@
</script>
<div class="flex flex-col h-full">
{#if isDraft}
{#if runBlockedByDraft}
<div
class="shrink-0 flex items-center gap-2 px-3 py-1.5 text-2xs bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-900/60 text-amber-700 dark:text-amber-400"
>
@@ -168,13 +178,24 @@
{/if}
</span>
<div class="flex items-center gap-1.5">
{#if inputsOnly && onShowScript}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Code2 }}
onclick={onShowScript}
title="Show the script instead of its inputs"
>
Open script
</Button>
{/if}
{#if hasCascade}
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{ icon: running ? Loader2 : Zap }}
onclick={() => run(true)}
disabled={isDraft || running || !runValid}
disabled={runBlockedByDraft || running || !runValid}
title={`Run this script with these inputs, then run its ${downstreamCount} downstream pipeline script${downstreamCount === 1 ? '' : 's'} in order`}
>
Run + downstream
@@ -185,8 +206,8 @@
unifiedSize="sm"
startIcon={{ icon: running ? Loader2 : Play }}
onclick={() => run(false)}
disabled={isDraft || running || !runValid || !onRun}
title={isDraft
disabled={runBlockedByDraft || running || !runValid || !onRun}
title={runBlockedByDraft
? 'Deploy this draft to run it for real'
: hasCascade
? 'Run just this step — downstream scripts are not triggered'
@@ -210,9 +231,11 @@
{/key}
{/if}
</div>
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
<HighlightCode code={script.content ?? ''} language={script.language} />
</div>
{#if !inputsOnly}
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
<HighlightCode code={script.content ?? ''} language={script.language} />
</div>
{/if}
</div>
</Pane>
<Pane size={45} minSize={20}>
@@ -0,0 +1,87 @@
import { untrack } from 'svelte'
import type { AssetGraphResponse } from './types'
type Staged = { args: Record<string, any>; valid: boolean }
// An S3Object-shaped value (the file picker writes `{ s3: '<path>' }`).
function isS3Object(v: any): boolean {
return !!v && typeof v === 'object' && !Array.isArray(v) && 's3' in v
}
// Whether a staged value carries actual data. Covers the two shapes a
// data-upload entry takes: an S3Object (file picker → `{ s3: '<path>' }`) and
// a plain required input (e.g. a JSON array pasted into the run form).
function hasMeaningfulValue(v: any): boolean {
if (v == null) return false
if (typeof v === 'string') return v.length > 0
if (Array.isArray(v)) return v.length > 0
if (typeof v === 'object')
return isS3Object(v) ? typeof v.s3 === 'string' && v.s3.length > 0 : Object.keys(v).length > 0
return true // numbers / booleans count as provided
}
/**
* Staged run-form input for the graph's data-upload entry scripts, keyed by path.
* Lifted out of the (transient, per-selection) run form so the uploaded/entered
* data persists across selection changes: it drives each entry node's green
* "ready" state and seeds a run with that input. `valid` is the run form's
* full-schema validity (all required fields satisfied).
*/
export function usePipelineDataUploads(getGraph: () => AssetGraphResponse) {
let staged = $state<Record<string, Staged>>({})
// Scripts that are data-upload entry points (a `data_upload` trigger in the
// displayed graph). They can't auto-run — they need their data first.
const entryPaths = $derived(
new Set(
getGraph()
.triggers.filter((t) => t.trigger_kind === 'data_upload' && t.runnable_kind === 'script')
.map((t) => t.runnable_path)
)
)
// Ready once the user actually provided its data, not just opened the form. Two
// guards: the form's own full-schema `valid` (every required field — including
// non-file ones — is satisfied), AND that any declared S3Object file field
// actually carries a file (the picker can leave an empty `{ s3: '' }` on a
// non-required file field, which `valid` alone wouldn't catch).
function isReady(path: string): boolean {
const s = staged[path]
if (!s || !s.valid) return false
const values = Object.values(s.args)
const s3s = values.filter(isS3Object)
if (s3s.length > 0) return s3s.every(hasMeaningfulValue)
return values.some(hasMeaningfulValue)
}
const readyPaths = $derived(new Set([...entryPaths].filter((p) => isReady(p))))
return {
get entryPaths(): ReadonlySet<string> {
return entryPaths
},
/** Entry scripts with their data provided — drives the green node treatment. */
get readyPaths(): ReadonlySet<string> {
return readyPaths
},
isReady,
argsFor: (path: string): Record<string, any> | undefined => staged[path]?.args,
// Persist the run form's args + validity, but only for data-upload entries —
// other run forms (partitioned producers) run their own way and must not be
// mistaken for a staged upload. Idempotent: the run form re-emits on every
// keystroke/validation pass, so bail when the value is unchanged — otherwise
// each emit would reassign `staged`, giving `readyPaths` a fresh Set identity
// that re-syncs the canvas and re-fires the form's emit effect
// (effect_update_depth_exceeded).
// Called from the run forms' own effects, so its reads are untracked: a form
// must not re-run because the store it just wrote to changed.
stage: (path: string, args: Record<string, any>, valid: boolean) =>
untrack(() => {
if (!entryPaths.has(path)) return
const prev = staged[path]
if (prev && prev.valid === valid && JSON.stringify(prev.args) === JSON.stringify(args))
return
staged = { ...staged, [path]: { args, valid } }
})
}
}
@@ -4,6 +4,7 @@
import { Loader2, Save, Workflow } from 'lucide-svelte'
import PipelineGraphEditor from '$lib/components/assets/AssetGraph/PipelineGraphEditor.svelte'
import PipelineTriggerEditors from '$lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte'
import { usePipelineDataUploads } from '$lib/components/assets/AssetGraph/pipelineDataUploads.svelte'
import { resolveGraph } from '$lib/components/assets/AssetGraph/resolveGraph'
import { useActiveRunnableIds } from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import type {
@@ -142,6 +143,7 @@
})
function handleCanvasSelect(s: AssetGraphSelection | undefined) {
uploadInputsPath = undefined
if (s && s.kind === 'runnable' && s.runnable_kind === 'script' && pe.drafts.has(s.path)) {
pe.activeDraftPath = s.path
pe.selection = undefined
@@ -202,7 +204,11 @@
// Data upload has no trigger row — open the target in the details pane and pulse
// the signal so its auto-generated run form focuses the S3 input.
// A data-upload click opens the node on its inputs alone; any other selection
// (or "Open script") goes back to the editor.
let uploadInputsPath = $state<string | undefined>(undefined)
function openDataUploadRun(scriptPath: string) {
uploadInputsPath = scriptPath
if (pe.drafts.has(scriptPath)) {
pe.activeDraftPath = scriptPath
pe.selection = undefined
@@ -244,12 +250,19 @@
}
})
const dataUploads = usePipelineDataUploads(() => resolvedGraph)
function runProducer(producer: { kind: 'script' | 'flow'; path: string; cascade?: boolean }) {
// Pipeline nodes are scripts; a flow producer can't be preview/by-path run.
if (producer.kind !== 'script') return Promise.resolve(undefined)
return runNode(producer.path, {}, producer.cascade ?? false)
// A data-upload entry runs on the file staged in its run form.
return runNode(producer.path, dataUploads.argsFor(producer.path) ?? {}, producer.cascade ?? false)
}
// Tell the open node's runs list a job started, so it shows it right away.
let runsRefreshKey = $state(0)
let runsPendingJobId = $state<string | undefined>(undefined)
async function runNode(
nodePath: string,
args: Record<string, any> = {},
@@ -283,6 +296,8 @@
}
activeRunnable = { kind: 'script', path: nodePath }
activeRunnableJobId = jobId
runsPendingJobId = jobId
runsRefreshKey++
return jobId
} catch (e: any) {
sendUserToast(`Run failed: ${e?.body ?? e?.message ?? e}`, true)
@@ -483,12 +498,20 @@
onOpenWebhook={openWebhookDrawer}
onOpenDataUpload={openDataUploadRun}
focusUploadSignal={focusDataUploadSignal}
readyDataUploadPaths={dataUploads.readyPaths}
runFormInitialArgs={pe.openScriptPath ? dataUploads.argsFor(pe.openScriptPath) : undefined}
onRunFormArgsChange={dataUploads.stage}
{activeRunnable}
{runsRefreshKey}
{runsPendingJobId}
activeRunnableIds={activeRunnables.ids}
runStates={activeRunnables.states}
eventLogEvents={activeRunnables.events}
onRunProducer={runProducer}
onRunByPath={(path, args) => runNode(path, args)}
inputsOnly={uploadInputsPath !== undefined && uploadInputsPath === pe.openScriptPath}
onRunInputs={(path, args) => runNode(path, args)}
onShowScript={() => (uploadInputsPath = undefined)}
canRunByPath
onTestStateChange={(running) => {
const openPath = pe.openScriptPath
@@ -45,6 +45,7 @@
} from '$lib/components/assets/AssetGraph/resolveGraph'
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
import { hideDbtRunnables } from '$lib/components/assets/AssetGraph/hideDbtRunnables'
import { usePipelineDataUploads } from '$lib/components/assets/AssetGraph/pipelineDataUploads.svelte'
import { buildSchemaContractContext } from '$lib/components/assets/AssetGraph/schemaContracts'
import {
computeDownstreamClosure,
@@ -995,6 +996,7 @@
// template can gate them per-mode with simple ternaries — the canvas
// hides each affordance when its callback is undefined.
function handleCanvasSelect(s: AssetGraphSelection | undefined) {
uploadInputsPath = undefined
// Clicking a node while the pane is explicitly hidden is a request
// to see that node — unhide. Background clicks (s == undefined)
// keep the hidden state.
@@ -1904,7 +1906,7 @@
// data_upload) receives its input from the live event or request at dispatch
// time — a message, an HTTP body, an uploaded file — which doesn't exist
// during a manual run, so there's nothing to default and those scripts run
// empty (data_upload being fed its staged file instead, see dataUploadArgs).
// empty (data_upload being fed its staged file instead, see dataUploads).
// Resolved on demand right before a run (schedule args aren't in the graph
// response); fail-safe — a schedule we can't fetch just contributes nothing.
let scheduleArgsByPath: Record<string, Record<string, any>> = {}
@@ -1950,7 +1952,7 @@
// data-upload entry's staged file override. runWholePipeline already
// refused to start unless every data-upload entry is staged, so this is
// always the intended input.
const staged = { ...(scheduleArgsByPath[path] ?? {}), ...(dataUploadArgs[path]?.args ?? {}) }
const staged = { ...(scheduleArgsByPath[path] ?? {}), ...(dataUploads.argsFor(path) ?? {}) }
const draft = mode === 'edit' || includeDrafts ? pe.drafts.get(path) : undefined
if (draft) {
if (!draft.script.content || !draft.script.language) {
@@ -2266,70 +2268,8 @@
}
}
// Staged run-form input for data-upload entry scripts, keyed by path. Lifted
// out of the (transient, per-selection) run form so the uploaded/entered data
// persists across selection changes — it drives each entry node's green
// "ready" state and seeds the whole-pipeline run with that input. `valid` is
// the run form's full-schema validity (all required fields satisfied).
let dataUploadArgs = $state<Record<string, { args: Record<string, any>; valid: boolean }>>({})
// An S3Object-shaped value (the file picker writes `{ s3: '<path>' }`).
function isS3Object(v: any): boolean {
return !!v && typeof v === 'object' && !Array.isArray(v) && 's3' in v
}
// Whether a staged value carries actual data. Covers the two shapes a
// data-upload entry takes: an S3Object (file picker → `{ s3: '<path>' }`) and
// a plain required input (e.g. a JSON array pasted into the run form).
function hasMeaningfulValue(v: any): boolean {
if (v == null) return false
if (typeof v === 'string') return v.length > 0
if (Array.isArray(v)) return v.length > 0
if (typeof v === 'object')
return isS3Object(v) ? typeof v.s3 === 'string' && v.s3.length > 0 : Object.keys(v).length > 0
return true // numbers / booleans count as provided
}
// A data-upload entry is ready once the user actually provided its data, not
// just opened the form. Two guards: the form's own full-schema `valid` (every
// required field — including non-file ones — is satisfied), AND that any
// declared S3Object file field actually carries a file (the picker can leave
// an empty `{ s3: '' }` on a non-required file field, which `valid` alone
// wouldn't catch).
function dataUploadReady(path: string): boolean {
const staged = dataUploadArgs[path]
if (!staged || !staged.valid) return false
const values = Object.values(staged.args)
const s3s = values.filter(isS3Object)
if (s3s.length > 0) return s3s.every(hasMeaningfulValue)
return values.some(hasMeaningfulValue)
}
// Persist the run form's args + validity, but only for data-upload entries —
// other run forms (partitioned producers) run their own way and must not be
// mistaken for a staged upload. Idempotent: the run form re-emits on every
// keystroke/validation pass, so bail when the value is unchanged — otherwise
// each emit would reassign `dataUploadArgs`, giving `readyDataUploadPaths` a
// fresh Set identity that re-syncs the canvas and re-fires the form's emit
// effect (effect_update_depth_exceeded).
function stageRunFormArgs(path: string, args: Record<string, any>, valid: boolean) {
if (!dataUploadEntryPaths.has(path)) return
const prev = dataUploadArgs[path]
if (prev && prev.valid === valid && JSON.stringify(prev.args) === JSON.stringify(args)) return
dataUploadArgs = { ...dataUploadArgs, [path]: { args, valid } }
}
// Pipeline scripts that are data-upload entry points (a `data_upload` trigger
// in the displayed graph). They can't auto-run — they need an uploaded file
// before the pipeline can go (see runWholePipeline's gate + the node's green
// state).
let dataUploadEntryPaths = $derived(
new Set(
displayGraph.triggers
.filter((t) => t.trigger_kind === 'data_upload' && t.runnable_kind === 'script')
.map((t) => t.runnable_path)
)
)
// Of those, the ones with a staged file — drives the green node treatment.
let readyDataUploadPaths = $derived(
new Set([...dataUploadEntryPaths].filter((p) => dataUploadReady(p)))
)
// Data-upload entries' staged input; see usePipelineDataUploads.
const dataUploads = usePipelineDataUploads(() => displayGraph)
// Every pipeline-member script, for the always-visible header "Run pipeline"
// control. Per-node runs are hover/select-gated on the canvas; this
@@ -2352,7 +2292,7 @@
// point the user at the first unready node instead of launching a doomed
// run.
const unready = allPipelineScripts.filter(
(p) => dataUploadEntryPaths.has(p) && !dataUploadReady(p)
(p) => dataUploads.entryPaths.has(p) && !dataUploads.isReady(p)
)
if (unready.length > 0) {
sendUserToast(
@@ -2559,7 +2499,45 @@
// auto-generated run form (the script declares an `S3Object` input, so the
// form renders the S3 picker) lets the user upload a file and run the
// pipeline. Drafts open in-place too so the user can fill the body first.
// A data-upload click opens the node on its inputs alone; any other selection
// (or "Open script") goes back to the script.
let uploadInputsPath = $state<string | undefined>(undefined)
// Runs the open node with its form inputs: a draft previews its own content, a
// deployed script runs for real (in view mode, cascading like any legit run).
async function runUploadInputs(
path: string,
args: Record<string, any>
): Promise<string | undefined> {
const draft = mode === 'edit' ? pe.drafts.get(path) : undefined
if (!draft) return mode === 'edit' ? runDeployedWithArgs(path, args) : runByPathLegit(path, args)
if (!$workspaceStore || !draft.script.content || !draft.script.language) return undefined
activeRunnables.arm(`script:${path}`)
try {
const jobId = await JobService.runScriptPreview({
workspace: $workspaceStore,
requestBody: {
content: draft.script.content,
language: draft.script.language,
path,
args: { ...args, _wmill_skip_asset_dispatch: true }
}
})
runsPendingJobId = jobId
runsRefreshKey++
activeRunnable = { kind: 'script', path }
activeRunnableJobId = jobId
return jobId
} catch (e: any) {
sendUserToast(`Run failed: ${e?.body ?? e?.message ?? e}`, true)
return undefined
}
}
// Same as a canvas run of a deployed node in edit mode: this step only.
function runDeployedWithArgs(path: string, args: Record<string, any>) {
return runByPathLegit(path, { ...args, _wmill_skip_asset_dispatch: true })
}
function openDataUploadRun(scriptPath: string) {
uploadInputsPath = scriptPath
// Opening the run form is meaningless while the pane is hidden —
// unhide first, same as a plain node click.
panelHidden = false
@@ -3019,9 +2997,12 @@
onDeleteTrigger={mode === 'edit' ? deleteAttachedTrigger : undefined}
onOpenWebhook={openWebhookDrawer}
onOpenDataUpload={openDataUploadRun}
{readyDataUploadPaths}
runFormInitialArgs={openScriptPath ? dataUploadArgs[openScriptPath]?.args : undefined}
onRunFormArgsChange={stageRunFormArgs}
readyDataUploadPaths={dataUploads.readyPaths}
runFormInitialArgs={openScriptPath ? dataUploads.argsFor(openScriptPath) : undefined}
onRunFormArgsChange={dataUploads.stage}
inputsOnly={uploadInputsPath !== undefined && uploadInputsPath === openScriptPath}
onRunInputs={runUploadInputs}
onShowScript={() => (uploadInputsPath = undefined)}
onSelect={handleCanvasSelect}
onAddScriptForAsset={mode === 'edit' ? handleAddScriptForAsset : undefined}
onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined}