mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
fix: claim a run before the form mints its secrets, not after
The claim sat in `runAction`, which `RunForm.run()` reaches only after `processSecretArgs` has written a variable per `password` argument. So a press that lost the race still minted — and worse, it read the pending call after that round trip, by which point the winner had settled it: with no call to claim, the losing press started a job of its own. `runRefusal` already documents this boundary, saying a caller cannot gate in `runAction` because it comes after the mint. The claim belongs on the same side, so the form takes it through `claimRun` just before the first write and hands it back through `releaseRun` when the mint fails. The pages keep the call they claimed rather than re-reading it, so a claimed press either answers that call or does nothing. The chat card's own form already worked this way, taking the claim ahead of its `processSecretArgs`; the page was the asymmetric surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e5e741d97d
commit
6c2ad4bbf6
@@ -91,6 +91,10 @@
|
|||||||
if (blockedByUnparseable) {
|
if (blockedByUnparseable) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Last gate before the first write: everything below either mints or sends.
|
||||||
|
if (claimRun && !claimRun()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let processedArgs: Record<string, any>
|
let processedArgs: Record<string, any>
|
||||||
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
||||||
if (resetKeys.length > 0) {
|
if (resetKeys.length > 0) {
|
||||||
@@ -99,6 +103,7 @@
|
|||||||
try {
|
try {
|
||||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace)
|
processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
releaseRun?.()
|
||||||
sendUserToast('Failed to process sensitive args: ' + e, true)
|
sendUserToast('Failed to process sensitive args: ' + e, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,6 +148,18 @@
|
|||||||
* for JSON or back mounts a `password` widget, which mints from the literal it opens
|
* for JSON or back mounts a `password` widget, which mints from the literal it opens
|
||||||
* on regardless of being disabled. */
|
* on regardless of being disabled. */
|
||||||
runRefusal?: string
|
runRefusal?: string
|
||||||
|
/** Take whatever this press has to own before anything is written, and return false to
|
||||||
|
* drop it. Behind the same boundary as `runRefusal` and for the same reason: the
|
||||||
|
* secret processing mints a variable per `password` argument, so a claim made in
|
||||||
|
* `runAction` comes after that write. For a caller whose run can be started from a
|
||||||
|
* second surface — the chat card holding the same tool call — where one press winning
|
||||||
|
* has to stop the other from minting, not just from starting a job. Called once per
|
||||||
|
* press that reaches the mint, and says itself why a press was dropped. */
|
||||||
|
claimRun?: () => boolean
|
||||||
|
/** Hand back what `claimRun` took, for a press that claimed and then never reached
|
||||||
|
* `runAction` — the secret processing in between can still fail. A press that does
|
||||||
|
* reach it releases through the caller's own start path instead. */
|
||||||
|
releaseRun?: () => void
|
||||||
runAction: (
|
runAction: (
|
||||||
scheduledForStr: string | undefined,
|
scheduledForStr: string | undefined,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
@@ -177,6 +194,8 @@
|
|||||||
runnable,
|
runnable,
|
||||||
runAction,
|
runAction,
|
||||||
runRefusal = undefined,
|
runRefusal = undefined,
|
||||||
|
claimRun = undefined,
|
||||||
|
releaseRun = undefined,
|
||||||
buttonText = 'Run',
|
buttonText = 'Run',
|
||||||
schedulable = true,
|
schedulable = true,
|
||||||
detailed = true,
|
detailed = true,
|
||||||
|
|||||||
@@ -290,6 +290,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Held between `claimRun` and `runFlow`: the form mints its `password` arguments in
|
||||||
|
* between, so the claim has to be taken before that write while the job is started after
|
||||||
|
* it. Cleared as it is used, so a press that never claimed cannot inherit it. */
|
||||||
|
let claimedCall: PendingRun | undefined = undefined
|
||||||
|
|
||||||
|
/** One call is one job, and the chat card's form is live alongside this page's: the first
|
||||||
|
* of the two to claim owns the call, and the other stops here — before it mints a
|
||||||
|
* variable per `password` argument for a run it will not start. */
|
||||||
|
function claimRun(): boolean {
|
||||||
|
const call = pendingRun
|
||||||
|
if (call && !call.beginSubmit()) {
|
||||||
|
sendUserToast('This run is already being started from the chat.', true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claimedCall = call
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form claimed and then never reached the run — its secret processing failed. */
|
||||||
|
function releaseRun() {
|
||||||
|
claimedCall?.endSubmit()
|
||||||
|
claimedCall = undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function runFlow(
|
async function runFlow(
|
||||||
scheduledForStr: string | undefined,
|
scheduledForStr: string | undefined,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
@@ -299,13 +323,12 @@
|
|||||||
// Taken before the round trip below: the call this press answers is the one it was
|
// Taken before the round trip below: the call this press answers is the one it was
|
||||||
// pressed under, and a call settled meanwhile — declined, then another proposed for
|
// pressed under, and a call settled meanwhile — declined, then another proposed for
|
||||||
// this same flow — must not be answered with a job it never asked for.
|
// this same flow — must not be answered with a job it never asked for.
|
||||||
const call = pendingRun
|
// The call this press claimed, not whatever is pending now: the form mints its secrets
|
||||||
// One call is one job, and the card's form is live alongside this one: without the
|
// between the claim and here, and a call that settled across that round trip would
|
||||||
// claim, a Run there during the round trip below starts a second.
|
// leave this press looking like a page run of its own — starting a second job for a
|
||||||
if (call && !call.beginSubmit()) {
|
// call the chat has already answered.
|
||||||
sendUserToast('This run is already being started from the chat.', true)
|
const call = claimedCall
|
||||||
return
|
claimedCall = undefined
|
||||||
}
|
|
||||||
loading = true
|
loading = true
|
||||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||||
try {
|
try {
|
||||||
@@ -930,6 +953,8 @@
|
|||||||
bind:isValid
|
bind:isValid
|
||||||
runnable={flow}
|
runnable={flow}
|
||||||
runAction={runFlow}
|
runAction={runFlow}
|
||||||
|
{claimRun}
|
||||||
|
{releaseRun}
|
||||||
runRefusal={pendingRun?.planModeActive
|
runRefusal={pendingRun?.planModeActive
|
||||||
? PLAN_MODE_MESSAGES.runFormRefused
|
? PLAN_MODE_MESSAGES.runFormRefused
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@@ -379,6 +379,30 @@
|
|||||||
let isValid = $state(true)
|
let isValid = $state(true)
|
||||||
|
|
||||||
let runLoading = $state(false)
|
let runLoading = $state(false)
|
||||||
|
/** Held between `claimRun` and `runScript`: the form mints its `password` arguments in
|
||||||
|
* between, so the claim has to be taken before that write while the job is started after
|
||||||
|
* it. Cleared as it is used, so a press that never claimed cannot inherit it. */
|
||||||
|
let claimedCall: PendingRun | undefined = undefined
|
||||||
|
|
||||||
|
/** One call is one job, and the chat card's form is live alongside this page's: the first
|
||||||
|
* of the two to claim owns the call, and the other stops here — before it mints a
|
||||||
|
* variable per `password` argument for a run it will not start. */
|
||||||
|
function claimRun(): boolean {
|
||||||
|
const call = pendingRun
|
||||||
|
if (call && !call.beginSubmit()) {
|
||||||
|
sendUserToast('This run is already being started from the chat.', true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claimedCall = call
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form claimed and then never reached the run — its secret processing failed. */
|
||||||
|
function releaseRun() {
|
||||||
|
claimedCall?.endSubmit()
|
||||||
|
claimedCall = undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function runScript(
|
async function runScript(
|
||||||
scheduledForStr: string | undefined,
|
scheduledForStr: string | undefined,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
@@ -388,13 +412,12 @@
|
|||||||
// Taken before the round trip below: the call this press answers is the one it was
|
// Taken before the round trip below: the call this press answers is the one it was
|
||||||
// pressed under, and a call settled meanwhile — declined, then another proposed for
|
// pressed under, and a call settled meanwhile — declined, then another proposed for
|
||||||
// this same script — must not be answered with a job it never asked for.
|
// this same script — must not be answered with a job it never asked for.
|
||||||
const call = pendingRun
|
// The call this press claimed, not whatever is pending now: the form mints its secrets
|
||||||
// One call is one job, and the card's form is live alongside this one: without the
|
// between the claim and here, and a call that settled across that round trip would
|
||||||
// claim, a Run there during the round trip below starts a second.
|
// leave this press looking like a page run of its own — starting a second job for a
|
||||||
if (call && !call.beginSubmit()) {
|
// call the chat has already answered.
|
||||||
sendUserToast('This run is already being started from the chat.', true)
|
const call = claimedCall
|
||||||
return
|
claimedCall = undefined
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
runLoading = true
|
runLoading = true
|
||||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||||
@@ -1145,6 +1168,8 @@
|
|||||||
bind:isValid
|
bind:isValid
|
||||||
runnable={script}
|
runnable={script}
|
||||||
runAction={runScript}
|
runAction={runScript}
|
||||||
|
{claimRun}
|
||||||
|
{releaseRun}
|
||||||
runRefusal={pendingRun?.planModeActive
|
runRefusal={pendingRun?.planModeActive
|
||||||
? PLAN_MODE_MESSAGES.runFormRefused
|
? PLAN_MODE_MESSAGES.runFormRefused
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
Reference in New Issue
Block a user