mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 00:02:42 +00:00
* fix: disable a dynamic input when its schema field is disabled Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * refactor: extract the run form's argument hygiene into job_args Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: give Tabs an opt-in sliding selection indicator Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * refactor: share the chat's scroll-fade measurement Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: give the chat a run-form contract and incremental job output Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: run and test a script from the chat through an argument form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: carry a chat run's card and job across saves and reloads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: render a chat run as a tool call row with its form, logs and result Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: open a pending run form in the sessions preview pane Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * test: benchmark running a deployed script from the chat Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh * feat: offer a test run's dynamic options from the draft it previews Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: say that a test run's dynselect helper executes on form display * fix: send a schema default the model omitted when yolo skips the form * fix: infer a test run's schema when the stored one declares no properties * docs: tighten the note on the form's mount-time helper job * fix: apply a nested schema default the bypass posture counts as answered * fix: apply a declared default to a null value and an optional nested field * fix: check required fields inside a supplied optional object before bypassing * fix: read required args as own properties before bypassing the form * fix: stop the turn from the run form's action row in the preview panel * refactor: drop the run-form prediction and share its secret minting * refactor: prefill a proposed secret instead of emptying the field * docs: correct the comments the run-form prediction left behind * fix: keep a proposed secret out of the chat's stored messages * docs: say what a literal secret argument now does * test: restore the copilotInfo export the aiStore mock omits * docs: cut the run form's helper-script note to its constraints * refactor: settle a run form from one entry and fetch a job's logs once * fix: separate colliding secret paths, gate plan mode, keep polled logs * fix: mint before the form opens, skip empty fields, show what ran * revert: mint a run form's secrets at submit, not before it opens * fix: settle a cancelled run card on the form's arguments, not the proposal * fix: settle a stopped run form like a cancelled one, and keep an empty secret empty * fix: snapshot a run's arguments before minting its secrets --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
/**
|
|
* A job's arguments prepared for a run form, its readers, and a result view. Coercing must
|
|
* not lose what the caller meant to send, so it is exact and shallow; stripping and
|
|
* redacting only blank a field, so they go to any depth and err towards visiting too much.
|
|
*/
|
|
import { deepEqual } from 'fast-equals'
|
|
|
|
const isLockedProp = (prop: any) => !!prop?.disabled && 'default' in prop
|
|
|
|
/**
|
|
* A field the schema disables is not the caller's to set: the run sends the schema's
|
|
* default whatever it holds. Top-level only, like every filter here. Returns the keys it
|
|
* overwrote; notifying is the caller's job.
|
|
*/
|
|
export function enforceDisabledDefaults(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined
|
|
): { args: Record<string, any>; resetKeys: string[] } {
|
|
// Null prototype: assigning a declared `__proto__` into a plain `{}` reaches the
|
|
// inherited setter and the default vanishes. Always copied — callers bind the result to
|
|
// a form that edits in place, so returning the input would write through to theirs.
|
|
const result: Record<string, any> = Object.assign(Object.create(null), args)
|
|
if (!schema?.properties) return { args: { ...result }, resetKeys: [] }
|
|
const resetKeys: string[] = []
|
|
for (const [key, prop] of Object.entries<any>(schema.properties)) {
|
|
if (!isLockedProp(prop)) continue
|
|
// Never supplied is not overwritten, and compared by value: a default can be an
|
|
// object, where identity would report every correct run as overridden.
|
|
if (result[key] !== undefined && !deepEqual(result[key], prop.default)) resetKeys.push(key)
|
|
result[key] = prop.default
|
|
}
|
|
return { args: { ...result }, resetKeys }
|
|
}
|
|
|
|
/** How a form says what {@link enforceDisabledDefaults} overwrote, shared by the two that
|
|
* run it so the wording cannot drift apart. */
|
|
export const resetKeysToast = (resetKeys: string[]): string =>
|
|
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys
|
|
.map((k) => `'${k}'`)
|
|
.join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
|
|
|
|
/** Types `setInputCat` routes to a widget bound to a scalar. */
|
|
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean'])
|
|
|
|
/**
|
|
* Declares an array though its `type` says `object`. A mismatch here throws rather than
|
|
* reading wrong: `MultiSelect` maps over the value as it renders, so anything else takes
|
|
* the form down, Cancel with it — a reference included, since it draws before resolving.
|
|
*/
|
|
const declaresDynMultiselect = (prop: any) =>
|
|
typeof prop?.format === 'string' && prop.format.startsWith('dynmultiselect-')
|
|
|
|
const fitsScalarType = (value: any, type: string): boolean =>
|
|
type === 'integer' ? typeof value === 'number' : typeof value === type
|
|
|
|
/**
|
|
* Resolved at run time, so the declared type describes what the job receives and never the
|
|
* string standing in for it. `ArgInput.validateInput` blesses these ahead of every type check.
|
|
*/
|
|
const REFERENCE_PREFIXES = ['$var:', '$res:', '$jsonvar:']
|
|
const isReference = (value: any): boolean =>
|
|
typeof value === 'string' && REFERENCE_PREFIXES.some((prefix) => value.startsWith(prefix))
|
|
|
|
/** No plain reading in the declared type; distinct from a value that reads as `undefined`. */
|
|
const UNCOERCIBLE = Symbol('uncoercible')
|
|
|
|
/**
|
|
* The value a scalar widget would stand for, or {@link UNCOERCIBLE}. Only conversions with
|
|
* one plain reading: a number input shows `"7"` as 7 and a toggle shows any non-empty
|
|
* string as on, so guessing past this would put a value on screen that nobody wrote.
|
|
*/
|
|
function coerceScalar(value: any, type: string): any {
|
|
if (typeof value === 'object') return UNCOERCIBLE
|
|
if (type === 'string') {
|
|
return typeof value === 'number' || typeof value === 'boolean' ? String(value) : UNCOERCIBLE
|
|
}
|
|
if (typeof value !== 'string') return UNCOERCIBLE
|
|
const trimmed = value.trim()
|
|
if (type === 'number' || type === 'integer') {
|
|
if (trimmed === '') return UNCOERCIBLE
|
|
const parsed = Number(trimmed)
|
|
return Number.isFinite(parsed) ? parsed : UNCOERCIBLE
|
|
}
|
|
if (type === 'boolean') {
|
|
if (trimmed.toLowerCase() === 'true') return true
|
|
if (trimmed.toLowerCase() === 'false') return false
|
|
}
|
|
return UNCOERCIBLE
|
|
}
|
|
|
|
/**
|
|
* Drop every argument the schema does not declare, naming them. The schema is what every run
|
|
* surface builds its fields from, so a value under a name it never declares has no widget
|
|
* anywhere: sending one is sending what nobody could see or edit before the run.
|
|
*/
|
|
export function dropUndeclaredArgs(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined
|
|
): { args: Record<string, any>; undeclaredKeys: string[] } {
|
|
const properties = schema?.properties ?? {}
|
|
// hasOwn, not `in`: every object inherits `constructor` and `toString`, so `in` would
|
|
// hand an inherited declaration to an argument the schema never named.
|
|
const kept: Record<string, any> = Object.create(null)
|
|
const undeclaredKeys: string[] = []
|
|
for (const [key, value] of Object.entries(args ?? {})) {
|
|
if (Object.hasOwn(properties, key)) kept[key] = value
|
|
else undeclaredKeys.push(key)
|
|
}
|
|
return { args: { ...kept }, undeclaredKeys }
|
|
}
|
|
|
|
/**
|
|
* Make arguments say what the run form will show, then apply {@link enforceDisabledDefaults}.
|
|
* A scalar widget renders its own reading of a wrong-typed value and never writes it back, so
|
|
* an untouched form would submit what it never displayed; a value with no reading is cleared.
|
|
* Top-level only: descending means resolving `oneOf`, where being wrong rewrites user input.
|
|
*/
|
|
export function coerceArgsToSchema(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined
|
|
): {
|
|
args: Record<string, any>
|
|
resetKeys: string[]
|
|
clearedKeys: string[]
|
|
undeclaredKeys: string[]
|
|
} {
|
|
const properties = schema?.properties ?? {}
|
|
const clearedKeys: string[] = []
|
|
const { args: declared, undeclaredKeys } = dropUndeclaredArgs(args, schema)
|
|
const kept: Record<string, any> = Object.create(null)
|
|
for (const [key, value] of Object.entries(declared)) {
|
|
// Declared, but a declaration can still be nothing, and reading `.type` off it throws.
|
|
const prop = properties[key]
|
|
if (
|
|
prop === undefined ||
|
|
value == null ||
|
|
(isReference(value) && !declaresDynMultiselect(prop))
|
|
) {
|
|
kept[key] = value
|
|
continue
|
|
}
|
|
if (declaresDynMultiselect(prop)) {
|
|
if (Array.isArray(value)) kept[key] = value
|
|
else clearedKeys.push(key)
|
|
continue
|
|
}
|
|
if (!SCALAR_TYPES.has(prop.type) || fitsScalarType(value, prop.type)) {
|
|
kept[key] = value
|
|
continue
|
|
}
|
|
const coerced = coerceScalar(value, prop.type)
|
|
if (coerced === UNCOERCIBLE) clearedKeys.push(key)
|
|
else kept[key] = coerced
|
|
}
|
|
const { args: result, resetKeys } = enforceDisabledDefaults({ ...kept }, schema)
|
|
return { args: result, resetKeys, clearedKeys, undeclaredKeys }
|
|
}
|
|
|
|
/**
|
|
* Every bag of `properties` a declaration can show a value's keys through, including every
|
|
* `oneOf` branch rather than the selected one: a secret under a variant nobody opened
|
|
* leaves the form just the same.
|
|
*/
|
|
function declarationBags(prop: any): Record<string, any>[] {
|
|
const bags: Record<string, any>[] = []
|
|
if (prop?.properties) bags.push(prop.properties)
|
|
if (Array.isArray(prop?.oneOf))
|
|
for (const branch of prop.oneOf) if (branch?.properties) bags.push(branch.properties)
|
|
return bags
|
|
}
|
|
|
|
/**
|
|
* Apply `visit` to every value whose declaration matches `isLeaf`, at any depth; returning
|
|
* `undefined` removes it. Recursive because the form is, so a level left unvisited is one a
|
|
* secret can sit at. Descends on the value's shape, never on the declaration's keys: one
|
|
* carrying both `items` and `properties` must not route a shape down the other's branch.
|
|
*/
|
|
function mapLeaves(
|
|
value: any,
|
|
prop: any,
|
|
isLeaf: (prop: any) => boolean,
|
|
visit: (value: unknown, prop: any, path: (string | number)[]) => unknown,
|
|
path: (string | number)[]
|
|
): any {
|
|
if (value == null || typeof value !== 'object') return value
|
|
// A container shaped unlike its declaration is kept rather than dropped, since the widget
|
|
// is the one that reports it — so the walk has to reach in through whichever half the
|
|
// declaration does carry, or a secret under one leaves the form verbatim.
|
|
if (Array.isArray(value))
|
|
return value.map((item, i) => mapLeaves(item, prop?.items ?? prop, isLeaf, visit, [...path, i]))
|
|
const bags = declarationBags(prop)
|
|
if (bags.length === 0)
|
|
return prop?.items ? mapLeaves(value, prop.items, isLeaf, visit, path) : value
|
|
// Null prototype, and keyed off the value rather than the declaration: a key is only
|
|
// ever rewritten where it already exists, so no branch of a `oneOf` can add one.
|
|
const result: Record<string, any> = Object.assign(Object.create(null), value)
|
|
for (const key of Object.keys(result)) {
|
|
const declared = bags.filter((bag) => Object.hasOwn(bag, key)).map((bag) => bag[key])
|
|
// Segments, never a joined name: a key can itself hold a dot, and two leaves reported
|
|
// under one name let a caller correlating by it take the one for the other.
|
|
const keyPath = [...path, key]
|
|
// A matching object is a leaf, not a level: a password object is stored whole as a
|
|
// single $jsonvar: reference, and a file is one opaque base64 string.
|
|
const leaf = declared.find(isLeaf)
|
|
if (leaf) {
|
|
const mapped = visit(result[key], leaf, keyPath)
|
|
if (mapped === undefined) delete result[key]
|
|
else result[key] = mapped
|
|
continue
|
|
}
|
|
for (const declaration of declared)
|
|
result[key] = mapLeaves(result[key], declaration, isLeaf, visit, keyPath)
|
|
}
|
|
return { ...result }
|
|
}
|
|
|
|
export const isSecretProp = (prop: any) => !!prop?.password
|
|
|
|
const isFileProp = (prop: any) =>
|
|
prop?.contentEncoding === 'base64' || prop?.items?.contentEncoding === 'base64'
|
|
|
|
function fileMarker(base64: string): string {
|
|
const bytes = Math.floor((base64.length * 3) / 4)
|
|
return bytes < 1024 * 1024
|
|
? `<file: ${Math.max(1, Math.round(bytes / 1024))} KB>`
|
|
: `<file: ${(bytes / 1024 / 1024).toFixed(1)} MB>`
|
|
}
|
|
|
|
/** {@link mapLeaves} over a whole argument object, against a schema that may declare
|
|
* nothing to match. Copies either way, for the reason {@link enforceDisabledDefaults}
|
|
* copies. */
|
|
export function mapArgLeaves(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined,
|
|
isLeaf: (prop: any) => boolean,
|
|
visit: (value: unknown, prop: any, path: (string | number)[]) => unknown
|
|
): Record<string, any> {
|
|
return mapLeaves(args ?? {}, { properties: schema?.properties ?? {} }, isLeaf, visit, [])
|
|
}
|
|
|
|
/** A leaf's path as the lines naming it to a reader read: `creds[0].secret`. */
|
|
const formatArgPath = (path: (string | number)[]): string =>
|
|
path.reduce<string>(
|
|
(acc, segment) =>
|
|
typeof segment === 'number'
|
|
? `${acc}[${segment}]`
|
|
: acc
|
|
? `${acc}.${segment}`
|
|
: String(segment),
|
|
''
|
|
)
|
|
|
|
/**
|
|
* Drop every file argument, so a caller cannot propose file bytes on the user's behalf:
|
|
* the field opens empty and the user attaches the file. Bytes a form is prefilled with
|
|
* are bytes the stored transcript carries, unbounded, for a value no caller can produce.
|
|
* Reports the path of each one removed, or the caller reads the absence as the user having
|
|
* deleted the value.
|
|
*/
|
|
export function stripFileArgs(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined,
|
|
strippedKeys?: string[]
|
|
): Record<string, any> {
|
|
return mapArgLeaves(args, schema, isFileProp, (value, _prop, path) => {
|
|
if (value !== undefined) strippedKeys?.push(formatArgPath(path))
|
|
return undefined
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Replace a sensitive value with a fixed marker, for text that leaves the form. A reference is
|
|
* kept: it names a variable rather than holding one, and the run page shows the same job's
|
|
* arguments that way. An empty field is kept for the reason `processSecretArgs` mints nothing
|
|
* for one — marking it would describe a secret the run never carried.
|
|
*/
|
|
export function redactSecretArgs(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined
|
|
): Record<string, any> {
|
|
return mapArgLeaves(args, schema, isSecretProp, (value) =>
|
|
value == null ? undefined : value === '' || isReference(value) ? value : '<hidden>'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Replace every file argument with a marker naming its size. The base64 belongs in the
|
|
* job request and nowhere else: rendered it is unreadable, persisted it is unbounded, and
|
|
* a file small enough to survive truncation reaches the model whole.
|
|
*/
|
|
export function redactFileArgs(
|
|
args: Record<string, any>,
|
|
schema: { properties?: Record<string, any> } | undefined
|
|
): Record<string, any> {
|
|
const mark = (value: unknown) => (typeof value === 'string' ? fileMarker(value) : value)
|
|
return mapArgLeaves(args, schema, isFileProp, (value) =>
|
|
Array.isArray(value) ? value.map(mark) : mark(value)
|
|
)
|
|
}
|
|
|
|
export function isWindmillTooBigObject(obj: any): boolean {
|
|
return (
|
|
typeof obj === 'object' &&
|
|
deepEqual(Object.keys(obj), ['reason']) &&
|
|
obj['reason'] == 'WINDMILL_TOO_BIG'
|
|
)
|
|
}
|