mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
fix(sessions): bind a press on the page to the call it started against
A call stops being pending the instant Run is pressed, well before its job exists. The page went back to running on its own in that gap, so a second press there started a job the chat never heard about. A press now holds the call until the job lands or the call settles. A press also kept reading its runner across `processSecretArgs`: cancelling while a password was being minted switched the press to the page's own runner, which then started a job the reader had just cancelled. The runner is read once, when the press begins. A chat flow's message waits on that same round trip. It needs nothing from it, but a composer filled ahead of the inputs can be sent ahead of them, and the run would carry the inputs the proposal was meant to replace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ac9b56ba3
commit
7a02b21d52
@@ -85,6 +85,11 @@
|
||||
return
|
||||
}
|
||||
if (claimRun && !claimRun.claim()) return
|
||||
// Captured for the whole press, not read again after the await below: a `runAction` that
|
||||
// stands for something the press was started against — a chat tool call waiting on this
|
||||
// form — is replaced by the page's own runner the moment that thing goes away, and a
|
||||
// press that switched runners mid-flight would start a job nobody asked for.
|
||||
const action = runAction
|
||||
let processedArgs: Record<string, any>
|
||||
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
||||
if (resetKeys.length > 0) {
|
||||
@@ -104,7 +109,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
runAction(
|
||||
action(
|
||||
overrideScheduledForStr === null ? undefined : (overrideScheduledForStr ?? scheduledForStr),
|
||||
processedArgs,
|
||||
invisible_to_owner,
|
||||
|
||||
@@ -410,15 +410,22 @@
|
||||
// Minted before they are applied, not only before the run: the composer's inputs
|
||||
// are saved to localStorage as soon as the reader touches any of them, so a
|
||||
// `password` the model proposed as a literal would be written there in the clear.
|
||||
//
|
||||
// The message waits on the same round trip even though it needs nothing from it:
|
||||
// a composer filled ahead of the inputs can be sent ahead of them, and that run
|
||||
// would carry whatever the inputs were before — saved values or schema defaults,
|
||||
// not the proposal the reader just agreed to send.
|
||||
const chat = flowChat
|
||||
processSecretArgs(inputs, flow?.schema as Schema | undefined, workspace)
|
||||
.then((minted) => chat.applyInputs(minted))
|
||||
.then((minted) => {
|
||||
chat.applyInputs(minted)
|
||||
// A composer already holding a draft declines, and the message is then dropped
|
||||
// rather than held: injecting it whenever the reader happens to clear their
|
||||
// draft would put words in the box long after they were proposed. The strip
|
||||
// above it still says what was asked for.
|
||||
if (typeof message === 'string' && message) chat.offerMessage(message)
|
||||
})
|
||||
.catch((e) => sendUserToast('Failed to process sensitive args: ' + e, true))
|
||||
// A composer already holding a draft declines, and the message is then dropped
|
||||
// rather than held: injecting it whenever the reader happens to clear their draft
|
||||
// would put words in the box long after they were proposed. The strip above it
|
||||
// still says what was asked for.
|
||||
if (typeof message === 'string' && message) flowChat.offerMessage(message)
|
||||
} else if (runForm) {
|
||||
seededCallId = pendingRun.toolCallId
|
||||
runForm.setArgs(pendingRun.args)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
import type { PendingRun } from '$lib/components/details/pendingRun'
|
||||
import { get } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
@@ -577,6 +577,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// plain deployed page.
|
||||
const adoptedRuns = new SvelteMap<string, { toolCallId: string; args: Record<string, any> }>()
|
||||
const adoptionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
|
||||
/** Calls whose Run has been pressed on the page and whose job has not landed yet. */
|
||||
const heldPresses = new SvelteSet<string>()
|
||||
|
||||
/** The call waiting on this item's run form, for the viewer that renders it. Read live
|
||||
* rather than copied, so it disappears the moment the call settles however it settles. */
|
||||
@@ -588,7 +590,15 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// a call can end is what covers the ways that reach no hook at all — a submit whose
|
||||
// job never started, because the server refused it or plan mode blocked it after the
|
||||
// arguments were already handed over.
|
||||
if (!manager.isRunFormPending(adopted.toolCallId)) return undefined
|
||||
//
|
||||
// A press already handed over counts as waiting: the call stops being pending the
|
||||
// instant Run is pressed, well before its job exists, and a page that went back to
|
||||
// running on its own in that gap would start a second job for the same request on a
|
||||
// second press. It ends when the job lands or the call settles, which both drop the
|
||||
// adoption outright.
|
||||
if (!manager.isRunFormPending(adopted.toolCallId) && !heldPresses.has(adopted.toolCallId)) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
toolCallId: adopted.toolCallId,
|
||||
args: adopted.args,
|
||||
@@ -600,9 +610,14 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true)
|
||||
return false
|
||||
}
|
||||
return manager.beginRunFormSubmit(adopted.toolCallId)
|
||||
if (!manager.beginRunFormSubmit(adopted.toolCallId)) return false
|
||||
heldPresses.add(adopted.toolCallId)
|
||||
return true
|
||||
},
|
||||
release: () => {
|
||||
heldPresses.delete(adopted.toolCallId)
|
||||
manager.endRunFormSubmit(adopted.toolCallId)
|
||||
},
|
||||
release: () => manager.endRunFormSubmit(adopted.toolCallId),
|
||||
// False when the call is no longer waiting — a turn stopped out from under the page,
|
||||
// or a job that failed to start. The page says so rather than leaving Run dead.
|
||||
submit: (args) => manager.handleRunFormSubmit(adopted.toolCallId, args),
|
||||
@@ -620,6 +635,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
/** Drop the adoption, leaving the page as the plain deployed page it also is. Called when
|
||||
* the call settles by any route: Run, Cancel, a stopped turn, the tab closing. */
|
||||
function dropAdoption(toolCallId: string): void {
|
||||
heldPresses.delete(toolCallId)
|
||||
for (const [key, adopted] of [...adoptedRuns]) {
|
||||
if (adopted.toolCallId === toolCallId) adoptedRuns.delete(key)
|
||||
}
|
||||
@@ -707,6 +723,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => {
|
||||
const runHref = `${base}/run/${jobId}?workspace=${workspace}`
|
||||
// Either surface the call was waiting on becomes the run it started, in place.
|
||||
heldPresses.delete(toolCallId)
|
||||
for (const [key, adopted] of [...adoptedRuns]) {
|
||||
if (adopted.toolCallId !== toolCallId) continue
|
||||
adoptedRuns.delete(key)
|
||||
|
||||
Reference in New Issue
Block a user