fix: lineage follows the editor that inferred it; upload runs preview unsaved edits of deployed nodes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 15:52:03 +02:00
co-authored by Claude Opus 5.5
parent 66e4f0072e
commit d4a93d1e4f
4 changed files with 44 additions and 30 deletions
@@ -304,12 +304,13 @@
// its editor. `onRunInputs` runs it with them, drafts included, and
// `onShowScript` leaves this view for the script.
inputsOnly?: boolean
/** `content` is the pane's live buffer, newer than the stored draft when it was
* edited before switching to the inputs view. */
/** `live` is the pane's buffer when it differs from what is stored: always
* for a draft, and for a deployed script edited before switching to the
* inputs view. A run with it previews that body. */
onRunInputs?: (
path: string,
args: Record<string, any>,
content?: string
live?: { content: string; language: ScriptLang }
) => Promise<string | undefined>
onShowScript?: () => void
}
@@ -525,15 +526,12 @@
// 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.
// The script whose editor last bound `liveBodyAssets` (undefined there means it
// inferred nothing). They stay bound after that editor unmounts, so a script
// that never had one (the inputs-only view) must not report or persist them as
// its lineage. Set by an effect declared after the save-on-switch one, whose
// cleanup still needs the previous owner when the selection moves.
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
@@ -651,7 +649,7 @@
return () => {
// 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 inferred = liveAssetsOwner === captured.path
const writes = !inferred
? (writesAtRegister ?? [])
: (liveBodyAssets ?? [])
@@ -712,12 +710,23 @@
}
})
$effect(() => {
if (script && !readOnly && !inputsOnly) liveAssetsOwner = script.path
})
let saving = $state(false)
// What this pane last deployed, so the persist-back cleanup can tell "the
// buffer equals the new deployed head" from real unsaved edits (plain
// variable: only read inside the untracked cleanup).
let deployedFromPane: { path: string; content: string } | undefined = undefined
let isDraft = $derived(draftScript != undefined)
function liveBody(): { content: string; language: ScriptLang } | undefined {
if (!script) return undefined
const live = { content: script.content ?? '', language: script.language as ScriptLang }
if (isDraft) return live
const orig = scriptRes.current
return orig?.path === script.path && (orig.content ?? '') !== live.content ? live : 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.
@@ -1396,7 +1405,7 @@
runsDrafts
inputsOnly
{onShowScript}
onRun={onRunInputs && ((path, args) => onRunInputs(path, args, script?.content))}
onRun={onRunInputs && ((path, args) => onRunInputs(path, args, liveBody()))}
script={inputsScript ?? script}
{runsRefreshKey}
{runsPendingJobId}
@@ -221,7 +221,7 @@
onRunInputs?: (
path: string,
args: Record<string, any>,
content?: string
live?: { content: string; language: ScriptLang }
) => Promise<string | undefined>
onShowScript?: () => void
// Data-upload entry scripts whose staged upload is ready (green node).
@@ -12,7 +12,7 @@
AssetGraphSelection,
NativeTriggerKind
} from '$lib/components/assets/AssetGraph/types'
import { AssetService, JobService } from '$lib/gen'
import { AssetService, JobService, type ScriptLang } from '$lib/gen'
import { DATA_ASSET_KINDS } from '$lib/components/assets/AssetGraph/cascadeRun'
import { usePipelineAssetPrefetch } from '$lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte'
import { sendUserToast } from '$lib/utils'
@@ -263,25 +263,27 @@
let runsRefreshKey = $state(0)
let runsPendingJobId = $state<string | undefined>(undefined)
// `content` overrides the stored draft body with a newer one (an open editor's).
// `live` is an open editor's body, newer than what is stored: it is previewed,
// a deployed node included.
async function runNode(
nodePath: string,
args: Record<string, any> = {},
cascade = false,
content?: string
live?: { content: string; language: ScriptLang }
): Promise<string | undefined> {
const draft = pe.drafts.get(nodePath)
const body = live ?? (draft && { content: draft.script.content, language: draft.script.language })
activeRunnables.arm(`script:${nodePath}`)
try {
let jobId: string
if (draft) {
// Preview-run the draft content; a preview never dispatches downstream.
if (body) {
// Preview-run the unsaved body; a preview never dispatches downstream.
jobId = await JobService.runScriptPreview({
workspace: workspaceId,
requestBody: {
path: nodePath,
content: content ?? draft.script.content,
language: draft.script.language,
content: body.content,
language: body.language,
args
}
})
@@ -513,7 +515,7 @@
onRunByPath={(path, args) => runNode(path, args)}
inputsOnly={uploadInputsPath !== undefined &&
(pe.openScriptPath === undefined || uploadInputsPath === pe.openScriptPath)}
onRunInputs={(path, args, content) => runNode(path, args, false, content)}
onRunInputs={(path, args, live) => runNode(path, args, false, live)}
onShowScript={() => (uploadInputsPath = undefined)}
canRunByPath
onTestStateChange={(running) => {
@@ -2504,24 +2504,27 @@
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.
// `live` is the pane's buffer when it differs from what is stored (a draft, or a
// deployed node edited before switching to the inputs view): it is previewed.
async function runUploadInputs(
path: string,
args: Record<string, any>,
content?: string
live?: { content: string; language: ScriptLang }
): Promise<string | undefined> {
const draft = mode === 'edit' ? pe.drafts.get(path) : undefined
if (!draft) return mode === 'edit' ? runDeployedWithArgs(path, args) : runByPathLegit(path, args)
const body = content ?? draft.script.content
if (!$workspaceStore || !body || !draft.script.language) return undefined
const body =
mode === 'edit'
? (live ?? (draft && { content: draft.script.content, language: draft.script.language }))
: undefined
if (!body) return mode === 'edit' ? runDeployedWithArgs(path, args) : runByPathLegit(path, args)
if (!$workspaceStore || !body.content || !body.language) return undefined
activeRunnables.arm(`script:${path}`)
try {
const jobId = await JobService.runScriptPreview({
workspace: $workspaceStore,
requestBody: {
content: body,
language: draft.script.language,
content: body.content,
language: body.language,
path,
args: { ...args, _wmill_skip_asset_dispatch: true }
}