Files
windmill/frontend/src/lib/components/scripts/perpetualRuns.ts
T
hugocasaandClaude Opus 5 cf5c49c3dc feat: restart perpetual runs on the version a deploy makes runnable (#11200)
* feat: opt-in move of perpetual runs to a newly deployed script version

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep the perpetual-run opt-in across relocks and check the new version's tag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: pin the tag check on a perpetual version switch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: count perpetual runs past the first queue page in the deploy prompt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: restart perpetual runs on the version a deploy makes runnable

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: claim a perpetual run and queue its replacement in one transaction

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: honour a cancel that lands after the worker last read its queue row

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: leave the lost-cancel fix to its own PR and match the scale down to 0 wording

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: run a preprocessor the deployed version adds over the arguments carried over

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: shorten the wording of the modal's argument warning

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: never preprocess a restarted perpetual run, as every other restart does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: preprocess for a replacement whose run had not been through one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: push a perpetual replacement without the deployed debounce settings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: name the runs the deploy button restarts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf: skip the perpetual restart lookups on a deploy that is not perpetual

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: move perpetual runs before anything that can fail after the deploy commits

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: report cancellation on the queue listing so the deploy prompt can skip it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: pass the tag workspace to the availability check after the merge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: say which arguments are defined differently and which values are kept

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve a dynamic tag before checking it for a restarted run

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: pin that a deployed dynamic tag is checked as it resolves

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 18:03:43 +02:00

96 lines
3.5 KiB
TypeScript

import { JobService, ScriptService, type QueuedJob, type Script } from '$lib/gen'
import { computeDiff } from '$lib/components/schema/schemaUtils.svelte'
const QUEUE_PAGE_SIZE = 1000
export type PerpetualRunsAtPath = {
/** Undefined when the runs at the path could not be listed: the deploy restarts them either way. */
count: number | undefined
/** Arguments the deployed schema removes, retypes or newly requires, compared with the runs' versions. */
mismatchedArgs: string[]
}
const RUNS_UNKNOWN: PerpetualRunsAtPath = { count: undefined, mismatchedArgs: [] }
/** By path rather than by the ids listed: a loop restarts every 10s, so the run listed when the
* modal opened is usually gone, and this endpoint makes the two passes that window needs. */
export async function stopPerpetualRuns(workspace: string, path: string): Promise<void> {
await JobService.cancelPersistentQueuedJobs({
workspace,
path,
requestBody: { reason: 'stopped before a new version was deployed' }
})
}
// Every page: the deploy switches every perpetual run at the path, so one past the first page
// still needs the prompt and its version's arguments compared.
async function listQueuedAtPath(workspace: string, path: string): Promise<QueuedJob[]> {
const jobs = new Map<string, QueuedJob>()
for (let page = 1; ; page++) {
const batch = await JobService.listQueue({
workspace,
scriptPathExact: path,
jobKinds: 'script',
perPage: QUEUE_PAGE_SIZE,
page
})
for (const job of batch) jobs.set(job.id, job)
if (batch.length < QUEUE_PAGE_SIZE) return [...jobs.values()]
}
}
export async function loadPerpetualRunsAtPath(
workspace: string,
path: string,
schema: { [key: string]: any } | undefined
): Promise<PerpetualRunsAtPath | undefined> {
// A deploy restarts every perpetual run at the path whatever this finds, so anything it cannot
// read leaves the count unknown rather than reporting none and skipping the prompt.
let queued: QueuedJob[]
try {
queued = await listQueuedAtPath(workspace, path)
} catch (error) {
console.error('Could not list the runs of this perpetual script', error)
return RUNS_UNKNOWN
}
// Only what the backend restarts: never a flow step or a run already canceled, and only a run
// of a perpetual version.
const candidates = queued.filter((job) => !job.is_flow_step && !job.canceled && job.script_hash)
const hashes = [...new Set(candidates.map((job) => job.script_hash!))]
let versions: Map<string, Script>
try {
versions = new Map(
await Promise.all(
hashes.map(
async (hash) => [hash, await ScriptService.getScriptByHash({ workspace, hash })] as const
)
)
)
} catch (error) {
console.error('Could not read the versions the runs of this script are on', error)
return RUNS_UNKNOWN
}
const runs = candidates.filter((job) => versions.get(job.script_hash!)?.restart_unless_cancelled)
if (runs.length === 0) {
return undefined
}
const mismatchedArgs = new Set<string>()
for (const hash of new Set(runs.map((job) => job.script_hash!))) {
const previous = versions.get(hash)?.schema
for (const [arg, { diff }] of Object.entries(computeDiff(schema, previous))) {
// An added argument only breaks a reused run when it is required, checked below.
if (diff !== 'same' && diff !== 'added') mismatchedArgs.add(arg)
}
const previouslyRequired: unknown[] = Array.isArray(previous?.required) ? previous.required : []
for (const arg of schema?.required ?? []) {
if (!previouslyRequired.includes(arg)) mismatchedArgs.add(arg)
}
}
return {
count: runs.length,
mismatchedArgs: [...mismatchedArgs]
}
}