mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat(ai-chat): add set_script/flow/app_metadata tools
The global AI chat could create/edit script/flow/app content but had almost no way to edit metadata: write_script covers 4 fields, write_flow 6, apps only summary. There was no way to change an existing item's description, labels, worker tag, concurrency, cache TTL, script kind, or an app's execution mode without a full content rewrite. Add set_script_metadata, set_flow_metadata, and set_app_metadata. Each is a flat per-type tool whose fields are optional and nullable (omit = leave, null = clear, value = set), funneled through the existing writeScriptDraft/writeFlowDraft specs (apps via loadAppDraftValue + saveAppDraft). Sensitive app changes (execution_mode -> anonymous, custom_path) prompt for confirmation before saving. read_workspace_item now surfaces the editable metadata so the AI sees current values before editing (read shape == write shape via an ItemMetadata carrier). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1383,9 +1383,7 @@ describe('global AI tools', () => {
|
||||
file_path: '/min.tsx'
|
||||
})
|
||||
|
||||
expect(result).toContain(
|
||||
'lines 1-3 of 3, truncated to the first 50000 of 90002 chars.'
|
||||
)
|
||||
expect(result).toContain('lines 1-3 of 3, truncated to the first 50000 of 90002 chars.')
|
||||
expect(result).toContain('the file is likely minified')
|
||||
expect(result.split('\n\n')[1]).toHaveLength(50_000)
|
||||
})
|
||||
@@ -1401,9 +1399,7 @@ describe('global AI tools', () => {
|
||||
file_path: '/generated.js'
|
||||
})
|
||||
|
||||
expect(result).toContain(
|
||||
'lines 1-1 of 1, truncated to the first 50000 of 60000 chars.'
|
||||
)
|
||||
expect(result).toContain('lines 1-1 of 1, truncated to the first 50000 of 60000 chars.')
|
||||
expect(result).toContain('re-read with a smaller limit')
|
||||
expect(result.split('\n\n')[1]).toBe('x'.repeat(50_000))
|
||||
})
|
||||
@@ -1488,8 +1484,7 @@ describe('global AI tools', () => {
|
||||
versions: [5],
|
||||
value: {
|
||||
files: {
|
||||
'/lib/aggregations.ts':
|
||||
'export function computeRevenue(o) {\n return o.unitPrice\n}\n',
|
||||
'/lib/aggregations.ts': 'export function computeRevenue(o) {\n return o.unitPrice\n}\n',
|
||||
'/components/SummaryPanel.tsx':
|
||||
'import { computeRevenue } from "../lib/aggregations"\nconst total = computeRevenue(order)\n',
|
||||
'/components/OrdersTable.tsx': 'const r = computeRevenue(row)\n// renders revenue\n',
|
||||
@@ -2475,6 +2470,197 @@ describe('global AI tools', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('set_*_metadata tools', () => {
|
||||
it('set_script_metadata merges provided fields, clears with null, preserves content', async () => {
|
||||
seedBackendDraft('script', 'f/s/meta', {
|
||||
path: 'f/s/meta',
|
||||
summary: 'orig summary',
|
||||
description: 'orig desc',
|
||||
content: 'export async function main() {}',
|
||||
schema: {},
|
||||
is_template: false,
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
tag: 'old-tag'
|
||||
})
|
||||
|
||||
await callGlobalTool('set_script_metadata', {
|
||||
path: 'f/s/meta',
|
||||
description: 'new desc',
|
||||
priority: 5,
|
||||
tag: null
|
||||
})
|
||||
|
||||
const draft = getBackendDraft<any>('script', 'f/s/meta')
|
||||
expect(draft).toMatchObject({
|
||||
summary: 'orig summary', // omitted -> unchanged
|
||||
description: 'new desc', // set
|
||||
priority: 5, // set
|
||||
kind: 'script', // untouched
|
||||
content: 'export async function main() {}' // content preserved
|
||||
})
|
||||
expect(draft.tag).toBeUndefined() // null -> cleared
|
||||
})
|
||||
|
||||
it('set_flow_metadata routes wrapper vs value-level fields correctly', async () => {
|
||||
seedBackendDraft('flow', 'f/f/meta', {
|
||||
path: 'f/f/meta',
|
||||
summary: 'flow summary',
|
||||
value: { modules: [], priority: 1 },
|
||||
schema: {},
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
tag: 'old'
|
||||
})
|
||||
|
||||
await callGlobalTool('set_flow_metadata', {
|
||||
path: 'f/f/meta',
|
||||
description: 'doc',
|
||||
tag: 'highmem',
|
||||
priority: 9,
|
||||
concurrent_limit: 3
|
||||
})
|
||||
|
||||
const draft = getBackendDraft<any>('flow', 'f/f/meta')
|
||||
expect(draft).toMatchObject({ description: 'doc', tag: 'highmem', summary: 'flow summary' })
|
||||
// priority/concurrency live inside value, not on the Flow wrapper.
|
||||
expect(draft.value).toMatchObject({ priority: 9, concurrent_limit: 3 })
|
||||
})
|
||||
|
||||
it('set_app_metadata applies a non-sensitive summary edit without confirmation', async () => {
|
||||
seedBackendDraft('raw_app', 'f/a/meta', {
|
||||
summary: 'app summary',
|
||||
files: {},
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'publisher' }
|
||||
})
|
||||
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestUserQuestion: vi.fn()
|
||||
}
|
||||
await callGlobalTool('set_app_metadata', { path: 'f/a/meta', summary: 'updated' }, callbacks)
|
||||
|
||||
expect(callbacks.requestUserQuestion).not.toHaveBeenCalled()
|
||||
expect(getBackendDraft<any>('raw_app', 'f/a/meta').summary).toBe('updated')
|
||||
})
|
||||
|
||||
it('set_app_metadata confirms before making an app anonymous and applies on accept', async () => {
|
||||
seedBackendDraft('raw_app', 'f/a/pub', {
|
||||
summary: 'app',
|
||||
files: {},
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'publisher' }
|
||||
})
|
||||
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestUserQuestion: vi.fn(async () => 'Yes, proceed')
|
||||
}
|
||||
await callGlobalTool(
|
||||
'set_app_metadata',
|
||||
{ path: 'f/a/pub', execution_mode: 'anonymous' },
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(callbacks.requestUserQuestion).toHaveBeenCalled()
|
||||
expect(getBackendDraft<any>('raw_app', 'f/a/pub').policy.execution_mode).toBe('anonymous')
|
||||
})
|
||||
|
||||
it('set_app_metadata aborts the anonymous change when the user declines', async () => {
|
||||
seedBackendDraft('raw_app', 'f/a/decl', {
|
||||
summary: 'app',
|
||||
files: {},
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'publisher' }
|
||||
})
|
||||
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestUserQuestion: vi.fn(async () => 'No, cancel')
|
||||
}
|
||||
const result = await callGlobalTool(
|
||||
'set_app_metadata',
|
||||
{ path: 'f/a/decl', execution_mode: 'anonymous' },
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(JSON.parse(result)).toMatchObject({ success: false })
|
||||
expect(getBackendDraft<any>('raw_app', 'f/a/decl').policy.execution_mode).toBe('publisher') // unchanged
|
||||
})
|
||||
|
||||
it('set_app_metadata confirms before changing custom_path', async () => {
|
||||
seedBackendDraft('raw_app', 'f/a/cp', {
|
||||
summary: 'app',
|
||||
files: {},
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'publisher' }
|
||||
})
|
||||
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestUserQuestion: vi.fn(async () => 'Yes, proceed')
|
||||
}
|
||||
await callGlobalTool(
|
||||
'set_app_metadata',
|
||||
{ path: 'f/a/cp', custom_path: 'reports' },
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(callbacks.requestUserQuestion).toHaveBeenCalled()
|
||||
expect(getBackendDraft<any>('raw_app', 'f/a/cp').custom_path).toBe('reports')
|
||||
})
|
||||
|
||||
it('set_app_metadata fails safe on a sensitive change when no question callback is wired', async () => {
|
||||
seedBackendDraft('raw_app', 'f/a/noq', {
|
||||
summary: 'app',
|
||||
files: {},
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'publisher' }
|
||||
})
|
||||
|
||||
// No requestUserQuestion on the callbacks: a sensitive change must abort, not save silently.
|
||||
const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
|
||||
const result = await callGlobalTool(
|
||||
'set_app_metadata',
|
||||
{ path: 'f/a/noq', execution_mode: 'anonymous' },
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(JSON.parse(result)).toMatchObject({ success: false })
|
||||
expect(getBackendDraft<any>('raw_app', 'f/a/noq').policy.execution_mode).toBe('publisher') // unchanged
|
||||
})
|
||||
|
||||
it('surfaces editable metadata in read_workspace_item for a script', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/s/read',
|
||||
summary: 's',
|
||||
description: 'the description',
|
||||
language: 'bun',
|
||||
content: 'x',
|
||||
kind: 'trigger',
|
||||
tag: 'nginx',
|
||||
priority: 7,
|
||||
labels: ['a']
|
||||
} as any)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', { type: 'script', path: 'f/s/read' })
|
||||
expect(JSON.parse(raw).metadata).toMatchObject({
|
||||
description: 'the description',
|
||||
kind: 'trigger',
|
||||
tag: 'nginx',
|
||||
priority: 7,
|
||||
labels: ['a']
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalSystemMessage', () => {
|
||||
|
||||
@@ -86,10 +86,14 @@ import {
|
||||
variableRequestSchema
|
||||
} from '../workspaceToolsZod.gen'
|
||||
import {
|
||||
appItemMetadata,
|
||||
flowItemMetadata,
|
||||
getWorkspaceItemKey,
|
||||
scriptItemMetadata,
|
||||
TRIGGER_KINDS,
|
||||
type AppDraftValue,
|
||||
type FlowDraftValue,
|
||||
type ItemMetadata,
|
||||
type ResourceDraftState,
|
||||
type TriggerKind,
|
||||
type TriggerRequestBody,
|
||||
@@ -678,6 +682,134 @@ const initAppSchema = z.object({
|
||||
)
|
||||
})
|
||||
|
||||
// Metadata edit tools. Every editable field is optional and nullable: omit to
|
||||
// leave it unchanged, pass null to clear it, pass a value to set it. Schemas are
|
||||
// flat (no unions/$ref) so Gemini's tool API accepts them — see the note above
|
||||
// writeFlowSchema.
|
||||
const CLEAR_HINT = 'Pass null to clear, omit to leave unchanged.'
|
||||
|
||||
const setScriptMetadataSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the script to edit.'),
|
||||
summary: z.string().nullable().optional().describe(`Short human-readable summary. ${CLEAR_HINT}`),
|
||||
description: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Longer documentation for the script. ${CLEAR_HINT}`),
|
||||
labels: z
|
||||
.array(z.string())
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Workspace labels. Replaces the whole list. ${CLEAR_HINT}`),
|
||||
kind: z
|
||||
.enum(['script', 'failure', 'trigger', 'command', 'approval', 'preprocessor'])
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Script role in the workspace. ${CLEAR_HINT}`),
|
||||
tag: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`Worker tag — routes runs to a specific worker group (which may hold privileged secrets). Confirm with the user if unsure. ${CLEAR_HINT}`
|
||||
),
|
||||
priority: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Execution priority; higher runs first. ${CLEAR_HINT}`),
|
||||
timeout: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Max execution time in seconds. ${CLEAR_HINT}`),
|
||||
concurrent_limit: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Max concurrent executions. ${CLEAR_HINT}`),
|
||||
concurrency_time_window_s: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Concurrency time window in seconds. ${CLEAR_HINT}`),
|
||||
cache_ttl: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Cache the result for this many seconds. ${CLEAR_HINT}`)
|
||||
})
|
||||
|
||||
const setFlowMetadataSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the flow to edit.'),
|
||||
summary: z.string().nullable().optional().describe(`Short human-readable summary. ${CLEAR_HINT}`),
|
||||
description: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Longer documentation for the flow. ${CLEAR_HINT}`),
|
||||
labels: z
|
||||
.array(z.string())
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Workspace labels. Replaces the whole list. ${CLEAR_HINT}`),
|
||||
tag: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`Worker tag — routes runs to a specific worker group (which may hold privileged secrets). Confirm with the user if unsure. ${CLEAR_HINT}`
|
||||
),
|
||||
priority: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Execution priority; higher runs first. ${CLEAR_HINT}`),
|
||||
timeout: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Max execution time in seconds. ${CLEAR_HINT}`),
|
||||
concurrent_limit: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Max concurrent executions. ${CLEAR_HINT}`),
|
||||
concurrency_time_window_s: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Concurrency time window in seconds. ${CLEAR_HINT}`),
|
||||
cache_ttl: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Cache the result for this many seconds. ${CLEAR_HINT}`)
|
||||
})
|
||||
|
||||
const setAppMetadataSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the app to edit.'),
|
||||
summary: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(`Short human-readable summary of the app. ${CLEAR_HINT}`),
|
||||
execution_mode: z
|
||||
.enum(['viewer', 'publisher', 'anonymous'])
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`Who the app runs as: "viewer"/"publisher" run with the viewer's/publisher's permissions; "anonymous" makes the app PUBLICLY runnable by anyone. Setting "anonymous" requires user confirmation. ${CLEAR_HINT}`
|
||||
),
|
||||
custom_path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`Custom URL path for the app (admin-only; changes require user confirmation). ${CLEAR_HINT}`
|
||||
)
|
||||
})
|
||||
|
||||
const buildGlobalSystemPrompt = (
|
||||
username: string,
|
||||
previewTools: boolean
|
||||
@@ -767,6 +899,7 @@ function scriptToItem(script: Script | NewScript, includeValue: boolean): Worksp
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
value: includeValue ? script.content : undefined,
|
||||
metadata: includeValue ? scriptItemMetadata(script) : undefined,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
@@ -779,6 +912,7 @@ function flowToItem(flow: Flow, includeValue: boolean): WorkspaceItem {
|
||||
value: includeValue
|
||||
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
|
||||
: undefined,
|
||||
metadata: includeValue ? flowItemMetadata(flow) : undefined,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
@@ -810,6 +944,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
|
||||
type: 'app',
|
||||
path: item.path,
|
||||
summary: item.summary,
|
||||
metadata: item.metadata,
|
||||
value: summarizeAppValue(item.value as AppDraftValue),
|
||||
isDraft: item.isDraft
|
||||
}
|
||||
@@ -823,6 +958,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
|
||||
type: 'flow',
|
||||
path: item.path,
|
||||
summary: item.summary,
|
||||
metadata: item.metadata,
|
||||
value: editable,
|
||||
isDraft: item.isDraft
|
||||
}
|
||||
@@ -1287,12 +1423,13 @@ async function readWorkspaceItem(
|
||||
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const value = appSourceToDraftValue(app)
|
||||
const metadata = summarizeAppValue(value)
|
||||
const summarized = summarizeAppValue(value)
|
||||
return {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: value.summary,
|
||||
value: metadata as unknown as AppDraftValue,
|
||||
metadata: appItemMetadata(value),
|
||||
value: summarized as unknown as AppDraftValue,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
@@ -1388,9 +1525,10 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
|
||||
return `# Global draft script instructions
|
||||
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
|
||||
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, isDraft }\` where \`value\` is the source code string.
|
||||
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, metadata?, isDraft }\` where \`value\` is the source code string and \`metadata\` holds the editable non-content fields (description, labels, kind, tag, priority, timeout, concurrency, cache).
|
||||
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.
|
||||
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.${note}
|
||||
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.
|
||||
- To change metadata WITHOUT rewriting the code (summary, description, labels, kind, tag, priority, timeout, concurrency, cache_ttl), use \`set_script_metadata\`. It edits an existing draft/deployed script. Every field is optional: omit to leave unchanged, pass null to clear. A \`tag\` routes runs to a specific worker group — confirm with the user if unsure which tag to use.${note}
|
||||
|
||||
# Windmill script authoring reference (${selected})
|
||||
|
||||
@@ -1403,7 +1541,8 @@ function getFlowInstructions(): string {
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
|
||||
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
|
||||
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions.
|
||||
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
|
||||
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`, plus a \`metadata\` object with the flow's editable non-structure fields (description, labels, tag, priority, timeout, concurrency, cache).
|
||||
- To change flow metadata (summary, description, labels, tag, priority, timeout, concurrency, cache_ttl) WITHOUT rewriting structure, use \`set_flow_metadata\`. Every field is optional: omit to leave unchanged, pass null to clear. A \`tag\` routes runs to a specific worker group — confirm with the user if unsure which tag to use. (\`patch_flow_json\` edits flow structure, not these top-level fields.)
|
||||
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
|
||||
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
|
||||
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
|
||||
@@ -1415,7 +1554,7 @@ function getFlowInstructions(): string {
|
||||
- Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools:
|
||||
- \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module.
|
||||
- \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the draft.
|
||||
- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body.
|
||||
- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, per-module summaries, preprocessor/failure swaps, schema/groups. (The flow's own summary/description/tag/etc. are NOT in this view — edit them with \`set_flow_metadata\`.) Use \`set_flow_module_code\` for changes inside a specific rawscript body.
|
||||
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder).
|
||||
|
||||
# Windmill flow authoring reference
|
||||
@@ -1432,6 +1571,7 @@ function getAppInstructions(): string {
|
||||
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
|
||||
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
|
||||
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
|
||||
- To change an existing app's metadata (the file tools don't touch metadata), use \`set_app_metadata\`: \`summary\`, \`execution_mode\` (\`viewer\`/\`publisher\`/\`anonymous\`), and \`custom_path\`. Every field is optional: omit to leave unchanged, pass null to clear. Setting \`execution_mode\` to \`anonymous\` (makes the app publicly runnable) or changing \`custom_path\` (admin-only) prompts the user to confirm before saving.
|
||||
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
|
||||
- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).
|
||||
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.
|
||||
@@ -1968,6 +2108,48 @@ export const globalTools: Tool<{}>[] = [
|
||||
return initApp(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
setScriptMetadataSchema,
|
||||
'set_script_metadata',
|
||||
'Edit metadata (summary, description, labels, kind, tag, priority, timeout, concurrency, cache) of an existing draft/deployed script.',
|
||||
{ strict: false }
|
||||
),
|
||||
showDetails: true,
|
||||
showFade: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = setScriptMetadataSchema.parse(ctx.args)
|
||||
return setScriptMetadata(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
setFlowMetadataSchema,
|
||||
'set_flow_metadata',
|
||||
'Edit metadata (summary, description, labels, tag, priority, timeout, concurrency, cache) of an existing draft/deployed flow.',
|
||||
{ strict: false }
|
||||
),
|
||||
showDetails: true,
|
||||
showFade: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = setFlowMetadataSchema.parse(ctx.args)
|
||||
return setFlowMetadata(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
setAppMetadataSchema,
|
||||
'set_app_metadata',
|
||||
'Edit metadata (summary, execution_mode, custom_path) of an existing draft/deployed app.',
|
||||
{ strict: false }
|
||||
),
|
||||
showDetails: true,
|
||||
showFade: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = setAppMetadataSchema.parse(ctx.args)
|
||||
return setAppMetadata(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
readAppFileSchema,
|
||||
@@ -2533,11 +2715,72 @@ async function writeDraft<T, A>(
|
||||
return finishDraftWrite(result, existed, ctx)
|
||||
}
|
||||
|
||||
/** A partial metadata update where each field may be a value, `null` (clear), or absent (leave). */
|
||||
export type MetadataPatch = { [K in keyof ItemMetadata]?: ItemMetadata[K] | null }
|
||||
|
||||
/**
|
||||
* Tri-state merge for partial metadata edits: `undefined` keeps the existing
|
||||
* value, `null` clears it, any other value sets it.
|
||||
*/
|
||||
function applyMetaField<T>(provided: T | null | undefined, existing: T | undefined): T | undefined {
|
||||
if (provided === undefined) return existing
|
||||
if (provided === null) return undefined
|
||||
return provided
|
||||
}
|
||||
|
||||
/** Apply a metadata patch onto a script draft (top-level NewScript fields). */
|
||||
function applyScriptMetadataPatch(draft: NewScript, patch: MetadataPatch | undefined): NewScript {
|
||||
if (!patch) return draft
|
||||
return {
|
||||
...draft,
|
||||
description: applyMetaField(patch.description, draft.description),
|
||||
labels: applyMetaField(patch.labels, draft.labels),
|
||||
kind: applyMetaField(patch.kind, draft.kind),
|
||||
tag: applyMetaField(patch.tag, draft.tag),
|
||||
priority: applyMetaField(patch.priority, draft.priority),
|
||||
timeout: applyMetaField(patch.timeout, draft.timeout),
|
||||
concurrent_limit: applyMetaField(patch.concurrent_limit, draft.concurrent_limit),
|
||||
concurrency_time_window_s: applyMetaField(
|
||||
patch.concurrency_time_window_s,
|
||||
draft.concurrency_time_window_s
|
||||
),
|
||||
cache_ttl: applyMetaField(patch.cache_ttl, draft.cache_ttl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a metadata patch onto a flow draft. Wrapper fields
|
||||
* (description/tag/timeout/labels) apply on the `Flow` object; value-level
|
||||
* fields (priority/concurrency/cache_ttl) apply inside `value`.
|
||||
*/
|
||||
function applyFlowMetadataPatch(draft: Flow, patch: MetadataPatch | undefined): Flow {
|
||||
if (!patch) return draft
|
||||
const value = draft.value as FlowValue
|
||||
return {
|
||||
...draft,
|
||||
description: applyMetaField(patch.description, draft.description),
|
||||
tag: applyMetaField(patch.tag, draft.tag),
|
||||
timeout: applyMetaField(patch.timeout, draft.timeout),
|
||||
labels: applyMetaField(patch.labels, draft.labels),
|
||||
value: {
|
||||
...value,
|
||||
priority: applyMetaField(patch.priority, value.priority),
|
||||
concurrent_limit: applyMetaField(patch.concurrent_limit, value.concurrent_limit),
|
||||
concurrency_time_window_s: applyMetaField(
|
||||
patch.concurrency_time_window_s,
|
||||
value.concurrency_time_window_s
|
||||
),
|
||||
cache_ttl: applyMetaField(patch.cache_ttl, value.cache_ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptDraftArgs = {
|
||||
path: string
|
||||
summary?: string
|
||||
language: ScriptLang
|
||||
content: string
|
||||
metadata?: MetadataPatch
|
||||
override?: boolean
|
||||
}
|
||||
|
||||
@@ -2548,24 +2791,27 @@ const SCRIPT_SPEC: WriteSpec<NewScript, ScriptDraftArgs> = {
|
||||
return { ...(existing as unknown as NewScript), parent_hash: existing.hash }
|
||||
},
|
||||
buildDraft: (base, args, path) =>
|
||||
base
|
||||
? {
|
||||
...structuredClone(base),
|
||||
path,
|
||||
summary: args.summary ?? base.summary,
|
||||
content: args.content,
|
||||
language: args.language
|
||||
}
|
||||
: {
|
||||
path,
|
||||
summary: args.summary ?? '',
|
||||
description: '',
|
||||
content: args.content,
|
||||
schema: emptySchema(),
|
||||
is_template: false,
|
||||
language: args.language,
|
||||
kind: 'script'
|
||||
}
|
||||
applyScriptMetadataPatch(
|
||||
base
|
||||
? {
|
||||
...structuredClone(base),
|
||||
path,
|
||||
summary: args.summary ?? base.summary,
|
||||
content: args.content,
|
||||
language: args.language
|
||||
}
|
||||
: {
|
||||
path,
|
||||
summary: args.summary ?? '',
|
||||
description: '',
|
||||
content: args.content,
|
||||
schema: emptySchema(),
|
||||
is_template: false,
|
||||
language: args.language,
|
||||
kind: 'script'
|
||||
},
|
||||
args.metadata
|
||||
)
|
||||
}
|
||||
|
||||
function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise<string> {
|
||||
@@ -2577,6 +2823,7 @@ type FlowDraftArgs = {
|
||||
summary?: string
|
||||
description?: string
|
||||
flow: FlowDraftValue
|
||||
metadata?: MetadataPatch
|
||||
override?: boolean
|
||||
}
|
||||
|
||||
@@ -2588,26 +2835,29 @@ const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = {
|
||||
if (args.flow.groups !== undefined && args.flow.groups !== null) {
|
||||
value.groups = structuredClone(args.flow.groups)
|
||||
}
|
||||
return base
|
||||
? {
|
||||
...structuredClone(base),
|
||||
path,
|
||||
summary: args.summary ?? base.summary,
|
||||
description: args.description ?? base.description,
|
||||
value,
|
||||
schema: args.flow.schema ?? base.schema
|
||||
}
|
||||
: {
|
||||
path,
|
||||
summary: args.summary ?? '',
|
||||
description: args.description ?? '',
|
||||
value,
|
||||
schema: args.flow.schema ?? emptySchema(),
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
return applyFlowMetadataPatch(
|
||||
base
|
||||
? {
|
||||
...structuredClone(base),
|
||||
path,
|
||||
summary: args.summary ?? base.summary,
|
||||
description: args.description ?? base.description,
|
||||
value,
|
||||
schema: args.flow.schema ?? base.schema
|
||||
}
|
||||
: {
|
||||
path,
|
||||
summary: args.summary ?? '',
|
||||
description: args.description ?? '',
|
||||
value,
|
||||
schema: args.flow.schema ?? emptySchema(),
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
},
|
||||
args.metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2739,6 +2989,109 @@ async function loadFlowDraftValue(
|
||||
}
|
||||
}
|
||||
|
||||
async function setScriptMetadata(
|
||||
args: z.infer<typeof setScriptMetadataSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
// loadScriptForEdit throws if the script has neither a draft nor a deployed
|
||||
// version, so metadata edits never create an item from nothing.
|
||||
const base = await loadScriptForEdit(args.path, ctx.workspace)
|
||||
const { path, summary, ...metadata } = args
|
||||
return writeScriptDraft(
|
||||
{
|
||||
path,
|
||||
summary: summary === null ? '' : summary,
|
||||
language: base.language,
|
||||
content: base.content,
|
||||
metadata
|
||||
},
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
async function setFlowMetadata(
|
||||
args: z.infer<typeof setFlowMetadataSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
// loadFlowDraftValue throws if the flow doesn't exist (draft or deployed).
|
||||
const base = await loadFlowDraftValue(args.path, ctx.workspace)
|
||||
const { path, summary, ...metadata } = args
|
||||
return writeFlowDraft(
|
||||
{
|
||||
path,
|
||||
summary: summary === null ? '' : summary,
|
||||
flow: base.flow,
|
||||
metadata
|
||||
},
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
async function setAppMetadata(
|
||||
args: z.infer<typeof setAppMetadataSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Setting metadata on app "${args.path}"...`
|
||||
})
|
||||
|
||||
const { value } = await loadAppDraftValue(args.path, workspace)
|
||||
|
||||
// Sensitive changes need explicit user confirmation before saving: making the
|
||||
// app publicly runnable (execution_mode = anonymous) or changing its custom
|
||||
// URL path (admin-only). Other edits apply directly.
|
||||
const makesPublic =
|
||||
args.execution_mode === 'anonymous' && value.policy?.execution_mode !== 'anonymous'
|
||||
const changesCustomPath =
|
||||
args.custom_path !== undefined &&
|
||||
(args.custom_path ?? undefined) !== (value.custom_path ?? undefined)
|
||||
|
||||
if (makesPublic || changesCustomPath) {
|
||||
const question = makesPublic
|
||||
? `Make app "${args.path}" PUBLICLY runnable by anyone (execution_mode = anonymous)?`
|
||||
: `Change the custom URL path of app "${args.path}" to "${args.custom_path}"? (admin-only)`
|
||||
const userQuestion = { question, choices: ['Yes, proceed', 'No, cancel'] }
|
||||
if (!toolCallbacks.requestUserQuestion) {
|
||||
const message =
|
||||
'This change is sensitive and requires confirmation, but this chat cannot ask interactive questions.'
|
||||
toolCallbacks.setToolStatus(toolId, { content: message, isLoading: false, error: message })
|
||||
return JSON.stringify({ success: false, error: message })
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, { content: question, userQuestion, isLoading: true })
|
||||
const answer = await toolCallbacks.requestUserQuestion(toolId, userQuestion)
|
||||
if (answer !== 'Yes, proceed') {
|
||||
const message = `Cancelled: metadata of app "${args.path}" was not changed.`
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: message,
|
||||
userQuestion: { ...userQuestion, canceled: true },
|
||||
isLoading: false,
|
||||
error: message
|
||||
})
|
||||
return JSON.stringify({ success: false, error: message })
|
||||
}
|
||||
}
|
||||
|
||||
if (args.summary !== undefined) {
|
||||
value.summary = args.summary === null ? '' : args.summary
|
||||
}
|
||||
if (args.execution_mode !== undefined) {
|
||||
const policy = (value.policy ?? {}) as NonNullable<AppDraftValue['policy']>
|
||||
policy.execution_mode = args.execution_mode === null ? undefined : args.execution_mode
|
||||
value.policy = policy
|
||||
}
|
||||
if (args.custom_path !== undefined) {
|
||||
value.custom_path = args.custom_path === null ? undefined : args.custom_path
|
||||
}
|
||||
|
||||
const result = await saveAppDraft(workspace, args.path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Updated metadata on app "${args.path}"`,
|
||||
message: `Updated metadata of draft app "${args.path}".`
|
||||
}))
|
||||
}
|
||||
|
||||
async function patchFlowJson(
|
||||
args: { path: string; old_string: string; new_string: string; replace_all: boolean },
|
||||
ctx: WriteDraftCtx
|
||||
@@ -3253,7 +3606,9 @@ async function searchApp(
|
||||
const header = `${totalMatchCount} match${
|
||||
totalMatchCount === 1 ? '' : 'es'
|
||||
} in ${fileCount} file${fileCount === 1 ? '' : 's'}${
|
||||
truncated ? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)` : ''
|
||||
truncated
|
||||
? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)`
|
||||
: ''
|
||||
}`
|
||||
|
||||
const out: string[] = [header]
|
||||
|
||||
@@ -6,7 +6,10 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import {
|
||||
appItemMetadata,
|
||||
flowItemMetadata,
|
||||
getWorkspaceItemKey,
|
||||
scriptItemMetadata,
|
||||
type AppDraftValue,
|
||||
type ResourceDraftState,
|
||||
type TriggerKind,
|
||||
@@ -135,6 +138,7 @@ function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceIt
|
||||
summary: draft.summary,
|
||||
language: draft.language,
|
||||
value: draft.content,
|
||||
metadata: scriptItemMetadata(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
@@ -150,6 +154,7 @@ function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
|
||||
groups: draft.value.groups ?? null,
|
||||
description: draft.description ?? null
|
||||
},
|
||||
metadata: flowItemMetadata(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
@@ -161,6 +166,7 @@ function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceI
|
||||
path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
metadata: appItemMetadata(value),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
Flow,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
@@ -10,9 +11,11 @@ import type {
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewScript,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
Script,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
@@ -93,6 +96,31 @@ export type VariableDraftState = {
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable metadata surfaced on read and accepted by the `set_*_metadata` tools.
|
||||
* A flat superset across scripts, flows, and apps; each producer fills only the
|
||||
* fields relevant to its type. Carried on `WorkspaceItem` so it survives the
|
||||
* lossy draft -> WorkspaceItem projection (which otherwise keeps only
|
||||
* summary/language/value). `summary` stays a top-level WorkspaceItem field, so
|
||||
* it is intentionally absent here.
|
||||
*/
|
||||
export type ItemMetadata = {
|
||||
description?: string
|
||||
labels?: string[]
|
||||
tag?: string
|
||||
priority?: number
|
||||
timeout?: number
|
||||
concurrent_limit?: number
|
||||
concurrency_time_window_s?: number
|
||||
cache_ttl?: number
|
||||
/** scripts only */
|
||||
kind?: 'script' | 'failure' | 'trigger' | 'command' | 'approval' | 'preprocessor'
|
||||
/** apps only */
|
||||
execution_mode?: 'viewer' | 'publisher' | 'anonymous'
|
||||
/** apps only */
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
@@ -107,10 +135,52 @@ export type WorkspaceItem = {
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
metadata?: ItemMetadata
|
||||
isDraft: boolean
|
||||
isLiveDraft?: boolean
|
||||
}
|
||||
|
||||
/** Extract the editable metadata from a script (draft `NewScript` or deployed `Script`). */
|
||||
export function scriptItemMetadata(s: NewScript | Script): ItemMetadata {
|
||||
return {
|
||||
description: s.description,
|
||||
labels: s.labels,
|
||||
kind: s.kind,
|
||||
tag: s.tag,
|
||||
priority: s.priority,
|
||||
timeout: s.timeout,
|
||||
concurrent_limit: s.concurrent_limit,
|
||||
concurrency_time_window_s: s.concurrency_time_window_s,
|
||||
cache_ttl: s.cache_ttl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the editable metadata from a flow. Summary/description/tag/timeout/labels
|
||||
* live on the `Flow` wrapper; priority/concurrency/cache_ttl live inside `value`.
|
||||
*/
|
||||
export function flowItemMetadata(f: Flow): ItemMetadata {
|
||||
const v = f.value as FlowValue | undefined
|
||||
return {
|
||||
description: f.description,
|
||||
labels: f.labels,
|
||||
tag: f.tag,
|
||||
timeout: f.timeout,
|
||||
priority: v?.priority,
|
||||
concurrent_limit: v?.concurrent_limit,
|
||||
concurrency_time_window_s: v?.concurrency_time_window_s,
|
||||
cache_ttl: v?.cache_ttl
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the editable metadata from a raw app draft value. */
|
||||
export function appItemMetadata(v: AppDraftValue): ItemMetadata {
|
||||
return {
|
||||
execution_mode: v.policy?.execution_mode,
|
||||
custom_path: v.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
|
||||
Reference in New Issue
Block a user