Files
windmill/frontend/src/lib/components/runs/ScriptRetryChain.svelte
T

192 lines
6.6 KiB
Svelte

<script lang="ts">
import { JobService, type Job } from '$lib/gen'
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import { workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import { canSkipRetryChainQuery } from './scriptRetryChain'
let { job }: { job: Job } = $props()
type AttemptColor = 'green' | 'red' | 'blue' | 'gray'
type Attempt = { id: string; color: AttemptColor; current: boolean }
type HandlerKind = 'failure' | 'recovery' | 'success'
type HandlerLink = { id: string; kind: HandlerKind; color: AttemptColor }
type Info = {
retries: Attempt[]
handlers: HandlerLink[]
self?: { kind: HandlerKind; schedulePath?: string; handledJob?: string }
}
const HANDLER_LABEL: Record<HandlerKind, string> = {
failure: 'Failure handler',
recovery: 'Recovery handler',
success: 'Success handler'
}
function statusColor(j: Job | undefined): AttemptColor {
if (!j) return 'gray'
if (j.type === 'CompletedJob') return j.success ? 'green' : 'red'
if (j.type === 'QueuedJob' && j.running) return 'blue'
return 'gray'
}
// A schedule completion handler is recognizable by its synthetic `created_by`.
// `on_recovery` and `on_success` share it, so the recovery-only `error_started_at`
// arg disambiguates them (absent => plain success). Needs the full job (the list
// endpoint omits args), so callers pass a job fetched via getJob or the page job.
function handlerKind(j: Job | undefined): HandlerKind | undefined {
if (j?.created_by === 'schedule_error_handler') return 'failure'
if (j?.created_by === 'schedule_recovery_handler') {
return j?.args && 'error_started_at' in j.args ? 'recovery' : 'success'
}
return undefined
}
const info = resource(
() => ({ job, ws: job?.workspace_id ?? $workspaceStore }),
async ({ job, ws }): Promise<Info> => {
if (!ws || !job) return { retries: [], handlers: [] }
// The current job IS a handler: describe it rather than look for a chain.
const self = handlerKind(job)
if (self) {
return {
retries: [],
handlers: [],
self: {
kind: self,
schedulePath: (job.args as any)?.schedule_path,
handledJob: job.parent_job
}
}
}
// Native script retries are real `Script` jobs linked by `parent_job` to the
// first attempt (the chain root, itself a script). Flow steps also carry
// `parent_job`, but their parent is a flow — a non-script root means flow step.
if (job.job_kind !== 'script') return { retries: [], handlers: [] }
// Skip the child-job query when it provably has nothing to show (see
// canSkipRetryChainQuery) — this runs on every script run view.
if (canSkipRetryChainQuery(job)) return { retries: [], handlers: [] }
const root = job.parent_job ?? job.id
const rootJob = job.id === root ? job : await JobService.getJob({ workspace: ws, id: root })
if (rootJob?.job_kind !== 'script') return { retries: [], handlers: [] }
// No kind filter: retry attempts are scripts (kept via `is_retry` below),
// but schedule handlers can be flows (flow/... handler paths), so both
// kinds must be fetched for the handler row to find flow handlers.
const children = await JobService.listJobs({
workspace: ws,
parentJob: root
})
// Retry attempts carry the explicit `is_retry` marker; other children
// (WAC v2 inline children, flow/script handlers) do not, so only real
// retries are kept here.
const retryJobs = (children ?? [])
.filter((c) => c.is_retry === true)
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
const chain: (Job | undefined)[] = [rootJob, ...retryJobs]
const retries: Attempt[] =
retryJobs.length === 0
? []
: chain.map((j) => ({
id: j?.id ?? root,
color: statusColor(j),
current: j?.id === job.id
}))
// Schedule handlers fire on the terminal attempt, so they are its children.
const terminalId = chain[chain.length - 1]?.id ?? root
const handlerChildren =
terminalId === root
? children
: await JobService.listJobs({ workspace: ws, parentJob: terminalId })
// `created_by` flags a handler; fetch the full job to read `args` (the list
// endpoint omits it) so recovery and success can be told apart.
const handlers: HandlerLink[] = (
await Promise.all(
(handlerChildren ?? [])
.filter(
(c) =>
c.created_by === 'schedule_error_handler' ||
c.created_by === 'schedule_recovery_handler'
)
.map(async (c) => {
const full = await JobService.getJob({ workspace: ws, id: c.id })
const kind = handlerKind(full)
return kind ? { id: c.id, kind, color: statusColor(full) } : undefined
})
)
).filter((h): h is HandlerLink => h !== undefined)
return { retries, handlers }
}
)
const self = $derived(info.current?.self)
const retries = $derived(info.current?.retries ?? [])
const handlers = $derived(info.current?.handlers ?? [])
const ws = $derived(job?.workspace_id ?? $workspaceStore)
</script>
{#if self}
<div class="max-w-7xl mx-auto w-full px-4 mt-12">
<div class="text-xs text-emphasis font-semibold mb-1">{HANDLER_LABEL[self.kind]}</div>
<div class="flex flex-row flex-wrap gap-2 items-center">
{#if self.handledJob}
<Button
size="xs"
color="gray"
variant="border"
onclick={() => goto(`/run/${self?.handledJob}?workspace=${ws}`)}
>
Handled run
</Button>
{/if}
{#if self.schedulePath}
<span class="text-xs text-secondary">
for schedule <span class="font-mono">{self.schedulePath}</span>
</span>
{/if}
</div>
</div>
{:else}
{#if retries.length > 1}
<div class="max-w-7xl mx-auto w-full px-4 mt-12">
<div class="text-xs text-emphasis font-semibold mb-1">Retries ({retries.length - 1})</div>
<div class="flex flex-row flex-wrap gap-2">
{#each retries as attempt, i (attempt.id)}
<Button
size="xs"
color={attempt.color}
variant={attempt.current ? 'contained' : 'border'}
onclick={() => goto(`/run/${attempt.id}?workspace=${ws}`)}
>
{i === 0 ? 'Original' : `Attempt ${i}`}
</Button>
{/each}
</div>
</div>
{/if}
{#if handlers.length > 0}
<div class="max-w-7xl mx-auto w-full px-4 mt-4">
<div class="text-xs text-emphasis font-semibold mb-1">Handlers</div>
<div class="flex flex-row flex-wrap gap-2">
{#each handlers as handler (handler.id)}
<Button
size="xs"
color={handler.color}
variant="border"
onclick={() => goto(`/run/${handler.id}?workspace=${ws}`)}
>
{HANDLER_LABEL[handler.kind]}
</Button>
{/each}
</div>
</div>
{/if}
{/if}