Files
windmill/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts
T
Ruben FiszelandClaude Opus 4.8 248540ac4d feat: bounded-cascade selective execution for pipelines (UI + CLI) (#9695)
* feat: bounded-cascade selective execution for pipelines (UI + CLI)

Run a prefix of a pipeline cascade: from a schedule/manual root, fan
downstream but stop at chosen end node(s) — the path-between set over the
asset-graph lineage DAG. Exposed as a canvas 'Run downstream up to…' pick
mode and a 'wmill pipeline run <folder> --to' CLI command. No backend or
parser changes; reads the existing graph, tags, and triggers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: surface bounded-run on the run caret, trigger-node kebab, and Test button

Move 'Run downstream up to…' from the runnable kebab onto the play-button
caret popover (Edit mode, next to Run / Run + trigger N downstream); add it
to the trigger-node kebab so schedule/data_upload entrypoints expose it on
the View page; and to the ScriptEditor Test split caret for the open script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address CI review on bounded-cascade (cubic)

- Port CLI engine test from Deno to bun:test under cli/test/ (won't run under bun test otherwise).
- closure() now excludes the start node on a cycle back to it (descendants/ancestors contract); regression tests both engines.
- CLI 'pipeline run --to' rejects unresolved/ambiguous end tokens instead of silently running a different subset.
- Sort a copy in the runSelection order test so the launch-order assertions aren't invalidated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address standing review nits on bounded-cascade

Resolves the four recurring P1/P2 findings from the codex/pi/claude
reviews:

- UI gate (P1): the canvas/trigger-node "Run downstream up to…"
  affordance was gated on the subscriber-only downstream map, so a valid
  start whose only downstream is a pure reader had a non-empty bounded
  set but no menu entry. Gate on the read-aware lineage downstream
  (buildLineageDownstreamMap), matching the bounded engine.
- waitJob (CLI): a completed job without explicit success:true now
  counts as a failure, mirroring the frontend waitJobTerminal — the
  cascade only advances on a confirmed success.
- Comment fix (CLI): the unbounded `run` path uses the read-aware
  lineage DAG (pure readers included); dropped the false "parity with
  the canvas cascade" (subscriber-only) claim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: expose bounded-run caret for pure-reader-only starts (codex P1)

The canvas wiring from the prior commit passed `onStartBoundedRun` from
the read-aware lineage map, but the leaf components still hid the popover
that holds the "Run downstream up to…" action behind a subscriber-only
gate:

- RunnableNode rendered the Run-button caret only when
  `hasCascade = downstreamCount > 0` (subscriber-only). A valid start
  whose only downstream is a pure reader got `onStartBoundedRun` but no
  visible action. Now the caret opens when there's a cascade OR a
  bounded-run start (`hasCaret`), and the "Run + trigger N downstream"
  item is gated on `hasCascade` so it never reads "trigger 0".
- ScriptEditor's Test split button activated only when
  `downstreamSubscribers > 0`, falling through to a plain Test button
  (no caret) otherwise. Now it also activates when `onBoundedRun` is
  set, with the "Test + trigger N" item gated on the count.

For a manual root (no trigger-node kebab fallback) with a pure-reader
downstream this was the only UI entry point, so it was previously
unreachable. Verified in-browser: a manual-root script writing an asset
read-only downstream now exposes "Run downstream up to…" on the
ScriptEditor Test caret with the cascade item hidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: gate ScriptEditor bounded-run on read-aware downstream; fix CLI asset-end warning (codex P2)

- Details-pane (ScriptEditor) bounded-run entry was gated only on
  `validStartPaths`, broader than the canvas which also requires
  read-aware downstream (`hasLineageDownstream`). An isolated start could
  thus expose "Run downstream up to…" and enter pick mode with no
  selectable end. Now gated on `lineageDownstreamPaths` (script paths with
  a downstream in `buildLineageDownstreamMap`), matching the canvas.
- CLI dropped-end warning called `scriptPathOf(d)` unconditionally, which
  slices `script:`-length chars off an asset id too — `datatable:main/raw`
  printed as `le:main/raw`. Now prefix-checks like the JSON output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: correct --from error to exclude only row-backed event triggers (codex P2)

The bounded-start validation message listed `kafka/webhook/…` as event
triggers that can't start a bounded run, but webhook/data_upload are
rowless and read as manual roots (valid starts). Only the row-backed
native kinds (kafka/mqtt/nats/postgres/sqs/gcp/email — EVENT_TRIGGER_KINDS)
are excluded; the message now names those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: surface dropped ends in CLI JSON; disambiguate shared-trigger bounded start (codex P2)

- CLI `run --json` silenced the dropped-end warning, and the JSON payload
  echoed the originally-resolved `--to` list with no reachable/dropped
  split — a resolved-but-unreachable end looked like a clean plan that
  silently runs only the start. JSON now includes `reachableEnds` and
  `droppedEnds` (shared `idLabel` helper, asset-id safe).
- Trigger nodes dedupe per (kind, ref), so a schedule shared across
  scripts collapses to one node, but `recordSourceTrigger` kept only the
  first target path — the bounded-run action then rooted at an arbitrary
  script (or hid when only that first script lacked downstream). Now all
  target paths are tracked and the action is offered only when exactly one
  is a valid start with downstream; multi-eligible nodes suppress it
  rather than guess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: don't run hidden drafts in View-mode bounded cascade (codex P1)

launchCascadeScript unconditionally preferred drafts.get(path) over the
deployed script. In View mode with drafts hidden (displayGraph is
deployed-only), a bounded run started from a trigger-node kebab would
execute preview jobs from hidden local draft content instead of the
deployed scripts the user is looking at.

Gate draft execution on `mode === 'edit' || includeDrafts` — the exact
condition under which displayGraph includes drafts — so execution always
matches the displayed graph. No-op for scripts without a draft; the
edit-mode "Run + trigger N downstream" cascade is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:08:05 +02:00

204 lines
7.6 KiB
TypeScript

import type { AssetGraphResponse } from './types'
import { assetKey, buildAssetSubscribers, isWriteEdge } from './lib'
// Execution-DAG traversal over the resolved asset graph (drafts included).
//
// The execution edges are producer→subscriber: a script S1 *produces* an
// asset A when it has a write lineage edge (`edges`, access 'w'/'rw'), and a
// script S2 *subscribes* to A when it declares `// on <A>` (`triggers`,
// trigger_kind 'asset'). Flow subscribers and self-loops are excluded,
// mirroring the backend dispatch policy (asset_dispatch.rs).
//
// Used by both the node badge counts (AssetGraphCanvas) and the dev-run
// cascade orchestrator — keep it dependency-free and pure.
/** `producer script path` → set of subscriber script paths (one hop). */
export function buildDownstreamMap(g: AssetGraphResponse): Map<string, Set<string>> {
const subscribersByAsset = buildAssetSubscribers(g)
const downstream = new Map<string, Set<string>>()
for (const e of g.edges ?? []) {
if (!isWriteEdge(e)) continue
const subs = subscribersByAsset.get(assetKey(e))
if (!subs) continue
const merged = downstream.get(e.runnable_path) ?? new Set<string>()
for (const s of subs) if (s !== e.runnable_path) merged.add(s)
downstream.set(e.runnable_path, merged)
}
return downstream
}
export type DownstreamClosure = {
/** Every script in the transitive downstream of root, root excluded. */
nodes: string[]
/**
* In-closure dependency edges, deduped: `edges.get(p)` = subscribers of
* `p` that are inside the closure (root included as a producer).
*/
edges: Map<string, Set<string>>
/**
* In-closure upstream count per node (root counts). A node is ready to
* run once all its in-closure upstreams completed — running in that
* order is a topological execution of the closure.
*/
indegree: Map<string, number>
/**
* Nodes reachable from root but unschedulable because they sit on a
* dependency cycle (or depend on one). They are excluded from `nodes`/
* `edges`/`indegree`; surfaced so callers can warn instead of hanging.
*/
cyclic: string[]
}
/**
* Transitive downstream closure of `root`, ready for Kahn-style scheduling.
* Cycle-safe: nodes on (or fed only through) a cycle are reported in
* `cyclic` rather than deadlocking the schedule.
*/
export function computeDownstreamClosure(g: AssetGraphResponse, root: string): DownstreamClosure {
const oneHop = buildDownstreamMap(g)
// BFS to collect the reachable set (root excluded from `nodes`).
const reachable = new Set<string>()
const queue = [root]
while (queue.length > 0) {
const cur = queue.shift()!
for (const next of oneHop.get(cur) ?? []) {
if (next === root || reachable.has(next)) continue
reachable.add(next)
queue.push(next)
}
}
// In-closure edges + indegrees (root participates as a producer only).
const edges = new Map<string, Set<string>>()
const indegree = new Map<string, number>()
for (const n of reachable) indegree.set(n, 0)
for (const p of [root, ...reachable]) {
const subs = new Set<string>()
for (const s of oneHop.get(p) ?? []) {
if (!reachable.has(s)) continue
subs.add(s)
indegree.set(s, (indegree.get(s) ?? 0) + 1)
}
if (subs.size > 0) edges.set(p, subs)
}
// Kahn pass purely to detect what is schedulable; anything left with a
// positive indegree at the end is on/behind a cycle. Seeded with the
// root (the run that kicks the cascade off) — its completion is what
// unlocks the first wave.
const remaining = new Map(indegree)
const ready = [root]
const ordered: string[] = []
while (ready.length > 0) {
const n = ready.shift()!
if (n !== root) ordered.push(n)
for (const s of edges.get(n) ?? []) {
const d = (remaining.get(s) ?? 0) - 1
remaining.set(s, d)
if (d === 0) ready.push(s)
}
}
const cyclic = [...reachable].filter((n) => !ordered.includes(n))
if (cyclic.length === 0) {
return { nodes: ordered, edges, indegree, cyclic }
}
// Drop cyclic nodes from the schedulable structures.
const cyclicSet = new Set(cyclic)
const cleanEdges = new Map<string, Set<string>>()
const cleanIndegree = new Map<string, number>()
for (const n of ordered) cleanIndegree.set(n, 0)
for (const [p, subs] of edges) {
if (cyclicSet.has(p)) continue
const kept = new Set([...subs].filter((s) => !cyclicSet.has(s)))
if (kept.size > 0) cleanEdges.set(p, kept)
for (const s of kept) cleanIndegree.set(s, (cleanIndegree.get(s) ?? 0) + 1)
}
return { nodes: ordered, edges: cleanEdges, indegree: cleanIndegree, cyclic }
}
export type InducedSchedule = {
/** Selected scripts that are schedulable, in a topological order. */
nodes: string[]
/** In-set dependency edges: `edges.get(p)` = in-set subscribers of `p`. */
edges: Map<string, Set<string>>
/** In-set upstream count per node. */
indegree: Map<string, number>
/** Schedulable scripts with no in-set upstream — the schedule's seeds. */
roots: string[]
/**
* Selected scripts on (or fed only through) a cycle. Excluded from
* `nodes`/`edges`/`indegree`/`roots`; surfaced so callers can warn.
*/
cyclic: string[]
}
/**
* Topological schedule of an arbitrary *set* of scripts (e.g. a bounded-cascade
* selection), respecting only the dependency edges that fall *inside* the set.
* Multi-root: every selected script with no in-set upstream is a seed.
* Cycle-safe in the same way as `computeDownstreamClosure`.
*
* `oneHop` is the producer→consumer adjacency (script path → downstream script
* paths). It defaults to the `// on` subscriber map — but a bounded run passes
* a *read-aware* map (boundedCascade.buildLineageDownstreamMap) so a selected
* script that merely *reads* an upstream asset still runs after its producer,
* matching the CLI's `topoOrder`. Keep the default subscriber-only: it mirrors
* the production asset-trigger dispatch the unbounded cascade simulates.
*/
export function computeInducedSchedule(
g: AssetGraphResponse,
selected: Set<string>,
oneHop: Map<string, Set<string>> = buildDownstreamMap(g)
): InducedSchedule {
// In-set edges + indegrees.
const edges = new Map<string, Set<string>>()
const indegree = new Map<string, number>()
for (const n of selected) indegree.set(n, 0)
for (const p of selected) {
const subs = new Set<string>()
for (const s of oneHop.get(p) ?? []) {
if (!selected.has(s)) continue
subs.add(s)
indegree.set(s, (indegree.get(s) ?? 0) + 1)
}
if (subs.size > 0) edges.set(p, subs)
}
// Kahn from every indegree-0 node.
const seeds = [...selected].filter((n) => (indegree.get(n) ?? 0) === 0)
const remaining = new Map(indegree)
const ready = [...seeds]
const ordered: string[] = []
while (ready.length > 0) {
const n = ready.shift()!
ordered.push(n)
for (const s of edges.get(n) ?? []) {
const d = (remaining.get(s) ?? 0) - 1
remaining.set(s, d)
if (d === 0) ready.push(s)
}
}
const orderedSet = new Set(ordered)
const cyclic = [...selected].filter((n) => !orderedSet.has(n))
if (cyclic.length === 0) {
return { nodes: ordered, edges, indegree, roots: seeds, cyclic }
}
// Strip cyclic nodes from the schedulable structures.
const cyclicSet = new Set(cyclic)
const cleanEdges = new Map<string, Set<string>>()
const cleanIndegree = new Map<string, number>()
for (const n of ordered) cleanIndegree.set(n, 0)
for (const [p, subs] of edges) {
if (cyclicSet.has(p)) continue
const kept = new Set([...subs].filter((s) => !cyclicSet.has(s)))
if (kept.size > 0) cleanEdges.set(p, kept)
for (const s of kept) cleanIndegree.set(s, (cleanIndegree.get(s) ?? 0) + 1)
}
const roots = ordered.filter((n) => (cleanIndegree.get(n) ?? 0) === 0)
return { nodes: ordered, edges: cleanEdges, indegree: cleanIndegree, roots, cyclic }
}