mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
* 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>
204 lines
7.6 KiB
TypeScript
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 }
|
|
}
|