feat(pipeline): cascade option on graph Run + match button heights

This commit is contained in:
Ruben Fiszel
2026-05-13 16:58:45 +00:00
parent 3ed9c27772
commit 322bee625c
5 changed files with 118 additions and 18 deletions
@@ -679,7 +679,15 @@
args = nargs
}
export async function runTest() {
export async function runTest(opts?: { cascade?: boolean }) {
// When the caller forces a cascade choice (e.g. the canvas runnable
// menu's "Run + trigger N downstream"), also flip the persistent
// `cascadeDownstream` state so the split button's label/icon reflect
// the active mode after the run kicks off — keeps "what mode am I in"
// visible instead of having a one-off run silently disagree with the
// UI. The caller is welcome to bump the signal repeatedly; we just
// keep the latest choice as the active mode.
if (opts?.cascade !== undefined) cascadeDownstream = opts.cascade
// Discard any previous recording when running a normal test
if (!scriptRecording.active) {
lastRecording = undefined
@@ -1717,9 +1725,13 @@
contentClasses="p-0"
>
{#snippet trigger()}
<!-- min-h-7 matches Button's unifiedSize="sm" (28px) so
the caret stretches to the same height as the
primary Test button. self-stretch falls back if the
parent's items-stretch is overridden upstream. -->
<button
type="button"
class="px-1.5 flex items-center justify-center bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/60 text-primary border-l border-blue-300 dark:border-blue-700 transition-colors"
class="self-stretch min-h-7 px-1.5 flex items-center justify-center bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/60 text-primary border-l border-blue-300 dark:border-blue-700 transition-colors"
title="Run options"
aria-label="Run options"
>
@@ -74,6 +74,11 @@
kind: 'script' | 'flow'
path: string
unsaved?: boolean
// Whether to let the asset-trigger cascade fan out to downstream
// subscribers after this run succeeds. Undefined = use the caller's
// default (no skip arg injected); true = explicit cascade; false =
// inject `_wmill_skip_asset_dispatch: true` to suppress.
cascade?: boolean
}) => Promise<string | undefined>
// Page-supplied dispatch for the per-runnable-node action menu.
// Drafts are discarded immediately by the page; persisted scripts
@@ -172,6 +177,34 @@
producersByAsset.set(key, list)
}
// Downstream subscriber count per producer script. Counts distinct
// script subscribers (excluding self and flow subs, mirroring the V1
// dispatch policy) across all assets the script writes. Drives the
// "Run + trigger N downstream" menu item on RunnableNode and lets the
// pipeline page short-circuit cascade UX when there's nothing to fan
// out to.
const subscribersByAsset = new Map<string, Set<string>>()
for (const t of g.triggers ?? []) {
if (t.trigger_kind !== 'asset' || t.runnable_kind !== 'script') continue
const key = `${t.asset_kind}:${t.asset_path}`
const set = subscribersByAsset.get(key) ?? new Set<string>()
set.add(t.runnable_path)
subscribersByAsset.set(key, set)
}
const downstreamSetsByScript = new Map<string, Set<string>>()
for (const e of g.edges ?? []) {
if (e.runnable_kind !== 'script') continue
const access = e.access_type ?? 'r'
if (access !== 'w' && access !== 'rw') continue
const subs = subscribersByAsset.get(`${e.asset_kind}:${e.asset_path}`)
if (!subs) continue
const merged = downstreamSetsByScript.get(e.runnable_path) ?? new Set<string>()
for (const s of subs) if (s !== e.runnable_path) merged.add(s)
downstreamSetsByScript.set(e.runnable_path, merged)
}
const downstreamByScript = new Map<string, number>()
for (const [path, set] of downstreamSetsByScript) downstreamByScript.set(path, set.size)
for (const a of g.assets) {
const assetId = `asset:${a.kind}:${a.path}`
nodes.push({
@@ -206,13 +239,15 @@
// click → run with no extra UI clutter.
onRunSelf:
r.usage_kind === 'script' && onRunProducer
? () =>
? (opts?: { cascade?: boolean }) =>
onRunProducer({
kind: 'script',
path: r.path,
unsaved: r.unsaved ?? false
unsaved: r.unsaved ?? false,
cascade: opts?.cascade
})
: undefined,
downstreamCount: downstreamByScript.get(r.path) ?? 0,
onSelectSelf: () =>
onselect?.({
kind: 'runnable',
@@ -151,6 +151,11 @@
// cascade option when > 0. The page computes this from the graph
// edges + triggers + currently-open path.
downstreamSubscribers?: number
// Sister to `requestRunSignal`. When bumped, the bridge calls
// `ScriptEditor.runTest({ cascade: true })` — used by the canvas
// runnable menu's "Run + trigger N downstream" item when the chosen
// script happens to be the currently-open one.
requestRunCascadeSignal?: number
}
let {
selection,
@@ -176,19 +181,28 @@
pathPrefix = '',
onDraftPathChange,
requestRemoveSignal,
downstreamSubscribers = 0
downstreamSubscribers = 0,
requestRunCascadeSignal
}: Props = $props()
// Held ref to ScriptEditor so we can route a canvas-side Run dispatch
// through .runTest() — gives the test panel logs/result/cancel for
// runs initiated from the graph, not just the in-pane Test button.
let scriptEditorRef: { runTest: () => Promise<unknown> } | undefined = $state(undefined)
let scriptEditorRef: { runTest: (opts?: { cascade?: boolean }) => Promise<unknown> } | undefined =
$state(undefined)
$effect(() => {
// Track the counter; ignore the initial 0/undefined.
const sig = requestRunSignal
if (sig === undefined || sig === 0) return
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
void scriptEditorRef?.runTest({ cascade: true })
})
// True when the script that writes to the currently-selected asset is
// running right now. Drives the "Recomputing…" banner above the
@@ -8,7 +8,8 @@
Loader2,
Play,
Timer,
Trash2
Trash2,
Zap
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { preventDefault, stopPropagation } from 'svelte/legacy'
@@ -33,8 +34,16 @@
// unsaved → runScriptPreview with the locally-cached draft content).
// Wired only for script runnables — flows are ignored upstream. When
// undefined, the play button is hidden — matches the asset-node
// behaviour outside editor contexts.
onRunSelf?: () => Promise<string | undefined>
// behaviour outside editor contexts. `opts.cascade` lets the
// dispatcher decide whether to skip the asset-trigger cascade — the
// node's default-click sends `cascade: false` (just this step) to
// stay consistent with the editor's Test button.
onRunSelf?: (opts?: { cascade?: boolean }) => Promise<string | undefined>
// Number of script subscribers that listen on assets this script
// writes. When > 0, the hover-menu exposes a "Run + trigger N
// downstream" alternative; the round Play button stays a single-
// click default. Undefined / 0 hides the cascade menu item.
downstreamCount?: number
// Called before running so the details pane focuses this script —
// mirrors AssetNode.onSelectAsset, keeps the runs/output in view
// instead of dispatching into nowhere.
@@ -72,7 +81,7 @@
// already running (so the loader doesn't disappear under the cursor).
let showRun = $derived(canRun && (hover || selected || running))
async function runSelf(e: MouseEvent) {
async function runSelf(e: MouseEvent, cascade?: boolean) {
e.stopPropagation()
if (!$workspaceStore || running || !data.onRunSelf) return
// Focus this runnable so the details pane opens to its editor — same
@@ -80,7 +89,7 @@
if (!selected) data.onSelectSelf?.()
running = true
try {
await data.onRunSelf()
await data.onRunSelf(cascade != undefined ? { cascade } : undefined)
} catch (err: any) {
sendUserToast(`Failed to run: ${err.body ?? err.message}`, true)
} finally {
@@ -88,8 +97,23 @@
}
}
let menuItems: Item[] = $derived(
data.onRequestRemove
let menuItems: Item[] = $derived([
// Cascade option lives at the top of the menu — the round Run button
// is the no-cascade default, this surfaces the alternative when there
// are downstream subscribers to fan out to.
...(canRun && (data.downstreamCount ?? 0) > 0
? [
{
displayName: `Run + trigger ${data.downstreamCount} downstream`,
icon: Zap,
action: () => {
// Fake MouseEvent-shaped arg so runSelf can stopPropagation.
void runSelf({ stopPropagation: () => {} } as MouseEvent, true)
}
}
]
: []),
...(data.onRequestRemove
? [
{
displayName: data.unsaved ? 'Discard' : 'Delete…',
@@ -98,8 +122,8 @@
action: () => data.onRequestRemove?.()
}
]
: []
)
: [])
])
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -923,6 +923,11 @@
// into nowhere with only edge animation as feedback. Counter rather
// than boolean so back-to-back runs re-fire.
let requestRunSignal = $state(0)
// Sister counter: bumped instead of requestRunSignal when the canvas user
// picks "Run + trigger N downstream" for the currently-open script. Lets
// ScriptEditor.runTest run with cascade=true without permanently flipping
// its persistent cascade choice.
let requestRunCascadeSignal = $state(0)
// Counter bumped from the runnable-node action menu to ask the pane to
// open its archive/delete confirmation modal for the loaded script.
// Counter (vs boolean) so successive triggers re-fire even if the user
@@ -1281,6 +1286,13 @@
// immediately — its background poll only kicks in
// for already-listed in-flight jobs.
if (!$workspaceStore || producer.kind !== 'script') return undefined
// Cascade default: same as the Test button — `cascade`
// undefined / false skips the asset-trigger dispatch
// via `_wmill_skip_asset_dispatch`; explicit `true`
// lets the dispatch fire normally. The asset-node
// affordance still passes `undefined` (legacy callers),
// which we treat as "skip" for consistency.
const cascade = producer.cascade === true
// If the producer being run is the script currently
// edited in the pane, route through ScriptEditor's
// Test path — the test panel then shows logs/result
@@ -1292,9 +1304,11 @@
? selection.path
: undefined)
if (openPath === producer.path) {
requestRunSignal++
if (cascade) requestRunCascadeSignal++
else requestRunSignal++
return undefined
}
const skipArg = cascade ? {} : { _wmill_skip_asset_dispatch: true }
let jobId: string | undefined
if (producer.unsaved) {
const draft = drafts.get(producer.path)
@@ -1305,14 +1319,14 @@
content: draft.script.content,
language: draft.script.language,
path: producer.path,
args: {}
args: { ...skipArg }
}
})
} else {
jobId = await JobService.runScriptByPath({
workspace: $workspaceStore,
path: producer.path,
requestBody: {}
requestBody: { ...skipArg }
})
}
if (jobId) {
@@ -1373,6 +1387,7 @@
}}
{requestRemoveSignal}
{requestRunSignal}
{requestRunCascadeSignal}
draftScript={activeDraft?.script}
{pathPrefix}
onDraftPathChange={renameDraft}