mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +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> * fix: iteration count for restart popup picks up wrong loop on step-id collision When a top-level ForLoop step shares an id with a loop nested inside an expanded subflow (e.g. parent has step `e` with 4 iterations and the subflow at step `h` also has step `e` with 1 iteration), the popup's iteration `<select>` rendered the subflow's count instead of the parent's because `iterationCounts` was double-indexed by both the prefixed graph key and the bare leaf segment after the last colon — last writer wins, and the bare key collided. - `iterationCounts` now indexed only by the full graph key. The selected-step iteration picker still works because at the top level the step id IS the graph key. - New `nestedPathIterationCounts` keyed by the popup's field-key ('top' / 'inner-N'). Populated by the composable during path construction so each entry uses the correct prefix-aware graph key, regardless of whether the ancestor lives in the parent flow or inside an expanded subflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
363 lines
12 KiB
Svelte
363 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { Badge, Button } from './common'
|
|
import Popover from './meltComponents/Popover.svelte'
|
|
import { Play, RefreshCw } from 'lucide-svelte'
|
|
import { FlowService, JobService, type FlowVersion } from '$lib/gen'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { emptyString, sendUserToast } from '$lib/utils'
|
|
|
|
interface Props {
|
|
jobId: string
|
|
selectedJobStep: string
|
|
selectedJobStepType: 'single' | 'forloop' | 'branchall'
|
|
restartBranchNames?: [number, string][]
|
|
flowPath?: string
|
|
/** The flow version ID used in this run (from job.script_hash) */
|
|
flowVersionId?: number
|
|
disabled?: boolean
|
|
enterpriseOnly?: boolean
|
|
variant?: 'default' | 'accent'
|
|
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
|
|
/**
|
|
* For nested-step restarts: path of ancestor containers from the top-level
|
|
* step down to the leaf. When provided, the LAST entry's step_id is the
|
|
* actual restart point and `selectedJobStep` (the leaf shown in the UI) is
|
|
* informational only — the request is built from `nestedTopStepId`,
|
|
* `nestedTopBranchOrIterationN` and `nestedPath`.
|
|
*/
|
|
nestedPath?: Array<{ step_id: string; branch_or_iteration_n?: number }>
|
|
/** Top-level container step id (only used when nestedPath is set) */
|
|
nestedTopStepId?: string
|
|
/** Top-level container branch_or_iteration_n (only used when nestedPath is set) */
|
|
nestedTopBranchOrIterationN?: number
|
|
/**
|
|
* For top-level ForLoop restart: iteration index the user is currently
|
|
* viewing in the graph (read from `selectedForloopIndex` upstream). When
|
|
* provided, the popup pre-fills the iteration selector with this value.
|
|
*/
|
|
presetIterationN?: number
|
|
/**
|
|
* Map from ForLoop step id to the number of iterations that ran in the
|
|
* original execution (i.e. `flow_jobs.length`). When provided for a step,
|
|
* the popup renders a `<select>` of `0..count-1` instead of a free-form
|
|
* number input — same surface as the graph's iteration tabs. Used for the
|
|
* SELECTED step's iteration picker when it is itself a top-level ForLoop.
|
|
*/
|
|
iterationCounts?: Record<string, number>
|
|
/**
|
|
* Iteration counts for nested-path entries, keyed by the popup's field-key
|
|
* (`'top'` for the outer container, `'inner-N'` for nested ancestors).
|
|
* Populated by the composable so each entry uses the *correct* graph-state
|
|
* key (prefixed for in-subflow ancestors), avoiding collisions when the
|
|
* same step id appears at multiple nesting levels.
|
|
*/
|
|
nestedPathIterationCounts?: Record<string, number>
|
|
/** Called when flow is restarted. If not provided, will navigate to the new run using goto (requires SvelteKit) */
|
|
onRestart?: (stepId: string, branchOrIterationN: number, flowVersion?: number) => void
|
|
/** Called when flow restart completes with the new job ID. Used for navigation in non-SvelteKit contexts */
|
|
onRestartComplete?: (newJobId: string) => void
|
|
}
|
|
|
|
let {
|
|
jobId,
|
|
selectedJobStep,
|
|
selectedJobStepType,
|
|
restartBranchNames = [],
|
|
flowPath = undefined,
|
|
flowVersionId = undefined,
|
|
disabled = false,
|
|
enterpriseOnly = false,
|
|
variant = 'default',
|
|
unifiedSize = 'md',
|
|
nestedPath = undefined,
|
|
nestedTopStepId = undefined,
|
|
nestedTopBranchOrIterationN = undefined,
|
|
presetIterationN = undefined,
|
|
iterationCounts = undefined,
|
|
nestedPathIterationCounts = undefined,
|
|
onRestart,
|
|
onRestartComplete
|
|
}: Props = $props()
|
|
|
|
const isNested = $derived((nestedPath?.length ?? 0) > 0)
|
|
// Inline-expanded subflow steps come in as `subflow:A:B:leaf` — show only `leaf`.
|
|
const displayStepId = $derived(
|
|
isNested && nestedPath && nestedPath.length > 0
|
|
? nestedPath[nestedPath.length - 1].step_id
|
|
: selectedJobStep
|
|
)
|
|
|
|
// Sentinel value meaning "use the same version as the original run" (backend receives undefined)
|
|
const RUN_VERSION_SENTINEL = -1
|
|
|
|
let branchOrIterationN = $state(0)
|
|
|
|
// Iteration inputs for nested-step restart inside one or more ForLoop ancestors.
|
|
// Each editable position keeps a local copy that the popup can mutate; on
|
|
// submit we rebuild the request from these. We snapshot from props every time
|
|
// the popup opens (see `resetEditsFromProps`), not on every prop change — that
|
|
// way the user's manual edits stick while the popup is open, and re-opening
|
|
// after picking a different iteration in the graph picks up the new value.
|
|
type IterField = { key: 'top' | `inner-${number}`; label: string; value: number }
|
|
let iterationEdits: Record<string, number> = $state({})
|
|
|
|
function resetEditsFromProps() {
|
|
const next: Record<string, number> = {}
|
|
if (isNested && nestedTopBranchOrIterationN !== undefined) {
|
|
next['top'] = nestedTopBranchOrIterationN
|
|
}
|
|
if (isNested) {
|
|
nestedPath?.forEach((entry, i) => {
|
|
if (entry.branch_or_iteration_n !== undefined) {
|
|
next[`inner-${i}`] = entry.branch_or_iteration_n
|
|
}
|
|
})
|
|
}
|
|
iterationEdits = next
|
|
if (selectedJobStepType === 'forloop' && presetIterationN !== undefined) {
|
|
branchOrIterationN = presetIterationN
|
|
}
|
|
}
|
|
const iterationFields = $derived.by((): IterField[] => {
|
|
if (!isNested) return []
|
|
const out: IterField[] = []
|
|
if (nestedTopBranchOrIterationN !== undefined && nestedTopStepId) {
|
|
out.push({
|
|
key: 'top',
|
|
label: nestedTopStepId,
|
|
value: iterationEdits['top'] ?? nestedTopBranchOrIterationN
|
|
})
|
|
}
|
|
nestedPath?.forEach((entry, i) => {
|
|
if (entry.branch_or_iteration_n !== undefined) {
|
|
const k = `inner-${i}` as const
|
|
out.push({
|
|
key: k,
|
|
label: entry.step_id,
|
|
value: iterationEdits[k] ?? entry.branch_or_iteration_n
|
|
})
|
|
}
|
|
})
|
|
return out
|
|
})
|
|
const needsPopup = $derived(
|
|
!!flowPath || iterationFields.length > 0 || selectedJobStepType !== 'single'
|
|
)
|
|
let selectedFlowVersion: number = $state(RUN_VERSION_SENTINEL)
|
|
let flowVersions: Array<FlowVersion> = $state([])
|
|
let loadingVersions: boolean = $state(false)
|
|
let versionsLoaded: boolean = $state(false)
|
|
let runVersionInList: boolean = $state(false)
|
|
|
|
async function restartFlow(stepId: string, branchOrIterationN: number, flowVersion?: number) {
|
|
const requestBody = isNested
|
|
? {
|
|
step_id: nestedTopStepId!,
|
|
branch_or_iteration_n: iterationEdits['top'] ?? nestedTopBranchOrIterationN,
|
|
flow_version: flowVersion,
|
|
nested_path: nestedPath?.map((entry, i) => ({
|
|
step_id: entry.step_id,
|
|
branch_or_iteration_n: iterationEdits[`inner-${i}`] ?? entry.branch_or_iteration_n
|
|
}))
|
|
}
|
|
: {
|
|
step_id: stepId,
|
|
branch_or_iteration_n: branchOrIterationN,
|
|
flow_version: flowVersion
|
|
}
|
|
let run = await JobService.restartFlowAtStep({
|
|
workspace: $workspaceStore!,
|
|
id: jobId,
|
|
requestBody
|
|
})
|
|
onRestartComplete?.(run)
|
|
}
|
|
|
|
async function loadFlowVersions() {
|
|
if (!flowPath || loadingVersions || versionsLoaded) return
|
|
loadingVersions = true
|
|
try {
|
|
flowVersions = await FlowService.getFlowHistory({
|
|
workspace: $workspaceStore!,
|
|
path: flowPath
|
|
})
|
|
if (flowVersions.length > 0) {
|
|
const match = flowVersionId ? flowVersions.find((v) => v.id === flowVersionId) : undefined
|
|
runVersionInList = match !== undefined
|
|
selectedFlowVersion =
|
|
match?.id ?? (flowVersionId ? RUN_VERSION_SENTINEL : flowVersions[0].id)
|
|
}
|
|
versionsLoaded = true
|
|
} catch (e) {
|
|
sendUserToast('Failed to load flow versions', true)
|
|
} finally {
|
|
loadingVersions = false
|
|
}
|
|
}
|
|
|
|
function getFlowVersionForRestart(): number | undefined {
|
|
return selectedFlowVersion === RUN_VERSION_SENTINEL ? undefined : selectedFlowVersion
|
|
}
|
|
|
|
function formatVersionLabel(version: FlowVersion): string {
|
|
const name = emptyString(version.deployment_msg) ? `v${version.id}` : version.deployment_msg!
|
|
return `${name} - ${new Date(version.created_at).toLocaleString()}`
|
|
}
|
|
|
|
function handleRestart() {
|
|
if (onRestart) {
|
|
onRestart(selectedJobStep, branchOrIterationN, getFlowVersionForRestart())
|
|
} else {
|
|
const flowVersion = getFlowVersionForRestart()
|
|
restartFlow(selectedJobStep, branchOrIterationN, flowVersion)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#snippet flowVersionSelector()}
|
|
<label>
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">Flow version</div>
|
|
{#if loadingVersions}
|
|
<div class="text-xs text-secondary">Loading versions...</div>
|
|
{:else if flowVersions.length > 0}
|
|
<select bind:value={selectedFlowVersion} class="w-full text-xs">
|
|
{#if flowVersionId && !runVersionInList}
|
|
<option value={RUN_VERSION_SENTINEL}>Same as run (v{flowVersionId})</option>
|
|
{/if}
|
|
{#each flowVersions as version, i (version.id)}
|
|
{@const isLatest = i === 0}
|
|
{@const isSameAsRun = flowVersionId !== undefined && version.id === flowVersionId}
|
|
<option value={version.id}>
|
|
{formatVersionLabel(version)}{isSameAsRun
|
|
? ' (Same as run)'
|
|
: isLatest
|
|
? ' (Latest)'
|
|
: ''}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
{:else}
|
|
<div class="text-xs text-tertiary">No versions available</div>
|
|
{/if}
|
|
</label>
|
|
{/snippet}
|
|
{#snippet restartTriggerButton(usePlayIcon: boolean)}
|
|
<Button
|
|
title={`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`}
|
|
{variant}
|
|
{unifiedSize}
|
|
{disabled}
|
|
startIcon={{ icon: usePlayIcon ? Play : RefreshCw }}
|
|
nonCaptureEvent={!usePlayIcon || !!flowPath}
|
|
onClick={() => {
|
|
if (usePlayIcon && !flowPath) {
|
|
handleRestart()
|
|
}
|
|
}}
|
|
>
|
|
Re-start from
|
|
<Badge baseClass="ml-1" color="indigo">
|
|
{displayStepId}
|
|
</Badge>
|
|
{#if enterpriseOnly && disabled}
|
|
(EE)
|
|
{/if}
|
|
</Button>
|
|
{/snippet}
|
|
|
|
{#if !needsPopup}
|
|
{@render restartTriggerButton(true)}
|
|
{:else}
|
|
<Popover
|
|
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
|
|
disablePopup={!needsPopup}
|
|
on:openChange={(e) => {
|
|
if (e.detail) {
|
|
loadFlowVersions()
|
|
resetEditsFromProps()
|
|
}
|
|
}}
|
|
>
|
|
{#snippet trigger()}
|
|
{@render restartTriggerButton(selectedJobStepType === 'single')}
|
|
{/snippet}
|
|
{#snippet content()}
|
|
<div class="flex flex-col gap-4 text-primary p-4 w-80">
|
|
{#if selectedJobStepType === 'forloop'}
|
|
{@const topCount = iterationCounts?.[selectedJobStep] ?? 0}
|
|
<label>
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">From iteration #</div>
|
|
<div class="flex w-full gap-2">
|
|
{#if topCount > 0}
|
|
<select bind:value={branchOrIterationN} class="!w-32 grow">
|
|
{#each Array.from({ length: topCount }, (_, i) => i) as i (i)}
|
|
<option value={i}>{i + 1}</option>
|
|
{/each}
|
|
</select>
|
|
{:else}
|
|
<input type="number" min="0" bind:value={branchOrIterationN} class="!w-32 grow" />
|
|
{/if}
|
|
</div>
|
|
</label>
|
|
{:else if selectedJobStepType === 'branchall'}
|
|
<label>
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">From branch</div>
|
|
<div class="flex w-full gap-2">
|
|
<select bind:value={branchOrIterationN} class="!w-32 grow">
|
|
{#each restartBranchNames as [branchIdx, branchName] (branchIdx)}
|
|
<option value={branchIdx}>{branchName}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</label>
|
|
{/if}
|
|
|
|
{#each iterationFields as field (field.key)}
|
|
{@const count = nestedPathIterationCounts?.[field.key] ?? 0}
|
|
<label>
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">
|
|
From iteration # of <code class="text-xs">{field.label}</code>
|
|
</div>
|
|
<div class="flex w-full gap-2">
|
|
{#if count > 0}
|
|
<select
|
|
value={field.value}
|
|
onchange={(e) => {
|
|
const v = parseInt((e.target as HTMLSelectElement).value, 10)
|
|
if (!Number.isNaN(v)) {
|
|
iterationEdits = { ...iterationEdits, [field.key]: v }
|
|
}
|
|
}}
|
|
class="!w-32 grow"
|
|
>
|
|
{#each Array.from({ length: count }, (_, i) => i) as i (i)}
|
|
<option value={i}>{i + 1}</option>
|
|
{/each}
|
|
</select>
|
|
{:else}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={field.value}
|
|
oninput={(e) => {
|
|
const v = parseInt((e.target as HTMLInputElement).value, 10)
|
|
if (!Number.isNaN(v)) {
|
|
iterationEdits = { ...iterationEdits, [field.key]: v }
|
|
}
|
|
}}
|
|
class="!w-32 grow"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</label>
|
|
{/each}
|
|
|
|
{#if flowPath}
|
|
{@render flowVersionSelector()}
|
|
{/if}
|
|
<Button variant="accent" onClick={handleRestart}>Restart</Button>
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
{/if}
|