fix: inputs-only view infers draft inputs, keeps stored lineage, runs the live body

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 15:36:04 +02:00
co-authored by Claude Opus 5.5
parent 30c9f0eb79
commit 66e4f0072e
4 changed files with 72 additions and 19 deletions
@@ -143,7 +143,8 @@
writes: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Body-inferred reads, so the parent's draft keeps its input
// lineage while inactive (no live inference runs for it).
reads: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Undefined when nothing was inferred: keep what the draft has.
reads?: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Full edited script — set when the source is a persisted
// script (needed to seed a brand-new draft entry).
script?: Script
@@ -303,7 +304,13 @@
// 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>
/** `content` is the pane's live buffer, newer than the stored draft when it was
* edited before switching to the inputs view. */
onRunInputs?: (
path: string,
args: Record<string, any>,
content?: string
) => Promise<string | undefined>
onShowScript?: () => void
}
let {
@@ -518,6 +525,15 @@
// alongside `liveBodyAssets` and forwarded so the live graph can show
// inferred column lineage on the edited script before it deploys.
let liveColumnLineage = $state<ColumnLineage[] | undefined>(undefined)
// The script `liveBodyAssets` was inferred for: they stay bound after its editor
// unmounts, so a script with no editor of its own (the inputs-only view) must
// not report or persist them as its lineage. Not reset on a switch: the
// previous script's save-on-switch cleanup still reads its own.
let liveAssetsOwner: string | undefined = undefined
$effect.pre(() => {
const assets = liveBodyAssets
untrack(() => (liveAssetsOwner = assets === undefined ? undefined : script?.path))
})
// Bumped when the runs panel reports a watched job has reached a
// terminal state. Drives S3FilePreview's refreshKey so the preview
@@ -633,18 +649,25 @@
b: Array<{ kind: string; path: string }>
) => a.length === b.length && a.every((x, i) => x.kind === b[i]?.kind && x.path === b[i]?.path)
return () => {
const writes = (liveBodyAssets ?? [])
// No editor inferred this script (the inputs-only view): keep its stored
// lineage rather than persisting it as empty.
const inferred = liveBodyAssets !== undefined && liveAssetsOwner === captured.path
const writes = !inferred
? (writesAtRegister ?? [])
: (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'w' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
const reads = (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'r' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
const reads = !inferred
? readsAtRegister
: (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'r' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
// Draft runs: emit only when content or lineage changed in THIS clone.
// An unconditional emit ping-pongs forever when the entry is rewritten
// externally while the pane stays mounted (rename rekey, AI edit):
@@ -654,6 +677,7 @@
(captured.content ?? '') === contentAtRegister &&
refsEq(writesAtRegister ?? [], writes) &&
readsAtRegister != undefined &&
reads != undefined &&
refsEq(readsAtRegister, reads)
)
return
@@ -694,6 +718,25 @@
// variable: only read inside the untracked cleanup).
let deployedFromPane: { path: string; content: string } | undefined = undefined
let isDraft = $derived(draftScript != undefined)
// A draft's stored schema is empty until an editor infers it, and the inputs
// view mounts none: infer from the body here so its fields (the file picker)
// show up.
const inputsSchema = resource(
[() => (inputsOnly && isDraft ? script?.content : undefined), () => script?.language],
async ([content, language]) => {
if (content === undefined || !language) return undefined
const schema = emptySchema()
try {
await inferArgs(language, content, schema as Schema)
} catch {
return undefined
}
return schema
}
)
let inputsScript = $derived(
script && inputsSchema.current ? { ...script, schema: inputsSchema.current } : undefined
)
// Single trash-bin button opens one modal that exposes both Archive
// (always available) and Delete permanently (admin-only). Archive is
@@ -800,7 +843,7 @@
onAnnotationsChange?.(script?.path, liveAnnotations)
})
$effect(() => {
if (readOnly) return
if (readOnly || (inputsOnly && liveAssetsOwner !== script?.path)) return
onAssetsChange?.(script?.path, liveBodyAssets ?? [], liveColumnLineage)
})
$effect(() => {
@@ -1348,13 +1391,13 @@
{:else if script && inputsOnly}
{#key script.path}
<PipelineScriptView
{script}
{isDraft}
canRun
runsDrafts
inputsOnly
{onShowScript}
onRun={onRunInputs}
onRun={onRunInputs && ((path, args) => onRunInputs(path, args, script?.content))}
script={inputsScript ?? script}
{runsRefreshKey}
{runsPendingJobId}
initialArgs={runFormInitialArgs}
@@ -218,7 +218,11 @@
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>
onRunInputs?: (
path: string,
args: Record<string, any>,
content?: string
) => Promise<string | undefined>
onShowScript?: () => void
// Data-upload entry scripts whose staged upload is ready (green node).
readyDataUploadPaths?: Set<string>
@@ -263,10 +263,12 @@
let runsRefreshKey = $state(0)
let runsPendingJobId = $state<string | undefined>(undefined)
// `content` overrides the stored draft body with a newer one (an open editor's).
async function runNode(
nodePath: string,
args: Record<string, any> = {},
cascade = false
cascade = false,
content?: string
): Promise<string | undefined> {
const draft = pe.drafts.get(nodePath)
activeRunnables.arm(`script:${nodePath}`)
@@ -278,7 +280,7 @@
workspace: workspaceId,
requestBody: {
path: nodePath,
content: draft.script.content,
content: content ?? draft.script.content,
language: draft.script.language,
args
}
@@ -511,7 +513,7 @@
onRunByPath={(path, args) => runNode(path, args)}
inputsOnly={uploadInputsPath !== undefined &&
(pe.openScriptPath === undefined || uploadInputsPath === pe.openScriptPath)}
onRunInputs={(path, args) => runNode(path, args)}
onRunInputs={(path, args, content) => runNode(path, args, false, content)}
onShowScript={() => (uploadInputsPath = undefined)}
canRunByPath
onTestStateChange={(running) => {
@@ -2504,19 +2504,23 @@
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).
// `content` is the pane's live buffer, newer than the stored draft when it was
// edited before switching to the inputs view.
async function runUploadInputs(
path: string,
args: Record<string, any>
args: Record<string, any>,
content?: string
): 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
const body = content ?? draft.script.content
if (!$workspaceStore || !body || !draft.script.language) return undefined
activeRunnables.arm(`script:${path}`)
try {
const jobId = await JobService.runScriptPreview({
workspace: $workspaceStore,
requestBody: {
content: draft.script.content,
content: body,
language: draft.script.language,
path,
args: { ...args, _wmill_skip_asset_dispatch: true }