mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
* feat: support restart from steps inside BranchOne, ForLoop, Subflow Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: preserve original job kind in nested restart, support expanded subflow steps Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: read selected iteration from graph state for nested ForLoop restart Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: iteration selectors per ForLoop in restart popup, more nested restart tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: extract useNestedRestartState composable Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: cover deployed-subflow + FlowDependencies path in nested restart Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update sqlx prepare cache Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: detect BranchOne/ForLoop ancestors inside expanded subflows for nested restart Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: hide restart button for non-restartable steps (parallel containers, untaken branches) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review feedback on nested restart PR - preview FlowRestartButton: hide nested case (chain UUIDs aren't resolvable in preview path; users can use the run page for nested restart instead) - branchOneAncestorMatchesOriginal: be permissive when status isn't reachable (don't hide the button for BranchOnes nested deeper than top-level) - worker_flow.rs: apply nested_restart_payload swap on the is_simple ForLoop fast path too, so simple iterations don't bypass restart spawn interception - FlowStatusViewer: reset expandedSubflows cache on jobId change; drop $bindable({}) banned pattern for the new prop - API resolver: validate the leaf step exists before returning (fail-fast) - doc fix: branch_or_iteration_n is 0-based, not 1-based - selectedJobStepIsTopLevel reset on early-return in composable - comment iterationCounts collision caveat - new HTTP-level integration tests covering the API endpoint contract: happy path (top-level + nested), unknown step, out-of-range iteration, parallel-loop rejection Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: remove unreachable nested-restart swap on is_simple ForLoop fast path The swap is unreachable in valid flows: `is_simple_modules` requires the body to be a single `script` / `rawscript` / `flowscript` (per `FlowModule::is_simple`), none of which spawn flow-kind children. Any nested-restart chain targeting a leaf inside such an iteration is rejected by the API at leaf validation. Even if a chain reached the worker via `JobPayload::RawFlow.restarted_from`, the resulting `RestartedFlow` would fail to push (script kind isn't a flow kind). Replaced the swap with an explanatory comment so the next reader knows why the symmetry with the non-simple path was deliberately not added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: handle undefined expandedSubflows + tighten branchOne match check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import type { FlowModule } from '$lib/gen'
|
|
|
|
export type ContainerType = 'branchone' | 'forloopflow' | 'flow' | 'whileloopflow' | 'branchall'
|
|
|
|
export type AncestorEntry = {
|
|
stepId: string
|
|
type: ContainerType
|
|
/** For BranchOne: -1 means default branch, 0..N-1 means branches[i] */
|
|
branchIndex?: number
|
|
/** True for parallel ForLoop / BranchAll — backend rejects nested restart
|
|
* inside parallel containers, so the caller should hide the restart button. */
|
|
parallel?: boolean
|
|
}
|
|
|
|
export type StepPath = {
|
|
target: FlowModule
|
|
ancestors: AncestorEntry[]
|
|
}
|
|
|
|
/**
|
|
* Walks the flow value tree to locate `targetId`. Returns the target module and
|
|
* the chain of containers from the root down to (but not including) the target.
|
|
* Returns `undefined` if the step is not found in this flow value.
|
|
*
|
|
* Subflow boundaries are NOT crossed: if the target sits inside a `Flow{path}`
|
|
* step's referenced flow, this returns `undefined` because we don't have that
|
|
* subflow's value here.
|
|
*/
|
|
export function findStepPath(modules: FlowModule[], targetId: string): StepPath | undefined {
|
|
for (const mod of modules) {
|
|
if (mod.id === targetId) {
|
|
return { target: mod, ancestors: [] }
|
|
}
|
|
const value = mod.value
|
|
if (value.type === 'forloopflow' || value.type === 'whileloopflow') {
|
|
const sub = findStepPath(value.modules, targetId)
|
|
if (sub) {
|
|
return {
|
|
target: sub.target,
|
|
ancestors: [
|
|
{ stepId: mod.id, type: value.type, parallel: value.parallel === true },
|
|
...sub.ancestors
|
|
]
|
|
}
|
|
}
|
|
} else if (value.type === 'branchone') {
|
|
const allBranches: { idx: number; modules: FlowModule[] }[] = [
|
|
{ idx: -1, modules: value.default }
|
|
]
|
|
value.branches.forEach((b, i) => allBranches.push({ idx: i, modules: b.modules }))
|
|
for (const { idx, modules: bm } of allBranches) {
|
|
const sub = findStepPath(bm, targetId)
|
|
if (sub) {
|
|
return {
|
|
target: sub.target,
|
|
ancestors: [{ stepId: mod.id, type: 'branchone', branchIndex: idx }, ...sub.ancestors]
|
|
}
|
|
}
|
|
}
|
|
} else if (value.type === 'branchall') {
|
|
for (let i = 0; i < value.branches.length; i++) {
|
|
const sub = findStepPath(value.branches[i].modules, targetId)
|
|
if (sub) {
|
|
return {
|
|
target: sub.target,
|
|
ancestors: [
|
|
{
|
|
stepId: mod.id,
|
|
type: 'branchall',
|
|
branchIndex: i,
|
|
parallel: value.parallel === true
|
|
},
|
|
...sub.ancestors
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Inline-expanded subflows produce step IDs like
|
|
* `subflow:<outer_subflow_step>:[<nested_subflow_step>:...]<leaf>`. Each `:`-separated
|
|
* segment after the `subflow:` marker is a step ID; the last segment is the leaf
|
|
* (the user's selected step) and the preceding segments are subflow steps along
|
|
* the way (each one a `Flow{path}` module).
|
|
*
|
|
* Returns the parsed segments + leaf, or `undefined` if `id` is not a subflow-prefixed
|
|
* step.
|
|
*/
|
|
export function parseExpandedSubflowId(
|
|
id: string
|
|
): { subflowSteps: string[]; leaf: string } | undefined {
|
|
if (!id.startsWith('subflow:')) {
|
|
return undefined
|
|
}
|
|
const parts = id
|
|
.slice('subflow:'.length)
|
|
.split(':')
|
|
.filter((p) => p.length > 0)
|
|
if (parts.length < 2) {
|
|
return undefined
|
|
}
|
|
const leaf = parts[parts.length - 1]
|
|
const subflowSteps = parts.slice(0, -1)
|
|
return { subflowSteps, leaf }
|
|
}
|