From 9f0c0258b3c9accb71e1f684bb8f665deba7fdf9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 15 May 2026 16:23:49 +0000 Subject: [PATCH] feat: live run activity + status badges in pipeline asset graph - folder-scoped queue poll lights up the downstream asset-trigger cascade (not just the launched script); zero requests at rest, catch-up for fast hops, auto-disarm when idle - per-runnable node badge: last-run status + session run count - animate unsaved/live-parsed edges (was unconditionally suppressed) - background-pane click no longer clears selection - run-bridge guarded so node selection/save no longer triggers a test Co-Authored-By: Claude Opus 4.7 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 48 +++-- .../AssetGraph/AssetGraphDetailsPane.svelte | 39 +++- .../assets/AssetGraph/RunnableNode.svelte | 37 ++++ .../AssetGraph/activeRunnables.svelte.ts | 184 ++++++++++++++++++ .../(logged)/pipeline/[folder]/+page.svelte | 21 ++ 5 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/activeRunnables.svelte.ts diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 9cf36f4123..b8e808dadb 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -16,6 +16,7 @@ import AssetGraphEdge from './AssetGraphEdge.svelte' import { layoutAssetGraph } from './assetGraphLayout' import type { AssetGraphResponse, AssetGraphSelection } from './types' + import type { RunnableRunState } from './activeRunnables.svelte' import type { AssetKind } from '$lib/gen' import { NODE } from '$lib/components/graph/util' @@ -91,9 +92,19 @@ }) => void // Runnable currently executing — its incoming asset/trigger edges and // outgoing write edges get an animated stroke to convey "this is - // running right now". Cleared by the page when the run reaches a - // terminal state. Future: same hook for live pipeline status. + // running right now". Zero-latency hint for the script the user just + // launched (set/cleared by the page from the in-pane Test state). activeRunnable?: { kind: 'script' | 'flow'; path: string } | undefined + // `${kind}:${path}` ids of every pipeline runnable with an in-flight + // (or just-finished) job, from the folder-scoped queue poll. This is + // what lights up the *downstream cascade* (jobs the user didn't launch + // directly). Merged with `activeRunnable` so the launched script lights + // instantly while cascade hops light as the poll observes them. + activeRunnableIds?: ReadonlySet + // `${kind}:${path}` → last-run status + session run count, rendered as + // a small badge on each runnable node. Same poll source as + // `activeRunnableIds`; persists the last status while idle. + runStates?: ReadonlyMap } let { graph, @@ -106,7 +117,9 @@ defaultScheduleCron = '', onRunProducer, onRunnableMenuRemove, - activeRunnable + activeRunnable, + activeRunnableIds, + runStates }: Props = $props() const ADD_NODE_ID = '__add__' @@ -248,6 +261,7 @@ }) : undefined, downstreamCount: downstreamByScript.get(r.path) ?? 0, + runState: runStates?.get(`${r.usage_kind}:${r.path}`), onSelectSelf: () => onselect?.({ kind: 'runnable', @@ -415,11 +429,16 @@ }) }) - // Runnable graph-id, if a job is currently running for it. Used below - // to animate the edges into and out of that node — keeps the static - // graph quiet and reserves animation as an "is happening now" signal. - let activeRunnableId = $derived( - activeRunnable ? `${activeRunnable.kind}:${activeRunnable.path}` : undefined + // Graph-ids of every runnable that's executing right now: the polled + // in-flight/just-finished set (cascade) plus the zero-latency hint for + // the script the user just launched. Edges into/out of any of these get + // the animated stroke — animation stays reserved as an "is happening + // now" signal and the static graph stays quiet when nothing runs. + let activeRunnableIdSet = $derived( + new Set([ + ...(activeRunnableIds ?? []), + ...(activeRunnable ? [`${activeRunnable.kind}:${activeRunnable.path}`] : []) + ]) ) let flowEdges = $derived.by(() => model.edges @@ -427,8 +446,8 @@ .filter((e) => e.kind !== 'add-anchor') .map((e) => { const touchesActiveRunnable = - activeRunnableId !== undefined && - (e.source === activeRunnableId || e.target === activeRunnableId) + activeRunnableIdSet.size > 0 && + (activeRunnableIdSet.has(e.source) || activeRunnableIdSet.has(e.target)) let style: string let animated = false let markerColor: string | undefined = undefined @@ -476,10 +495,16 @@ // parsed, not persisted. Lineage edges from base graph data // don't carry `unsaved`, only those synthesized by the draft // overlay (e.g. the random output asset). + // + // `animated` is intentionally NOT cleared here: a live-parsed + // output edge still belongs to a script that can be run, and + // the run feedback (flow animation toward its output assets) + // is exactly what the user expects while it executes. The + // dashed+dimmed treatment already conveys "not persisted"; + // it composes fine with the active-run animation. if (e.unsaved) { strokeDasharray = '3 3' style = `${style} opacity: 0.7;` - animated = false if (label) label = `${label} (unsaved)` } if (strokeDasharray) { @@ -553,7 +578,6 @@ defaultEdgeOptions={{ type: 'asset' }} proOptions={{ hideAttribution: true }} onnodeclick={handleNodeClick} - onpaneclick={() => onselect?.(undefined)} --background-color={false} >
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index b84e34215c..0e63d05fb0 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -190,17 +190,50 @@ // runs initiated from the graph, not just the in-pane Test button. let scriptEditorRef: { runTest: (opts?: { cascade?: boolean }) => Promise } | undefined = $state(undefined) + // Run-bridge: fire `ScriptEditor.runTest()` exactly once per distinct + // signal-counter value the page sends (canvas Run targeting the open + // script), on the editor that actually corresponds to the run target. + // 1. A plain node selection or a save must never trigger a run — they + // bump no counter, so the last-handled guard keeps the effect inert + // even though selecting/saving reassigns `scriptEditorRef`. + // 2. The Run button selects the node first, so the ScriptEditor is + // usually still (re)mounting when the counter bumps. We must NOT + // run yet — and must NOT mark the signal handled — so the effect + // re-runs and fires once the editor settles. + // 3. `scriptRes` is stale-while-revalidate: right after selection the + // PREVIOUS script's ScriptEditor is still mounted. Firing then runs + // the wrong script. `editorReadyForTarget` gates on the mounted + // editor being settled on the run-target path before we fire. + let lastHandledRunSig = 0 + let lastHandledCascadeSig = 0 + let runTargetPath = $derived.by(() => + isDraft + ? draftScript?.path + : selection?.kind === 'runnable' && selection.runnable_kind === 'script' + ? selection.path + : undefined + ) + let editorReadyForTarget = $derived.by( + () => + !!scriptEditorRef && + runTargetPath !== undefined && + script?.path === runTargetPath && + (isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath)) + ) $effect(() => { - // Track the counter; ignore the initial 0/undefined. const sig = requestRunSignal - if (sig === undefined || sig === 0) return + if (sig === undefined || sig === 0 || sig === lastHandledRunSig) return + if (!editorReadyForTarget) return + lastHandledRunSig = sig void scriptEditorRef?.runTest() }) $effect(() => { // Cascade-explicit sister signal: same pattern, but forces the // runTest call to opt into the asset-trigger cascade for this run. const sig = requestRunCascadeSignal - if (sig === undefined || sig === 0) return + if (sig === undefined || sig === 0 || sig === lastHandledCascadeSig) return + if (!editorReadyForTarget) return + lastHandledCascadeSig = sig void scriptEditorRef?.runTest({ cascade: true }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte index b562464259..bd283edd64 100644 --- a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte @@ -1,6 +1,7 @@