feat: run a deployed script from an AI session through an argument form

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-03 16:30:08 +02:00
co-authored by Claude Opus 5
parent 38fc0d3a12
commit 2ec77e349a
15 changed files with 906 additions and 51 deletions
@@ -132,6 +132,10 @@ export async function runEval<THelpers, TOutput>(
setToolStatus: () => {},
removeToolStatus: () => {},
isPlanModeActive,
// Accepts the run form exactly as the model prefilled it: there is nobody here to
// edit the arguments, so a case can assert what the model proposed but never how
// it reacts to the user changing something.
requestRunArgs: async (_toolId, form) => form.args,
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
@@ -86,6 +86,7 @@ vi.mock('$lib/gen', async () => {
previewBenchmarkSchedule,
runBenchmarkDatatableSql,
runBenchmarkFlowByPath,
runBenchmarkScriptByPath,
runBenchmarkScriptPreview,
updateBenchmarkDraft,
listBenchmarkMcpTools
@@ -279,6 +280,18 @@ vi.mock('$lib/gen', async () => {
}
return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody })
},
runScriptByPath: async (data: {
workspace: string
path: string
requestBody?: Record<string, unknown>
}) =>
hasBenchmarkWorkspace(data.workspace)
? runBenchmarkScriptByPath({
workspace: data.workspace,
path: data.path,
args: data.requestBody
})
: actual.JobService.runScriptByPath(data),
runFlowByPath: async (data: {
workspace: string
path: string
+34
View File
@@ -1974,6 +1974,40 @@
judgeChecklist:
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
- id: global-test33-run-deployed-script-with-form
prompt: |-
Run the deployed script `f/evals/global/format_greeting` for me with the name "ada".
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 8
# run_script is offered to session chats only.
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- run_script
forbiddenToolsUsed:
- test_run_script
- call_api_endpoint
- write_script
- deploy_workspace_item
# run_script renders the deployed schema itself, so reading the item first is a
# wasted round-trip.
- read_workspace_item
# An empty form pushes the work back onto the user, so the prefill is part of
# what the tool is for.
toolCallArgs:
- tool: run_script
field: args.name
stringIncludesAnyOf:
- ada
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
skipJudge: true
judgeChecklist:
- runs the deployed script through run_script rather than a preview test run or a raw API endpoint
- passes the name "ada" so the confirmation form comes up prefilled
- id: global-undo-created-draft
prompt: |-
Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with.
+9 -29
View File
@@ -24,6 +24,7 @@
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
import { processSecretArgs } from './secretArgUtils'
import { enforceDisabledDefaults } from './job_args'
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
let reloadArgs = $state(0)
@@ -60,11 +61,14 @@
export async function run(overrideScheduledForStr?: string | undefined | null) {
let processedArgs: Record<string, any>
try {
processedArgs = await processSecretArgs(
enforceDisabledDefaults(args ?? {}, true),
runnable?.schema
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
if (resetKeys.length > 0) {
sendUserToast(
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
)
}
try {
processedArgs = await processSecretArgs(withDefaults, runnable?.schema)
} catch (e) {
sendUserToast('Failed to process sensitive args: ' + e, true)
return
@@ -178,30 +182,6 @@
}
}
function enforceDisabledDefaults(
args: Record<string, any>,
notify: boolean = false
): Record<string, any> {
const schema = runnable?.schema
if (!schema?.properties) return args
const result = { ...args }
const resetKeys: string[] = []
for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
if (prop?.disabled && 'default' in prop) {
if (notify && result[key] !== prop.default) {
resetKeys.push(key)
}
result[key] = prop.default
}
}
if (resetKeys.length > 0) {
sendUserToast(
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
)
}
return result
}
/** Rewrite the open JSON editor from the current args. Only for args replaced from outside
* the editor: entering the JSON view already starts from whatever `args` holds. */
export function syncJsonEditor() {
@@ -322,7 +302,7 @@
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
args = enforceDisabledDefaults(e.detail)
args = enforceDisabledDefaults(e.detail, runnable?.schema).args
}
}}
initialCode={argsToJsonPayload(runnable.schema, args)}
@@ -224,6 +224,15 @@
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
if (!focusOnChat) return
// The run form parks the loop on the user, so an Escape aimed at one of its
// fields must not discard what they typed. Only the fields: from its buttons
// Escape still stops the turn, which is the way out while a submit is in flight.
if (
active?.closest('[data-chat-keyboard-scope="run-args-form"]') &&
active.matches('input, textarea, select, [contenteditable]')
) {
return
}
e.preventDefault()
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
@@ -23,6 +23,7 @@ import {
type ToolCallbacks,
type ToolDisplayMessage,
type UserQuestionDisplay,
type RunFormDisplay,
type ChatJob,
type ChatJobInit,
type ChatJobStatus,
@@ -682,6 +683,7 @@ export class AIChatManager {
{ resolve: (value: boolean) => void; toolName?: string }
>()
private userQuestionCallbacks = new Map<string, (choices: string[] | undefined) => void>()
private runFormCallbacks = new Map<string, (args: Record<string, any> | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
@@ -1787,6 +1789,67 @@ export class AIChatManager {
return true
}
requestRunArgs = (
toolId: string,
_form: RunFormDisplay
): Promise<Record<string, any> | undefined> => {
return new Promise((resolve) => {
this.runFormCallbacks.set(toolId, resolve)
})
}
// A form restored from history has no callback: the loop that opened it is gone.
isRunFormPending = (toolId: string): boolean => this.runFormCallbacks.has(toolId)
/** False when the form is no longer pending, so the caller can say so instead of
* leaving its submit button spinning on a run that will never start. */
handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => {
const callback = this.runFormCallbacks.get(toolId)
if (!callback) {
return false
}
// Only the flag: the card's `parameters` already records what ran, and a second
// copy of the arguments in the transcript is one more place a file argument's
// base64 lands in IndexedDB.
this.#patchRunForm(toolId, { submitted: true })
callback(args)
this.runFormCallbacks.delete(toolId)
return true
}
handleRunFormCancel = (toolId: string) => {
const callback = this.runFormCallbacks.get(toolId)
// Settled here rather than only in the tool's fn, which a form restored from
// history no longer has: Cancel is that card's one way out, and while it stays
// active the whole session reads as needs-confirmation (getSessionChatStatus
// asks pendingUserAction before loading). Clearing isLoading is part of
// settling — canceled alone unmounts the form but leaves the card shimmering.
this.displayMessages = this.displayMessages.map((message) =>
message.role === 'tool' && message.tool_call_id === toolId && message.runForm
? {
...message,
isLoading: false,
error: 'Cancelled by user',
content: `Run of "${message.runForm.path}" cancelled by user`,
runForm: { ...message.runForm, canceled: true }
}
: message
)
if (!callback) {
return
}
callback(undefined)
this.runFormCallbacks.delete(toolId)
}
#patchRunForm = (toolId: string, patch: Partial<RunFormDisplay>) => {
this.displayMessages = this.displayMessages.map((message) =>
message.role === 'tool' && message.tool_call_id === toolId && message.runForm
? { ...message, runForm: { ...message.runForm, ...patch } }
: message
)
}
setAiChatInput(aiChatInput: AIChatInput | null) {
this.aiChatInput = aiChatInput
}
@@ -3698,6 +3761,7 @@ export class AIChatManager {
isPlanModeActive: () => this.planModeActive,
onToolBlockedByPlanMode: this.planMode.noteBlockedTool,
requestUserQuestion: this.requestUserQuestion,
requestRunArgs: this.requestRunArgs,
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path),
@@ -3954,6 +4018,10 @@ export class AIChatManager {
resolveQuestion(undefined)
}
this.userQuestionCallbacks.clear()
for (const resolveRunArgs of this.runFormCallbacks.values()) {
resolveRunArgs(undefined)
}
this.runFormCallbacks.clear()
const cancelReason = reason ?? USER_CANCEL_REASON
console.log('cancelling request:', {
reason: cancelReason,
@@ -4523,6 +4591,9 @@ export class AIChatManager {
): DisplayMessage[] =>
messages.map((message) => {
if (message.role === 'tool' && (message.isLoading || message.isQueued)) {
// Stopping the turn does not stop the job: once the form was submitted the
// script is running for real, so the card must not claim it was canceled.
const ranAlready = message.runForm?.submitted === true
return {
...message,
isLoading: false,
@@ -4532,15 +4603,22 @@ export class AIChatManager {
// and a card that hides its result as still-streaming.
needsConfirmation: false,
isStreamingArguments: false,
// A question's card disappears once canceled, so keep the question
// itself readable in the collapsed header.
// An interactive card disappears once canceled, so keep what it was
// asking readable in the collapsed header.
content: message.userQuestion
? `Asked: ${message.userQuestion.question}${messageText}`
: messageText,
error: messageText,
: message.runForm
? ranAlready
? `Run ${message.runForm.path} — started, stopped tracking before it finished`
: `Run ${message.runForm.path}${messageText}`
: messageText,
// A started run keeps whatever the job reported: it is not this turn's
// error, and the jobs tray is still following it.
...(ranAlready ? {} : { error: messageText }),
userQuestion: message.userQuestion
? { ...message.userQuestion, canceled: true }
: undefined
: undefined,
runForm: message.runForm ? { ...message.runForm, canceled: !ranAlready } : undefined
}
}
return message
@@ -226,6 +226,56 @@ describe('AIChatManager unmounted-chat guard', () => {
})
})
describe('AIChatManager run form', () => {
// A transcript can be persisted mid-turn (a background job's status write) and
// restored into a fresh manager, which has none of the turn's callbacks. Cancel is
// then the card's only exit, and until it settles pendingUserAction keeps the whole
// session reading as needs-confirmation.
it('settles a restored form whose callback is gone', async () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_r',
content: 'Waiting for you to confirm the arguments of "f/a/b"',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {} }
}
]
expect(manager.isRunFormPending('call_r')).toBe(false)
manager.handleRunFormCancel('call_r')
const { pendingUserAction } = await import('./shared')
const settled = manager.displayMessages[0]
expect(settled.runForm?.canceled).toBe(true)
expect(settled.isLoading).toBe(false)
expect(pendingUserAction(manager.displayMessages)).toBe(undefined)
})
// Stop ends the turn, not the job: the deployed script is already running with all
// its side effects, so the transcript must not record it as cancelled.
it('does not mark a submitted run cancelled when the turn is stopped', () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_s',
content: 'Running "f/a/b"...',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true }
}
]
manager.cancelLoadingTools()
const settled = manager.displayMessages[0]
expect(settled.runForm?.canceled).toBe(false)
expect(settled.error).toBe(undefined)
expect(settled.isLoading).toBe(false)
})
})
describe('AIChatManager.sendOrQueue', () => {
// The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no
// composer to enforce the composer's rule for them: a second loop on one manager
@@ -0,0 +1,141 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import { Play, X } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import { conformArgsToSchema } from '$lib/components/job_args'
import { sendUserToast } from '$lib/utils'
import { getAiChatManager } from './aiChatManagerContext'
import type { RunFormDisplay } from './shared'
// Never the imported singleton: submitting has to resolve the pending callback of
// the manager that opened this form, which in a session is a per-pane one.
const aiChatManager = getAiChatManager()
interface Props {
toolCallId: string
runForm: RunFormDisplay
}
let { toolCallId, runForm }: Props = $props()
// The chat's workspace, not the globally-active one: a session may be acting on a
// fork, and that is where the job runs — so the pickers and the ephemeral secret
// variables have to resolve there too.
const workspace = $derived(aiChatManager.operatingWorkspace)
const properties = $derived(runForm.schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
// Deep copy, not a spread: runForm comes off displayMessages ($state), so its nested
// values are proxies that $state() hands back untouched. SchemaForm edits objects and
// arrays in place, so a shallow copy would write every keystroke — a password typed
// into a nested field included — straight into the persisted transcript.
let args = $state($state.snapshot(runForm.args ?? {}) as Record<string, any>)
let isValid = $state(true)
let submitting = $state(false)
let cardNode = $state<HTMLDivElement | undefined>()
onMount(() => {
void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' }))
})
async function run() {
if (submitting || !isValid) return
// Before processSecretArgs, not after: a card restored from history outlives the
// manager that opened it, so submitting would mint an ephemeral secret variable
// per click and still run nothing.
if (!aiChatManager.isRunFormPending(toolCallId)) {
sendUserToast('This run form is no longer active — ask again to run the script.', true)
return
}
submitting = true
let processed: Record<string, any>
try {
processed = await processSecretArgs(
// Last gate before the job: what the card showed is what runs, conformed the
// same way the prefill was.
conformArgsToSchema(args ?? {}, runForm.schema).args,
runForm.schema as any,
workspace
)
} catch (e) {
submitting = false
sendUserToast('Failed to process sensitive args: ' + e, true)
return
}
// The callback can still go away across the processSecretArgs round trip, and by
// then the ephemeral variables exist — say so rather than leaving a dead button.
if (!aiChatManager.handleRunFormSubmit(toolCallId, processed)) {
submitting = false
sendUserToast('This run form is no longer active — ask again to run the script.', true)
}
}
</script>
<!-- scroll-mb clears the chat's sticky "Waiting for your input" chip so the mount
scrollIntoView leaves the Run button uncovered. -->
<div
bind:this={cardNode}
class="scroll-mb-8 rounded-md border border-border-light bg-surface p-3"
data-chat-keyboard-scope="run-args-form"
>
<div class="flex items-start gap-2">
<Play class="h-4 w-4 shrink-0 text-accent" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-emphasis">
Run {runForm.summary || runForm.path}
</p>
{#if runForm.summary}
<p class="truncate font-mono text-2xs text-secondary">{runForm.path}</p>
{/if}
</div>
</div>
<div class="mt-3">
{#if hasArgs}
<SchemaForm
schema={runForm.schema}
helperScript={{ source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
{workspace}
prettifyHeader
lightHeader
bind:isValid
bind:args
/>
{:else}
<p class="text-xs text-secondary">This script takes no arguments.</p>
{/if}
{#if runForm.droppedKeys?.length}
<p class="mt-2 text-2xs text-secondary">
Not an input of this script, so it will not be sent:
<span class="font-mono">{runForm.droppedKeys.join(', ')}</span>
</p>
{/if}
</div>
<!-- Both buttons rest while a submit is in flight: the ephemeral variables exist by
then, so cancelling would settle the call as declined on a run that is already
starting. Escape from a field still stops the turn. -->
<div class="mt-3 flex items-center gap-2">
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Play }}
disabled={!isValid || submitting}
onClick={run}
>
Run
</Button>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: X }}
disabled={submitting}
onClick={() => aiChatManager.handleRunFormCancel(toolCallId)}
>
Cancel
</Button>
</div>
</div>
@@ -24,7 +24,7 @@
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import { isActiveRunForm, isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
@@ -37,6 +37,7 @@
import ToolMessageActions from './ToolMessageActions.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
@@ -117,6 +118,8 @@
isActiveUserQuestion(message) ? message.userQuestion : undefined
)
const activeRunForm = $derived(isActiveRunForm(message) ? message.runForm : undefined)
// The preview chip sits on the header row (to the right of the tool-call text);
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
const showPreviewChip = $derived(
@@ -128,6 +131,8 @@
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else if activeRunForm}
<RunArgsFormDisplay toolCallId={message.tool_call_id} runForm={activeRunForm} />
{:else if message.blockedByPlanMode}
<!-- Not an error card: the call did what plan mode says it should. One flat row
naming the refused tool, so "why can't it edit" is answered where it is asked. -->
@@ -82,6 +82,7 @@ vi.mock('$lib/gen', async () => {
runScriptPreview: vi.fn(async () => 'job-script-preview'),
runFlowPreview: vi.fn(async () => 'job-flow-preview'),
runFlowByPath: vi.fn(async () => 'job-flow-by-path'),
runScriptByPath: vi.fn(async () => 'job-script-by-path'),
getJob: vi.fn(async () => ({
type: 'CompletedJob',
success: true,
@@ -4633,6 +4634,187 @@ describe('global AI tools', () => {
})
})
// The form IS the consent, so a dismissed one must leave the script unrun.
it('run_script starts no job when the user cancels the form', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/deployed',
summary: 'Deployed',
schema: { properties: { name: { type: 'string' } } }
} as any)
const result = await callGlobalTool(
'run_script',
{ path: 'f/scripts/deployed', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async () => undefined }
)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('The user cancelled the run form')
expect(result).toContain('Do not call run_script again')
})
// The card is a consent surface: an argument it cannot show is an argument the user
// never approved, so the prefill is conformed to the schema before the form opens.
it('run_script drops a proposed argument the schema does not declare', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/noargs',
summary: 'Takes nothing',
schema: { properties: {} }
} as any)
let shown: Record<string, any> | undefined
let dropped: string[] | undefined
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/noargs', args: { force_delete: true } },
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
shown = form.args
dropped = form.droppedKeys
return form.args
}
}
)
)
expect(shown).toEqual({})
// Named on the card and to the model: a silent drop makes the user approve a run
// they think carries force_delete.
expect(dropped).toEqual(['force_delete'])
expect(result).toContain('force_delete')
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/noargs',
requestBody: {}
})
})
// A secret the model picked is not consent, and a result that echoed one back would let
// it propose the same value again on the next call.
it('run_script opens password fields empty and hides secrets from the model', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/rotate',
schema: {
properties: {
token: { type: 'string', password: true },
nested: {
type: 'object',
properties: { inner: { type: 'string', password: true } }
},
name: { type: 'string' }
}
}
} as any)
let shown: Record<string, any> | undefined
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{
path: 'f/scripts/rotate',
args: {
token: 'hunter2',
nested: { inner: '$var:u/ada/prod_api_key' },
name: 'ada'
}
},
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
shown = form.args
return { ...form.args, token: '$var:u/ada/secret_arg/typed' }
}
}
)
)
expect(shown).toEqual({ nested: {}, name: 'ada' })
expect(result).not.toContain('hunter2')
expect(result).not.toContain('secret_arg')
expect(result).not.toContain('prod_api_key')
expect(result).toContain('ada')
})
// The form is its own confirmation, so it never reaches processToolCall's second gate.
// Plan mode can be switched on while it sits open, and the job must not start.
it('run_script starts no job when plan mode is entered while the form is open', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/noargs',
schema: { properties: {} }
} as any)
let planning = false
const result = await callGlobalTool(
'run_script',
{ path: 'f/scripts/noargs', args: {} },
{
...toolCallbacks,
isPlanModeActive: () => planning,
requestRunArgs: async (_toolId, form) => {
planning = true
return form.args
}
}
)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('plan mode is active')
})
it('run_script prefills a locked field with its default, not the proposed value', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/locked',
schema: {
properties: {
locked: { type: 'string', default: 'fixed', disabled: true },
other: { type: 'string' }
}
}
} as any)
let shown: Record<string, any> | undefined
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/locked', args: { locked: 'tampered', other: 'hello' } },
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
shown = form.args
return form.args
}
}
)
)
expect(shown).toEqual({ locked: 'fixed', other: 'hello' })
})
it('run_script runs the arguments the user submitted, not the ones proposed', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/greet',
schema: { properties: { name: { type: 'string' } } }
} as any)
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/greet', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) }
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/greet',
requestBody: { name: 'Grace' }
})
// The model must not assume its proposal is what ran.
expect(result).toContain('Ran with arguments: {"name":"Grace"}')
})
it('test_run_step lists nested step ids when a step is not found', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/nested-step-error',
@@ -5323,6 +5505,9 @@ describe('session-only preview tools gating', () => {
expect(names).not.toContain('list_app_runs')
expect(names).not.toContain('search_dom')
expect(names).not.toContain('read_dom')
// Withheld for its own reason: the side-panel chat cannot render the argument
// form the tool blocks on.
expect(names).not.toContain('run_script')
// other tools are still present
expect(names).toContain('write_script')
})
@@ -5335,6 +5520,7 @@ describe('session-only preview tools gating', () => {
expect(names).toContain('list_app_runs')
expect(names).toContain('search_dom')
expect(names).toContain('read_dom')
expect(names).toContain('run_script')
// The session set is the full globalTools minus capability-gated tools:
// this environment is not Chromium, so take_screenshot is withheld (DOM
// capture is only faithful on Blink). search_dom / read_dom are not gated.
@@ -47,6 +47,8 @@ import {
STARTER_RUNNABLE_KEY,
type FrameworkKey
} from '$lib/components/raw_apps/templates'
import { conformArgsToSchema, redactSecretArgs, stripSecretArgs } from '$lib/components/job_args'
import { PLAN_MODE_MESSAGES } from '../planModeMessages'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
@@ -120,6 +122,7 @@ import {
isHubPath,
type CreatedResourceTriggerKind,
type PreviewCardKind,
type RunFormDisplay,
type Tool,
type ToolCallbacks,
type ToolDisplayAction
@@ -893,6 +896,18 @@ const testRunScriptToolDef = createToolDef(
{ strict: false }
)
const runScriptSchema = z.object({
path: z.string().describe('Workspace path of the deployed script to run.'),
args: testRunArgsSchema
})
const runScriptToolDef = createToolDef(
runScriptSchema,
'run_script',
"Run a DEPLOYED script for real, under the user's own permissions. The user always gets an argument form prefilled with `args` and decides what actually runs, so fill in every argument you can infer rather than leaving the form empty. Use this when the user asks to run or execute something; use test_run_script instead to try out a script you are writing.",
{ strict: false }
)
const testRunFlowSchema = z.object({
path: z.string().describe('Workspace path of the flow to test.'),
args: testRunArgsSchema,
@@ -1322,7 +1337,11 @@ ${pipelineBullet}
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools use the draft tools and delete_workspace_item instead.
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step they run the draft.
- ${
previewTools
? 'To run a DEPLOYED script for real (the user asks to run/execute something that already exists), use run_script: it shows them an argument form prefilled with what you pass, and they submit it. Fill in every argument you can infer from the conversation — an empty form makes them do the work. runScriptByPath and runFlowByPath from the API catalog also run the deployed item, but without a form: reach for them only for a flow, or when the user explicitly asks for a deployed run you cannot route through run_script — for those, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs)'
: 'runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs)'
}. To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step they run the draft.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
- Keep context targeted.${
@@ -3645,6 +3664,19 @@ export const globalTools: Tool<{}>[] = [
showDetails: true,
autoCollapseDetails: false
},
{
def: runScriptToolDef,
fn: async (ctx) => {
const parsed = runScriptSchema.parse(ctx.args)
return runDeployedScript(parsed, ctx)
},
// No requiresConfirmation: the argument form is the confirmation, and unlike a
// yes/no card it must not be auto-accepted away by YOLO.
streamingLabel: 'Preparing the run form...',
queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`,
showDetails: true,
autoCollapseDetails: false
},
{
def: testRunFlowToolDef,
fn: async (ctx) => {
@@ -4280,15 +4312,23 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
'list_artifact_versions'
])
// Withheld from the side-panel chat: the argument form is a blocking card, and
// sessions are the only surface that renders one.
const SESSION_ONLY_TOOL_NAMES = new Set(['run_script'])
/**
* The global tool set for a given chat: the full `globalTools` for a session
* chat, or `globalTools` minus the session-only preview tools for the regular
* global side-panel chat.
* chat, or `globalTools` minus the session-only tools for the regular global
* side-panel chat.
*/
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
const tools = sessionPreview
? globalTools
: globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name))
: globalTools.filter(
(t) =>
!SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name) &&
!SESSION_ONLY_TOOL_NAMES.has(t.def.function.name)
)
// DOM capture re-renders the app through the engine's SVG-image path, which is
// only faithful on Blink — Gecko/WebKit shift text spacing and wrapping (font
// fallback, sub-pixel rounding). Elsewhere the tool is withheld entirely and
@@ -5396,6 +5436,102 @@ async function testRunScriptByPath(
})
}
/** The "do not call again" half is load-bearing: without it the model re-proposes the
* call, which re-opens the form the user just dismissed, and Stop becomes their only
* way out. */
const RUN_FORM_CANCELLED =
'The user cancelled the run form. The script did NOT run. Do not call run_script again unless the user asks for it.'
/** The model only needs to see what the user changed, and a form can carry a base64
* file argument unbounded, this would eat the window with one attachment. */
const MAX_SUBMITTED_ARGS_LENGTH = 4000
async function runDeployedScript(
args: z.infer<typeof runScriptSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
if (!toolCallbacks.requestRunArgs) {
return 'This chat cannot show a run form, so a deployed script cannot be run from here.'
}
// No getDraft: this runs the script as it is live, so the form has to offer the
// inputs the live version accepts and not a draft's.
const script = await ScriptService.getScriptByPath({ workspace, path: args.path })
const schema = (script.schema as Record<string, any>) ?? {}
const conformed = conformArgsToSchema(normalizeTestRunArgs(args.args), schema)
// A secret the model picked is not consent, whatever it holds: a literal is a value
// the user never chose, a reference names something the card cannot show them.
const proposed = stripSecretArgs(conformed.args, schema as any)
const form: RunFormDisplay = {
path: args.path,
summary: script.summary || undefined,
schema,
args: proposed,
droppedKeys: conformed.droppedKeys.length ? conformed.droppedKeys : undefined
}
toolCallbacks.setToolStatus(toolId, {
content: `Waiting for you to confirm the arguments of "${args.path}"`,
runForm: form,
isLoading: true
})
const submitted = await toolCallbacks.requestRunArgs(toolId, form)
if (!submitted) {
toolCallbacks.setToolStatus(toolId, {
content: `Run of "${args.path}" cancelled by user`,
isLoading: false,
isStreamingArguments: false,
error: 'Cancelled by user',
declinedByUser: true
})
return RUN_FORM_CANCELLED
}
// processToolCall re-gates plan mode after a standard confirmation, because it can be
// entered while a card is pending. This form is its own confirmation and never reaches
// that gate, so it repeats it here.
if (toolCallbacks.isPlanModeActive?.()) {
toolCallbacks.onToolBlockedByPlanMode?.()
toolCallbacks.setToolStatus(toolId, {
content: PLAN_MODE_MESSAGES.blockedLabel,
isLoading: false,
isStreamingArguments: false,
error: PLAN_MODE_MESSAGES.blockedResult,
blockedByPlanMode: true
})
return PLAN_MODE_MESSAGES.blockedResult
}
// The card's details pane must show what ran, not what was proposed.
toolCallbacks.setToolStatus(toolId, { parameters: submitted })
const outcome = await executeTestRun({
jobStarter: () =>
JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted }),
workspace,
toolCallbacks,
toolId,
startMessage: `Running "${args.path}"...`,
contextName: 'script',
actionNoun: 'run',
label: args.path
})
const dropped = conformed.droppedKeys.length
? `\nThe deployed schema declares no ${conformed.droppedKeys.join(', ')}, so the form never offered ${conformed.droppedKeys.length > 1 ? 'them' : 'it'} and the run did not carry ${conformed.droppedKeys.length > 1 ? 'them' : 'it'}.`
: ''
// Redacted: a variable path is enough to run a job on a value the model cannot read,
// and one shown a path proposes it back on the next call.
const submittedJson = JSON.stringify(redactSecretArgs(submitted, schema as any))
const shown =
submittedJson.length > MAX_SUBMITTED_ARGS_LENGTH
? submittedJson.slice(0, MAX_SUBMITTED_ARGS_LENGTH) + '... (truncated)'
: submittedJson
return `Ran with arguments: ${shown}${dropped}\n${outcome}`
}
async function testRunFlowByPath(
args: z.infer<typeof testRunFlowSchema>,
ctx: WriteDraftCtx
@@ -945,6 +945,16 @@ describe('processToolCall', () => {
{ requestConfirmation: vi.fn().mockResolvedValue(false) }
)
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
// A tool that runs its own consent surface (the run form) declines by settling
// the card, then returns normally — that must not read as a successful run.
expect(
await outcomeKeys({
fn: vi.fn(async ({ toolCallbacks, toolId }: any) => {
toolCallbacks.setToolStatus(toolId, { declinedByUser: true })
return 'cancelled'
})
})
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([
['ai_chat', 'tool', 'run_script:blocked_plan_mode']
])
@@ -1272,6 +1282,28 @@ describe('isActiveUserQuestion', () => {
})
})
describe('isActiveRunForm', () => {
const runForm = { path: 'f/a/b', schema: {}, args: { name: 'ada' } }
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
return {
role: 'tool',
tool_call_id: 'call_r',
content: 'waiting for arguments',
isLoading: true,
runForm,
...overrides
}
}
// Either flag unmounts the card, so the loop must stop waiting on it.
it('is false once submitted or cancelled', async () => {
const { isActiveRunForm } = await import('./shared')
expect(isActiveRunForm(toolMessage())).toBe(true)
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, submitted: true } }))).toBe(false)
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, canceled: true } }))).toBe(false)
})
})
describe('pendingUserAction', () => {
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
role: 'tool',
@@ -1289,6 +1321,15 @@ describe('pendingUserAction', () => {
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
})
// A run form asks for arguments rather than a yes/no, but it blocks the loop the
// same way, so the chat reports it as a confirmation.
it('reports an unsubmitted run form as a confirmation', async () => {
const { pendingUserAction } = await import('./shared')
expect(
pendingUserAction([toolMessage({ runForm: { path: 'f/a/b', schema: {}, args: {} } })])
).toBe('confirmation')
})
it('is undefined for a tool the AI is running on its own', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([toolMessage()])).toBe(undefined)
@@ -553,6 +553,24 @@ export function answeredChoices(q: UserQuestionDisplay): string[] | undefined {
return q.selectedChoices ?? (q.selectedChoice ? [q.selectedChoice] : undefined)
}
/** Argument form for a deployed-script run, persisted with the transcript every
* field has to stay plain JSON. */
export type RunFormDisplay = {
path: string
summary?: string
/** Of the DEPLOYED script, not a draft. */
schema: Record<string, any>
/** Prefill only: the card's `parameters` records what the job started with. */
args: Record<string, any>
/** Proposed arguments the schema does not declare, so they have no field. Named on
* the card: approving a run is not consent to something it never showed. */
droppedKeys?: string[]
/** Either one unmounts the form, so set exactly one, and only once the loop has
* stopped waiting on this card. */
submitted?: boolean
canceled?: boolean
}
/** One page hit from a provider-side web search (OpenAI sources carry no title). */
export type WebSearchSource = {
url: string
@@ -578,6 +596,7 @@ export type ToolDisplayMessage = {
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
runForm?: RunFormDisplay
webSearchSources?: WebSearchSource[]
/** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */
imageUrl?: string
@@ -653,6 +672,18 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
)
}
export function isActiveRunForm(message: DisplayMessage | undefined): boolean {
return Boolean(
message &&
message.role === 'tool' &&
message.runForm &&
message.isLoading &&
!message.error &&
!message.runForm.submitted &&
!message.runForm.canceled
)
}
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
// staged for confirmation. The manager stays `loading` through both, so anything
// rendering progress must ask here first or it reports "the AI is working".
@@ -676,6 +707,12 @@ export function pendingUserActionDetail(
if (isActiveUserQuestion(message)) {
return { action: 'question', toolCallId: message.tool_call_id }
}
// A run form is a confirmation carrying arguments, not a question: it parks the
// turn the same way, but Run or Cancel resolves it and typing never does — so it
// must not claim the answer affordance a pending question offers.
if (isActiveRunForm(message)) {
return { action: 'confirmation', toolCallId: message.tool_call_id }
}
if (message.needsConfirmation && message.isLoading) {
return { action: 'confirmation', toolCallId: message.tool_call_id }
}
@@ -958,6 +995,9 @@ export async function processToolCall<T>({
}
let result = ''
// A tool that asks for consent itself settles the call as declined or blocked and
// returns normally, so without this both telemeter as successful runs.
let settledInsideTool: 'declined' | 'blocked_plan_mode' | undefined = undefined
try {
result = await callTool({
tools,
@@ -965,10 +1005,17 @@ export async function processToolCall<T>({
args,
workspace: workspaceId,
helpers,
toolCallbacks,
toolCallbacks: {
...toolCallbacks,
setToolStatus: (toolId, status) => {
if (status?.declinedByUser) settledInsideTool = 'declined'
else if (status?.blockedByPlanMode) settledInsideTool = 'blocked_plan_mode'
toolCallbacks.setToolStatus(toolId, status)
}
},
toolId: toolCall.id
})
logToolOutcome('ok')
logToolOutcome(settledInsideTool ?? 'ok')
toolCallbacks.setToolStatus(toolCall.id, {
isLoading: false,
isStreamingArguments: false
@@ -1229,6 +1276,12 @@ export interface ToolCallbacks {
toolId: string,
question: UserQuestionDisplay
) => Promise<string[] | undefined>
/** Park the loop on an argument form and resolve with the args the user submitted,
* or undefined if they cancelled. Wired only where the form can be rendered. */
requestRunArgs?: (
toolId: string,
form: RunFormDisplay
) => Promise<Record<string, any> | undefined>
/** Records a workspace item the tool call created/edited/deleted, by its
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
@@ -1517,13 +1570,16 @@ export interface TestRunConfig {
detachAfterMs?: number
/** Human label for the jobs tray row (path / step id). Defaults to the job id. */
label?: string
/** Overrides the default "test started, waiting for completion" status while the
/** Overrides the default "started, waiting for completion" status while the
* job runs inline (e.g. an SQL tool shows "SQL running…"). */
runningMessage?: string
/** Noun for the human-facing status strings ("<X> test completed successfully").
* Defaults to `contextName`, which also carries the jobs-tray kind and so cannot
* always name what ran: an app's path runnable queues a flow job. */
/** The item noun in the human-facing status strings ("Flow test completed
* successfully"). Defaults to `contextName`, which also carries the jobs-tray kind
* and so cannot always name what ran: an app's path runnable queues a flow job. */
completionName?: string
/** The action noun in those same strings ("Script run completed successfully").
* Defaults to "test", so a tool running the deployed item for real passes "run". */
actionNoun?: string
/** Custom terminal formatting for the INLINE completion path (callers whose
* result isn't a plain test-run summary, e.g. exec_datatable_sql shaping rows).
* Returns the string handed to the model plus the tool-card patch. When omitted,
@@ -1731,12 +1787,15 @@ export function backgroundJobCompletionNote(
)
}
// Main execution function for test runs
export async function executeTestRun(config: TestRunConfig): Promise<string> {
// Detach-into-background is enabled only when the host wired the job hooks
// (global/sessions chat). Otherwise this stays a blocking call.
const detachEnabled = !!config.toolCallbacks.onJobStarted
const label = config.label ?? config.contextName
const actionNoun = config.actionNoun ?? 'test'
// Stands on its own where the status strings are prefixed by the item, so its
// default carries the noun.
const failureNoun = config.actionNoun ?? 'test run'
try {
config.toolCallbacks.setToolStatus(config.toolId, {
content: config.startMessage || `Starting ${config.contextName} test...`
@@ -1761,7 +1820,8 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
})
config.toolCallbacks.setToolStatus(config.toolId, {
content: config.runningMessage ?? `${contextName} test started, waiting for completion...`
content:
config.runningMessage ?? `${contextName} ${actionNoun} started, waiting for completion...`
})
const outcome = await pollJobCompletion(
@@ -1781,7 +1841,7 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
if (outcome === 'detached') {
config.toolCallbacks.onJobDetached?.(jobId)
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${contextName} test running in background (job ${jobId})`
content: `${contextName} ${actionNoun} running in background (job ${jobId})`
})
return backgroundedSummary(jobId, label)
}
@@ -1800,7 +1860,7 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
}
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${contextName} test ${job.success ? 'completed successfully' : 'failed'}`,
content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`,
result: formatResult(job.result),
logs: formatLogs(job.logs),
...(job.success ? {} : { error: getErrorMessage(job.result) })
@@ -1823,10 +1883,10 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
// the flow it could not find — losing the one diagnostic the run exists for.
const errorMessage = formatToolError(error)
config.toolCallbacks.setToolStatus(config.toolId, {
content: `Test execution failed`,
content: `Execution failed`,
error: errorMessage
})
throw new Error(`Failed to execute test run: ${errorMessage}`)
throw new Error(`Failed to execute ${failureNoun}: ${errorMessage}`)
}
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { conformArgsToSchema } from './job_args'
describe('conformArgsToSchema', () => {
it('drops what the schema does not declare, prototype names included', () => {
const { args, droppedKeys } = conformArgsToSchema(
JSON.parse('{"keep":1,"force":true,"constructor":"x","toString":"y","__proto__":{"p":1}}'),
{ properties: { keep: { type: 'number' } } }
)
expect(args).toEqual({ keep: 1 })
expect(Object.getPrototypeOf(args)).toBe(Object.prototype)
expect(droppedKeys.sort()).toEqual(['__proto__', 'constructor', 'force', 'toString'])
})
})
+104
View File
@@ -1,5 +1,109 @@
import { deepEqual } from 'fast-equals'
/**
* A field the schema disables is not the caller's to set: whatever it holds, the run
* sends the schema's default. Returns the keys it actually overwrote so the caller can
* say so notifying is the caller's job, this stays pure.
*
* Top-level properties only; a disabled field nested in an object is not normalized.
*/
export function enforceDisabledDefaults(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): { args: Record<string, any>; resetKeys: string[] } {
if (!schema?.properties) return { args, resetKeys: [] }
const result = { ...args }
const resetKeys: string[] = []
for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
if (!prop?.disabled || !('default' in prop)) continue
if (result[key] !== prop.default) resetKeys.push(key)
result[key] = prop.default
}
return { args: result, resetKeys }
}
/**
* Conform caller-supplied arguments to what a run form can actually show: drop what the
* schema does not declare, then apply {@link enforceDisabledDefaults}. An argument with
* no field including every argument of a script whose schema declares none would
* otherwise be approved without ever being seen.
*/
export function conformArgsToSchema(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): { args: Record<string, any>; resetKeys: string[]; droppedKeys: string[] } {
const properties = schema?.properties ?? {}
const known: Record<string, any> = {}
const droppedKeys: string[] = []
for (const [key, value] of Object.entries(args ?? {})) {
// hasOwn, not `in`: every object inherits `constructor`, `toString` and
// `__proto__`, so `in` would wave through arguments no schema declares —
// and assigning `__proto__` would mutate the accumulator instead of it.
if (Object.hasOwn(properties, key)) {
known[key] = value
} else {
droppedKeys.push(key)
}
}
const { args: result, resetKeys } = enforceDisabledDefaults(known, schema)
return { args: result, resetKeys, droppedKeys }
}
/**
* Rebuild `holder` with `visit` applied to every password-typed argument the schema
* declares, at any depth; returning `undefined` removes that argument.
*
* Recursive because the form is: `ArgInput` renders a nested `SchemaForm` for any object
* property declaring properties of its own. Password props inside `items` or a `oneOf`
* branch mount through a different path and are not reached a known limit.
*/
function mapSecretArgs(
holder: any,
properties: Record<string, any>,
visit: (value: unknown) => unknown
): any {
if (holder == null || typeof holder !== 'object' || Array.isArray(holder)) return holder
const result = { ...holder }
for (const [key, prop] of Object.entries<any>(properties)) {
// A password-typed object is a leaf, not a level: it is stored whole as one
// $jsonvar: reference rather than field by field.
if (prop?.password) {
const mapped = visit(result[key])
if (mapped === undefined) delete result[key]
else result[key] = mapped
} else if (prop?.properties) {
result[key] = mapSecretArgs(result[key], prop.properties, visit)
}
}
return result
}
/**
* Drop every password-typed argument, so a caller cannot propose a secret on the user's
* behalf: password fields open empty and the user fills them in.
*/
export function stripSecretArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): Record<string, any> {
const properties = schema?.properties
if (!properties) return args
return mapSecretArgs(args, properties, () => undefined)
}
/**
* Replace every password-typed argument with a fixed marker, for text that leaves the
* form. A reference is enough to run a job on something the reader cannot see.
*/
export function redactSecretArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): Record<string, any> {
const properties = schema?.properties
if (!properties) return args
return mapSecretArgs(args, properties, (value) => (value == null ? undefined : '<hidden>'))
}
export function isWindmillTooBigObject(obj: any): boolean {
return (
typeof obj === 'object' &&