(null)
+ const versions = snapshots.data ?? []
+ // The newest version is the default, resolved on every render rather than copied into state: a
+ // refetch that adds a version must not leave the dialog pinned to a choice that predates it.
+ const selected = versions.find((version) => version.id === chosenId) ?? versions[0]
+ const projectChoices = (projects.data?.items ?? []).filter(isRunProject)
+ // A refetch can drop the chosen Project (deleted, or moved out of reach); the form then asks again
+ // instead of submitting an id the server would refuse.
+ const projectId = projectChoices.find((project) => project.id === chosenProjectId)?.id ?? ''
+
+ /** Closing forgets both choices, so the next visit opens on the newest version again. */
+ function handleOpenChange(next: boolean): void {
+ if (!next) {
+ setChosenId(null)
+ setChosenProjectId(null)
+ }
+ onOpenChange(next)
+ }
+
+ return (
+
+
+
+ {t('workflows.run.startTitle')}
+ {t('workflows.run.startDescription')}
+
+ {selected === undefined ? (
+ {t('workflows.run.noPublishedSnapshot')}
+ ) : (
+ <>
+
+
+ handleOpenChange(false)}
+ onStarted={onStarted}
+ />
+ >
+ )}
+
+
+ )
+}
+
+/**
+ * Whether a Project can host a run.
+ *
+ * A Project on its way out cannot: the creation gate refuses one already being deleted, and a
+ * deleted one no longer resolves, so offering either would only produce the refusal the member
+ * would then have to read.
+ */
+function isRunProject(project: Project): boolean {
+ return project.lifecycle !== 'deleting' && project.lifecycle !== 'deleted'
+}
+
+/**
+ * The Project the run and its Workspace belong to.
+ *
+ * The run is created against the Project named here rather than one derived from anything else: a
+ * workflow is a tenant document, and the Project is what says which repository the Workspace clones
+ * and where its lifecycle is accounted for. A space with no Project to choose from says so, because
+ * the next step is creating one — an empty picker would leave the member with a disabled button and
+ * no reason why.
+ */
+function ProjectPicker({
+ projects,
+ selectedId,
+ pending,
+ failed,
+ onSelect,
+}: {
+ projects: readonly Project[]
+ selectedId: string
+ pending: boolean
+ failed: boolean
+ onSelect: (projectId: string | null) => void
+}) {
+ const { t } = useTranslation()
+ if (failed) {
+ return (
+
+ {t('workflows.run.projectsUnavailable')}
+
+ )
+ }
+ if (!pending && projects.length === 0) {
+ return {t('workflows.run.noProjects')}
+ }
+ return (
+
+
{t('workflows.run.project')}
+
onSelect(typeof next === 'string' ? next : null)}
+ >
+
+
+
+
+ {projects.map((project) => (
+
+ {project.name}
+
+ ))}
+
+
+ {selectedId === '' && !pending && (
+
{t('workflows.run.projectRequired')}
+ )}
+
+ )
+}
+
+/** The published versions the run may execute, newest first, labelled the way the `@` form does. */
+function VersionPicker({
+ versions,
+ selectedId,
+ onSelect,
+}: {
+ versions: readonly WorkflowSnapshot[]
+ selectedId: string
+ onSelect: (snapshotId: string) => void
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {t('workflows.run.version')}
+ onSelect(next ?? '')}>
+
+
+ {(current: unknown) =>
+ versionLabel(versions, typeof current === 'string' ? current : '')
+ }
+
+
+
+ {versions.map((version) => (
+
+ {versionLabel(versions, version.id)}
+
+ ))}
+
+
+
+ )
+}
+
+/** The version number is what a member recognises, so it leads; the name says which release it was. */
+function versionLabel(versions: readonly WorkflowSnapshot[], snapshotId: string): string {
+ const version = versions.find((candidate) => candidate.id === snapshotId)
+ if (version === undefined) return snapshotId
+ return `v${version.version} · ${version.name}`
+}
+
+/**
+ * The selected version's own form, its submit, and whatever the server said about the last attempt.
+ *
+ * The request object is built once per set of answers and reused for every attempt, which is what
+ * makes a retry — a second click, or React Query replaying the mutation — carry the same
+ * idempotency key and reach the run the first attempt created instead of starting another one.
+ */
+function WorkflowRunForm({
+ tenantId,
+ workflowId,
+ projectId,
+ snapshot,
+ onCancel,
+ onStarted,
+}: {
+ tenantId: string
+ workflowId: string
+ projectId: string
+ snapshot: WorkflowSnapshot
+ onCancel: () => void
+ onStarted: (run: WorkflowRun) => void
+}) {
+ const { t } = useTranslation()
+ const translate = useWorkflowTranslator()
+ const createRun = useCreateWorkflowRun(tenantId)
+ const fields = useMemo(
+ () => workflowRunFields(parseWorkflowGraphValue(snapshot.graph)),
+ [snapshot.graph],
+ )
+ const descriptor = useMemo(() => runFormDescriptor(fields, translate), [fields, translate])
+ const [values, setValues] = useState(() => initialWorkflowRunValues(fields))
+ const missing = missingWorkflowRunFields(fields, values)
+ const request = useMemo(
+ () => ({
+ workflowId,
+ projectId,
+ snapshotId: snapshot.id,
+ input: workflowRunInput(fields, values),
+ }),
+ [workflowId, projectId, snapshot.id, fields, values],
+ )
+
+ /** Starts the run, and hands the created run to the caller; a fault stays on this form. */
+ async function submit(): Promise {
+ try {
+ onStarted(await createRun.mutateAsync(request))
+ } catch {
+ // the fault line above the footer already reports it
+ }
+ }
+
+ return (
+ <>
+ {fields.length === 0 ? (
+ {t('workflows.run.noInput')}
+ ) : (
+ setValues((current) => ({ ...current, [key]: next }))}
+ />
+ )}
+ {missing.length > 0 && (
+
+ {t('workflows.run.missingRequired', {
+ fields: missing.map((field) => fieldLabel(field, translate)).join('、'),
+ })}
+
+ )}
+ {createRun.error && (
+
+ {t(workflowErrorKey(createRun.error.response?.data?.code))}
+
+ )}
+
+
+ {t('workflows.list.cancel')}
+
+ 0 || projectId === ''}
+ onClick={() => void submit()}
+ >
+
+ {t('workflows.run.start')}
+
+
+ >
+ )
+}
+
+/**
+ * The text above one field's control.
+ *
+ * The platform's own field arrives named by its translation key, because the runtime layer knows
+ * which wording is the platform's but not what language the member reads; a variable the graph
+ * declares carries its author's label as text. Resolving both through one function is what keeps the
+ * label above a control and the "still missing" line below the form saying the same thing about the
+ * same field.
+ */
+function fieldLabel(field: WorkflowRunField, translate: WorkflowNodeTranslator): string {
+ return field.labelKey === undefined ? field.label : translate(field.labelKey)
+}
+
+/**
+ * Recasts the version's fields as the descriptor the shared Issues form renderer draws.
+ *
+ * The manual entry and the `@` form ask the same questions of the same graph, so they draw them with
+ * the same controls; keeping one renderer is what stops the two surfaces from drifting into asking
+ * the same thing two different ways. The form reference is this surface's own name, since the
+ * descriptor is rendered here and never fetched by it.
+ */
+function runFormDescriptor(
+ fields: readonly WorkflowRunField[],
+ translate: WorkflowNodeTranslator,
+): FormDescriptor {
+ return {
+ formRef: 'workflow-run',
+ fields: fields.map((field) => ({
+ key: field.key,
+ label: fieldLabel(field, translate),
+ type: field.control,
+ required: field.required,
+ options: field.options.map((option) => ({ value: option, label: option })),
+ ...(field.defaultValue === undefined ? {} : { defaultValue: field.defaultValue }),
+ })),
+ }
+}
diff --git a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx
index 5aef6224d..2db78402a 100644
--- a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx
+++ b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx
@@ -3,12 +3,16 @@ import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import type { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas'
+import { CurrentSpaceProvider } from '@/features/spaces/current-space'
import { useCreateWorkflowRun } from '@/features/workflows/api'
import { WorkflowRunTools } from '@/features/workflows/editor/workflow-run-tools'
import { routes } from '@/routes'
import {
+ TEST_PROJECT_ID,
TEST_TENANT_ID,
+ chooseProject,
installCloudSpaceHandlers,
+ installSpaceProjectHandlers,
installWorkflowHandlers,
workflowFixture,
} from '@/test/cloud-handlers'
@@ -17,8 +21,6 @@ import { renderRoutes } from '@/test/render'
import { stubViewportGeometry } from '@/test/viewport'
const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444'
-/** The Project a run freezes; the editor has none to state yet, so only the hook tests name one. */
-const PROJECT_ID = '55555555-5555-5555-5555-555555555555'
/**
* The readable workspace slug the browser route carries. It is deliberately not
* `TEST_TENANT_ID`: the tenant UUID scopes API calls, and using it as the slug is
@@ -101,15 +103,25 @@ function installRunHandlers({
)
}
-/** Mounts the tools alone at the real editor route, with the run route as their destination. */
+/**
+ * Mounts the tools alone at the real editor route, with the run route as their destination.
+ *
+ * The start dialog reads the space's Projects, so the bare mount stands in for the layout that
+ * normally provides the current space: without it the dialog has no space to list Projects of.
+ */
function renderRunTools(options: RunToolsOptions = {}) {
installCloudSpaceHandlers('admin')
+ installSpaceProjectHandlers()
installRunHandlers(options)
const { router } = renderRoutes(
[
{
path: EDITOR_ROUTE,
- element: ,
+ element: (
+
+
+
+ ),
},
{ path: `${EDITOR_ROUTE}/runs/:runId`, element: 运行详情
},
],
@@ -131,10 +143,9 @@ function wrapper() {
}
/**
- * The create mutation's own gate, pinned at the hook because the editor cannot reach it: a run must
- * name the Project its Workspace provisions in, and the editor has none to state until the run form
- * that picks one lands. A caller that states a Project still gets a run, and the Project it stated
- * is what the request carries.
+ * The create mutation's own gate, pinned at the hook rather than through the dialog: a run must name
+ * the Project its Workspace provisions in, so a caller that states an empty one is refused before
+ * anything is sent, and a caller that states a Project gets it carried on the request.
*/
describe('useCreateWorkflowRun', () => {
it('refuses a run that names no Project without sending it', async () => {
@@ -144,7 +155,7 @@ describe('useCreateWorkflowRun', () => {
wrapper: wrapper(),
})
- result.current.mutate({ workflowId: WORKFLOW_ID })
+ result.current.mutate({ workflowId: WORKFLOW_ID, projectId: '' })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toBe('workflow_run_project_required')
@@ -158,10 +169,10 @@ describe('useCreateWorkflowRun', () => {
wrapper: wrapper(),
})
- result.current.mutate({ workflowId: WORKFLOW_ID, projectId: PROJECT_ID })
+ result.current.mutate({ workflowId: WORKFLOW_ID, projectId: TEST_PROJECT_ID })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
- expect(bodies).toEqual([{ projectId: PROJECT_ID }])
+ expect(bodies).toEqual([{ projectId: TEST_PROJECT_ID }])
})
})
@@ -176,20 +187,21 @@ describe('WorkflowRunTools', () => {
expect(runButton).toHaveAttribute('title', '请先发布一个版本,才能运行这个工作流')
})
- it('arms the run button once a snapshot exists, but starts no run it cannot state a Project for', async () => {
+ it('starts the chosen version in the chosen Project, then opens the run', async () => {
const bodies: unknown[] = []
const router = renderRunTools({ runs: [], onCreate: (body) => bodies.push(body) })
const runButton = await screen.findByRole('button', { name: '运行' })
await waitFor(() => expect(runButton).toBeEnabled())
fireEvent.click(runButton)
-
- // A run must name the Project its Workspace provisions in, and the editor has none to state
- // yet: the refusal happens before the request, so nothing is created and nothing navigates.
- // The button goes pending and comes back armed, which is where the refusal settles.
- await waitFor(() => expect(runButton).toBeEnabled())
- expect(bodies).toEqual([])
- expect(router.state.location.pathname).toBe(EDITOR_PATH)
+ await chooseProject()
+ fireEvent.click(await screen.findByRole('button', { name: '开始运行' }))
+
+ // The browser path carries the workspace slug, never the tenant UUID.
+ await waitFor(() => expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`))
+ // The run names the version it executes and the Project its Workspace provisions in, so the
+ // server can freeze both onto it.
+ expect(bodies).toEqual([{ projectId: TEST_PROJECT_ID, snapshotId: 'snap-1', input: {} }])
})
it('lists run history and opens a run from a row', async () => {
@@ -222,6 +234,7 @@ describe('workflow run navigation through the app routes', () => {
/** Mounts the app's real routes inside the workspace and installs what the editor reads. */
function renderAppAt(path: string, runs: WorkflowRun[] = []) {
installCloudSpaceHandlers('admin')
+ installSpaceProjectHandlers()
installWorkflowHandlers([workflowFixture({ id: WORKFLOW_ID })])
const bodies: unknown[] = []
installRunHandlers({
@@ -233,19 +246,20 @@ describe('workflow run navigation through the app routes', () => {
return { ...view, bodies }
}
- it('starts no run, and stays on the same workspace route instead of the UUID one', async () => {
+ it('opens the started run on the same workspace route instead of the UUID one', async () => {
const { router, bodies } = renderAppAt(EDITOR_PATH)
const runButton = await screen.findByRole('button', { name: '运行' })
await waitFor(() => expect(runButton).toBeEnabled())
fireEvent.click(runButton)
+ await chooseProject()
+ fireEvent.click(await screen.findByRole('button', { name: '开始运行' }))
// A UUID workspace path would have been redirected to Issues by the Layout before
- // this page ever mounted, so the route is what proves the click stayed put.
- await waitFor(() => expect(runButton).toBeEnabled())
- expect(bodies).toHaveLength(0)
- expect(router.state.location.pathname).toBe(EDITOR_PATH)
- expect(screen.queryByRole('heading', { name: '运行详情' })).not.toBeInTheDocument()
+ // this page ever mounted, so the route is what proves the click stayed on the slug.
+ expect(await screen.findByRole('heading', { name: '运行详情' })).toBeInTheDocument()
+ expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`)
+ expect(bodies).toHaveLength(1)
})
it('opens an existing run from the history with the same slug and creates nothing', async () => {
diff --git a/frontend/src/features/workflows/editor/workflow-run-tools.tsx b/frontend/src/features/workflows/editor/workflow-run-tools.tsx
index b3b82108a..76f607872 100644
--- a/frontend/src/features/workflows/editor/workflow-run-tools.tsx
+++ b/frontend/src/features/workflows/editor/workflow-run-tools.tsx
@@ -3,25 +3,23 @@ import { Play, ScrollText } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
-import type { WorkflowRunStatus } from '@/api/generated.schemas'
+import type { WorkflowRun, WorkflowRunStatus } from '@/api/generated.schemas'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
-import {
- useCreateWorkflowRun,
- useWorkflowRuns,
- useWorkflowSnapshots,
-} from '@/features/workflows/api'
+import { useWorkflowRuns, useWorkflowSnapshots } from '@/features/workflows/api'
+import { WorkflowRunDialog } from '@/features/workflows/editor/workflow-run-dialog'
import { RunStatusBadge } from '@/features/workflows/run/run-status-mark'
import { workspacePaths } from '@/lib/paths'
/**
- * The toolbar's run controls: kick off one run now, or browse the run history.
+ * The toolbar's run controls: start one run, or browse the run history.
*
* A run executes against a published snapshot, so the primary button is disabled while
- * the workflow has none — there is nothing honest to execute a draft against. Kicking off
- * a run navigates to it; browsing history opens the popover, whose rows lead to each run's
- * read-only detail page.
+ * the workflow has none — there is nothing honest to execute a draft against. Starting a
+ * run opens {@link WorkflowRunDialog}, which is where the version and the run's own input
+ * are chosen; a started run then navigates to its read-only detail page, while browsing
+ * history opens the popover whose rows lead to each run's page.
*
* @param props.tenantId - Tenant the workflow belongs to; scopes the API calls only, never a
* browser path segment (the route's workspace slug is the one paths are built from).
@@ -43,34 +41,30 @@ export function WorkflowRunTools({
const paths = workspacePaths(workspaceSlug ?? tenantId)
const snapshots = useWorkflowSnapshots(tenantId, workflowId)
const runs = useWorkflowRuns(tenantId, workflowId)
- const createRun = useCreateWorkflowRun(tenantId)
const [historyOpen, setHistoryOpen] = useState(false)
+ const [startOpen, setStartOpen] = useState(false)
const noSnapshot = snapshots.data !== undefined && snapshots.data.length === 0
- const runNowDisabled = noSnapshot || createRun.isPending
return (
<>
{
- createRun.mutate(
- { workflowId },
- {
- onSuccess: (run) => {
- setHistoryOpen(false)
- void navigate(paths.workflowRun(run.workflowId, run.id))
- },
- },
- )
- }}
+ onClick={() => setStartOpen(true)}
>
{t('workflows.run.runNow')}
+
{
@@ -102,6 +96,13 @@ export function WorkflowRunTools({
>
)
+ /** Leaves the dialog for the run the server just created. */
+ function openStartedRun(run: WorkflowRun): void {
+ setStartOpen(false)
+ setHistoryOpen(false)
+ void navigate(paths.workflowRun(run.workflowId, run.id))
+ }
+
/** Navigates to a run's read-only detail page. */
function openRun(runId: string): void {
setHistoryOpen(false)
diff --git a/frontend/src/features/workflows/error-messages.ts b/frontend/src/features/workflows/error-messages.ts
index fe9a17998..e41c7ce54 100644
--- a/frontend/src/features/workflows/error-messages.ts
+++ b/frontend/src/features/workflows/error-messages.ts
@@ -19,6 +19,9 @@ const FAULT_MESSAGES: Record = {
version_required: 'workflows.errors.stale',
invalid_input: 'workflows.errors.nameBlank',
workflow_no_published_snapshot: 'workflows.run.noPublishedSnapshot',
+ required_field_missing: 'workflows.errors.requiredField',
+ invalid_field_value: 'workflows.errors.invalidField',
+ workflow_executor_unconfigured: 'workflows.errors.executorUnconfigured',
}
/**
diff --git a/frontend/src/features/workflows/runtime/run-input.test.ts b/frontend/src/features/workflows/runtime/run-input.test.ts
new file mode 100644
index 000000000..d50b9049c
--- /dev/null
+++ b/frontend/src/features/workflows/runtime/run-input.test.ts
@@ -0,0 +1,562 @@
+import { describe, expect, it } from 'vitest'
+import { parseWorkflowGraphValue } from '@/features/workflows/runtime/graph-codec'
+import type { WorkflowGraphEnvelope } from '@/features/workflows/runtime/graph-codec'
+import {
+ initialWorkflowRunValues,
+ missingWorkflowRunFields,
+ workflowRunFields,
+ workflowRunInput,
+} from '@/features/workflows/runtime/run-input'
+import type {
+ WorkflowGlobalVariable,
+ WorkflowInputVariable,
+ WorkflowLaunchField,
+} from '@/features/workflows/runtime/types'
+
+/** The prompt declaration that takes the kickoff prompt out of the form. */
+const NO_PROMPT: WorkflowLaunchField[] = [{ key: 'prompt', enabled: false }]
+
+/**
+ * The envelope a published snapshot of a Start node would parse into.
+ *
+ * Built through the real codec rather than as a literal, so the tests read the same shape the run
+ * dialog does — the snapshot's `graph` as the API returns it.
+ */
+function graphOf(options: {
+ variables?: WorkflowInputVariable[]
+ globals?: WorkflowGlobalVariable[]
+ launchFields?: WorkflowLaunchField[]
+ prompt?: string
+}): WorkflowGraphEnvelope {
+ return parseWorkflowGraphValue({
+ nodes: [
+ {
+ id: 'start',
+ type: 'workflow',
+ position: { x: 0, y: 0 },
+ data: {
+ kind: 'start',
+ title: 'Start',
+ description: '',
+ ...(options.prompt === undefined ? {} : { input: options.prompt }),
+ ...(options.variables === undefined ? {} : { inputVariables: options.variables }),
+ },
+ },
+ ],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ globalVariables: options.globals ?? [],
+ launchFields: options.launchFields ?? [],
+ })
+}
+
+/**
+ * One declared Start variable, so a bounds case reads as the rule it exercises rather than its shape.
+ */
+function variable(name: string, extra: Partial = {}): WorkflowInputVariable {
+ return { name, fieldType: 'text-input', valueType: 'string', ...extra }
+}
+
+describe('workflow run form projection', () => {
+ it('asks the version kickoff prompt first, prefilled from the Start node', () => {
+ const fields = workflowRunFields(graphOf({ prompt: ' Summarize the release notes ' }))
+
+ expect(fields).toEqual([
+ {
+ key: 'prompt',
+ label: 'prompt',
+ labelKey: 'workflows.launchFields.prompt',
+ required: false,
+ control: 'textarea',
+ options: [],
+ defaultValue: 'Summarize the release notes',
+ },
+ ])
+ })
+
+ it('drops the kickoff prompt when the launch declaration turned it off', () => {
+ expect(workflowRunFields(graphOf({ prompt: 'ignored', launchFields: NO_PROMPT }))).toEqual([])
+ })
+
+ it('opens the prompt empty rather than on blank space', () => {
+ const fields = workflowRunFields(graphOf({ prompt: ' ' }))
+
+ expect(fields).toEqual([
+ {
+ key: 'prompt',
+ label: 'prompt',
+ labelKey: 'workflows.launchFields.prompt',
+ required: false,
+ control: 'textarea',
+ options: [],
+ },
+ ])
+ })
+
+ it('names the platform field by its key rather than by its label', () => {
+ const prompt = workflowRunFields(graphOf({ prompt: 'go' }))[0]
+ const declared = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [variable('topic', { displayName: '主题' })],
+ }),
+ )[0]
+
+ // The platform owns the prompt's wording, so the surface renders it in the reader's language; an
+ // author's own label is text and travels as one.
+ expect(prompt?.labelKey).toBe('workflows.launchFields.prompt')
+ expect(declared?.labelKey).toBeUndefined()
+ })
+
+ it('insists on the prompt only when the declaration does', () => {
+ const fields = workflowRunFields(graphOf({ launchFields: [{ key: 'prompt', required: true }] }))
+
+ expect(fields.map((field) => field.required)).toEqual([true])
+ })
+
+ it('projects the declared variables in order, with their own controls', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: 'topic', displayName: '主题', fieldType: 'text-input', valueType: 'string' },
+ { name: 'tone', fieldType: 'select', valueType: 'string', options: ['brief', 'deep'] },
+ { name: 'count', fieldType: 'number', valueType: 'number', required: true },
+ ],
+ }),
+ )
+
+ expect(fields).toEqual([
+ {
+ key: 'topic',
+ label: '主题',
+ required: false,
+ control: 'text',
+ options: [],
+ },
+ {
+ key: 'tone',
+ label: 'tone',
+ required: false,
+ control: 'select',
+ options: ['brief', 'deep'],
+ },
+ {
+ key: 'count',
+ label: 'count',
+ required: true,
+ control: 'number',
+ options: [],
+ },
+ ])
+ })
+
+ it('degrades a select with nothing to choose from to a text field', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [{ name: 'tone', fieldType: 'select', valueType: 'string', options: [] }],
+ }),
+ )
+
+ expect(fields).toEqual([
+ { key: 'tone', label: 'tone', required: false, control: 'text', options: [] },
+ ])
+ })
+
+ it('collects a file reference and a document as the text that carries them', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: 'source', fieldType: 'file', valueType: 'file' },
+ { name: 'manifest', fieldType: 'json', valueType: 'object' },
+ ],
+ }),
+ )
+
+ expect(fields.map((field) => field.control)).toEqual(['text', 'textarea'])
+ })
+
+ it('leaves the prompt key to an author who declared it themselves', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ prompt: 'the initial prompt',
+ variables: [
+ { name: 'prompt', displayName: '提示词', fieldType: 'paragraph', valueType: 'string' },
+ ],
+ }),
+ )
+
+ expect(fields).toEqual([
+ {
+ key: 'prompt',
+ label: '提示词',
+ required: false,
+ control: 'textarea',
+ options: [],
+ defaultValue: 'the initial prompt',
+ },
+ ])
+ })
+
+ it('keeps the value the author declared for the prompt key over the initial prompt', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ prompt: 'the initial prompt',
+ variables: [
+ { name: 'prompt', fieldType: 'text-input', valueType: 'string', value: 'theirs' },
+ ],
+ }),
+ )
+
+ expect(fields.map((field) => field.defaultValue)).toEqual(['theirs'])
+ })
+
+ it('skips a variable the form cannot name', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: ' ', fieldType: 'text-input', valueType: 'string' },
+ { name: 'kept', fieldType: 'text-input', valueType: 'string' },
+ ],
+ }),
+ )
+
+ expect(fields.map((field) => field.key)).toEqual(['kept'])
+ })
+
+ it('adds the dotted globals after the declared variables and skips the plain ones', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [{ name: 'topic', fieldType: 'text-input', valueType: 'string' }],
+ globals: [
+ { name: 'config.region', valueType: 'string', value: 'eu' },
+ { name: 'plainConstant', valueType: 'string', value: 'unused' },
+ { name: 'config.retries', valueType: 'integer' },
+ ],
+ }),
+ )
+
+ expect(fields).toEqual([
+ { key: 'topic', label: 'topic', required: false, control: 'text', options: [] },
+ {
+ key: 'config.region',
+ label: 'config.region',
+ required: false,
+ control: 'text',
+ options: [],
+ defaultValue: 'eu',
+ },
+ {
+ key: 'config.retries',
+ label: 'config.retries',
+ required: false,
+ control: 'number',
+ options: [],
+ },
+ ])
+ })
+
+ it('lets a declared variable keep a name a global also uses', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [{ name: 'config.region', fieldType: 'text-input', valueType: 'string' }],
+ globals: [{ name: 'config.region', valueType: 'string', value: 'global' }],
+ }),
+ )
+
+ expect(fields).toEqual([
+ {
+ key: 'config.region',
+ label: 'config.region',
+ required: false,
+ control: 'text',
+ options: [],
+ },
+ ])
+ })
+})
+
+describe('workflow run form bounds', () => {
+ it('stops the author fields where the server stops projecting them', () => {
+ const variables = Array.from({ length: 101 }, (_, i) => variable(`v${i}`))
+
+ const fields = workflowRunFields(graphOf({ variables, prompt: 'go' }))
+
+ // The hundredth variable is projected and the hundred-and-first is not, which is where the
+ // server's own list ends: asking past it would be a question the run is never validated on.
+ expect(fields).toHaveLength(100)
+ expect(fields[0]?.key).toBe('v0')
+ expect(fields[99]?.key).toBe('v99')
+ })
+
+ it('gives up the kickoff prompt rather than exceed the descriptor bound', () => {
+ const variables = Array.from({ length: 100 }, (_, i) => variable(`v${i}`))
+
+ const fields = workflowRunFields(graphOf({ variables, prompt: 'go' }))
+
+ // The prompt is the platform's own field and the last one added, so it is the one the server
+ // drops when the author's fields already fill the descriptor.
+ expect(fields.map((field) => field.key)).toEqual(variables.map((entry) => entry.name))
+ })
+
+ it('shares the descriptor bound with the dotted globals', () => {
+ const variables = Array.from({ length: 100 }, (_, i) => variable(`v${i}`))
+
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables,
+ globals: [{ name: 'config.region', valueType: 'string', value: 'eu' }],
+ }),
+ )
+
+ // The globals fill whatever room the author's own questions left, rather than getting a bound
+ // of their own, because the server appends them to the same list.
+ expect(fields).toHaveLength(100)
+ expect(fields.some((field) => field.key === 'config.region')).toBe(false)
+ })
+
+ it('drops a variable whose name is not a field key', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ variable('topic name'),
+ variable('topic/name'),
+ variable(' topic '),
+ variable('深'),
+ variable('topic'),
+ variable('topic-1'),
+ variable('a@b:c'),
+ ],
+ }),
+ )
+
+ // The server drops a name outside the grammar instead of rewriting it, because the values reach
+ // the workflow keyed by name — so the form may neither offer one nor trim one into acceptance.
+ expect(fields.map((field) => field.key)).toEqual(['topic', 'topic-1', 'a@b:c'])
+ })
+
+ it('drops a global whose name is not a field key', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ globals: [
+ { name: 'config.region', valueType: 'string', value: 'eu' },
+ { name: 'config.region zone', valueType: 'string', value: 'utc' },
+ ],
+ }),
+ )
+
+ expect(fields.map((field) => field.key)).toEqual(['config.region'])
+ })
+
+ it('drops a variable whose label is over the server bound, counting bytes', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ variable('asciiOver', { displayName: 'x'.repeat(201) }),
+ variable('asciiAtLimit', { displayName: 'x'.repeat(200) }),
+ variable('chineseOver', { displayName: '深'.repeat(67) }),
+ variable('chineseAtLimit', { displayName: '深'.repeat(66) }),
+ ],
+ }),
+ )
+
+ // The bound counts UTF-8 bytes, so sixty-seven Chinese characters are over it where two hundred
+ // ASCII ones are not.
+ expect(fields.map((field) => field.key)).toEqual(['asciiAtLimit', 'chineseAtLimit'])
+ })
+
+ it('offers only the select choices the server keeps', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ variable('tone', {
+ fieldType: 'select',
+ options: [
+ 'brief',
+ 'brief',
+ '',
+ 'deep',
+ 'x'.repeat(200),
+ 'y'.repeat(201),
+ '深'.repeat(66),
+ '深'.repeat(67),
+ ],
+ }),
+ ],
+ }),
+ )
+
+ // A repeated, empty or over-long choice is dropped by the server rather than offered, and the
+ // bound counts bytes: two hundred ASCII characters are a legal choice where sixty-seven Chinese
+ // ones are not. Offering one the gate does not know is a question whose only answer is a refusal.
+ expect(fields[0]?.options).toEqual(['brief', 'deep', 'x'.repeat(200), '深'.repeat(66)])
+ })
+
+ it('stops the select choices where the server stops them', () => {
+ const choices = Array.from({ length: 101 }, (_, index) => `c${index}`)
+
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [variable('tone', { fieldType: 'select', options: choices })],
+ }),
+ )
+
+ // The hundredth choice is offered and the hundred-and-first is not, which is where the server's
+ // own list ends: past it the gate has no value to match an answer against.
+ expect(fields[0]?.options).toHaveLength(100)
+ expect(fields[0]?.options[99]).toBe('c99')
+ })
+
+ it('leaves out a default the server would refuse for its length', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ variable('asciiOver', { fieldType: 'paragraph', value: 'x'.repeat(4001) }),
+ variable('asciiAtLimit', { fieldType: 'paragraph', value: 'x'.repeat(4000) }),
+ variable('chineseOver', { fieldType: 'paragraph', value: '深'.repeat(1334) }),
+ variable('chineseAtLimit', { fieldType: 'paragraph', value: '深'.repeat(1333) }),
+ ],
+ }),
+ )
+
+ // Opening on a value the gate refuses would be worse than opening empty, and the bound counts
+ // bytes: 1334 Chinese characters are 4002 of them, and 1333 are 3999.
+ expect(initialWorkflowRunValues(fields)).toEqual({
+ asciiAtLimit: 'x'.repeat(4000),
+ chineseAtLimit: '深'.repeat(1333),
+ })
+ })
+
+ it('opens the kickoff prompt empty rather than on a prompt the bound cuts', () => {
+ const atLimit = workflowRunFields(graphOf({ prompt: 'x'.repeat(4000) }))[0]
+ const asciiOver = workflowRunFields(graphOf({ prompt: 'x'.repeat(4001) }))[0]
+ const chineseOver = workflowRunFields(graphOf({ prompt: '深'.repeat(1334) }))[0]
+
+ // The server clips a kickoff prompt this long, but a cut-off instruction is not a default this
+ // form may open on: the member would be editing a sentence the author never finished. An empty
+ // field leaves the run to fall back to the version's own prompt, which is all of it. The bound
+ // counts bytes, so 1334 Chinese characters are 4002 of them.
+ expect(atLimit?.defaultValue).toBe('x'.repeat(4000))
+ expect(asciiOver?.defaultValue).toBeUndefined()
+ expect(chineseOver?.defaultValue).toBeUndefined()
+ })
+})
+
+describe('workflow run form defaults', () => {
+ it('seeds only the defaults the form can render', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: 'topic', fieldType: 'text-input', valueType: 'string', value: 'release' },
+ { name: 'count', fieldType: 'number', valueType: 'number', value: 'many' },
+ {
+ name: 'tone',
+ fieldType: 'select',
+ valueType: 'string',
+ options: ['brief'],
+ value: 'deep',
+ },
+ ],
+ }),
+ )
+
+ expect(initialWorkflowRunValues(fields)).toEqual({ topic: 'release' })
+ })
+
+ it('leaves out a select default that is not a choice the server kept', () => {
+ const overLong = 'y'.repeat(201)
+
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ variable('tone', { fieldType: 'select', options: ['brief', overLong], value: overLong }),
+ ],
+ }),
+ )
+
+ // The choice is dropped from the field, so it cannot be the answer the form opens on either: the
+ // gate matches a select's value against the choices it kept, and this one is not among them.
+ expect(fields[0]?.options).toEqual(['brief'])
+ expect(initialWorkflowRunValues(fields)).toEqual({})
+ })
+
+ it('keeps a select default that is one of its choices', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ {
+ name: 'tone',
+ fieldType: 'select',
+ valueType: 'string',
+ options: ['brief'],
+ value: 'brief',
+ },
+ ],
+ }),
+ )
+
+ expect(initialWorkflowRunValues(fields)).toEqual({ tone: 'brief' })
+ })
+})
+
+describe('workflow run form gating', () => {
+ const fields = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: 'topic', fieldType: 'text-input', valueType: 'string', required: true },
+ { name: 'note', fieldType: 'paragraph', valueType: 'string' },
+ ],
+ }),
+ )
+
+ it('reports a required field the member left unanswered', () => {
+ expect(missingWorkflowRunFields(fields, {}).map((field) => field.key)).toEqual(['topic'])
+ })
+
+ it('counts blank space as unanswered', () => {
+ expect(missingWorkflowRunFields(fields, { topic: ' ' }).map((field) => field.key)).toEqual([
+ 'topic',
+ ])
+ })
+
+ it('leaves an optional field out of the gate', () => {
+ expect(missingWorkflowRunFields(fields, { topic: 'release' })).toEqual([])
+ })
+
+ it('omits the unanswered fields from the submitted input', () => {
+ expect(workflowRunInput(fields, { topic: 'release', note: '' })).toEqual({ topic: 'release' })
+ })
+
+ it('sends an answer of false and zero rather than treating it as absent', () => {
+ const booleans = workflowRunFields(
+ graphOf({
+ launchFields: NO_PROMPT,
+ variables: [
+ { name: 'dryRun', fieldType: 'checkbox', valueType: 'boolean' },
+ { name: 'count', fieldType: 'number', valueType: 'number' },
+ ],
+ }),
+ )
+
+ expect(workflowRunInput(booleans, { dryRun: false, count: 0 })).toEqual({
+ dryRun: false,
+ count: 0,
+ })
+ })
+})
diff --git a/frontend/src/features/workflows/runtime/run-input.ts b/frontend/src/features/workflows/runtime/run-input.ts
new file mode 100644
index 000000000..4c4acb5e9
--- /dev/null
+++ b/frontend/src/features/workflows/runtime/run-input.ts
@@ -0,0 +1,398 @@
+import type { WorkflowGraphEnvelope } from '@/features/workflows/runtime/graph-codec'
+import { resolveWorkflowInputFieldType } from '@/features/workflows/runtime/start-input'
+import type {
+ WorkflowGlobalVariable,
+ WorkflowInputFieldType,
+ WorkflowInputVariable,
+ WorkflowLaunchField,
+ WorkflowNodeData,
+} from '@/features/workflows/runtime/types'
+import { WORKFLOW_LAUNCH_FIELD_DEFAULTS } from '@/features/workflows/runtime/types'
+
+/** The control the manual run form renders for one answer. */
+export type WorkflowRunControl = 'text' | 'textarea' | 'number' | 'boolean' | 'select'
+
+/**
+ * One question the manual run form asks, and the key its answer travels under.
+ *
+ * The key is the run's own vocabulary: the answers are submitted as the run's `input` object, keyed
+ * exactly the way the frozen graph names them, because that is the object the start gate validates.
+ */
+export interface WorkflowRunField {
+ key: string
+ /** What the member reads above the control. */
+ label: string
+ /**
+ * The translation key for {@link WorkflowRunField.label}, when the platform owns that wording.
+ *
+ * A variable the graph declares is labelled by its author, so its label is the text to show; the
+ * platform's own field is named by the platform, and the surface renders it in the reader's
+ * language. The label above stays the fallback for a surface that has no translator, and the key
+ * stays an ordinary string here for the same reason the node catalog keeps its own keys that way.
+ */
+ labelKey?: string
+ /** Whether a run may start without an answer. */
+ required: boolean
+ control: WorkflowRunControl
+ /** The choices a `select` offers, in declaration order; empty for every other control. */
+ options: string[]
+ /** The answer the form opens with, when the frozen graph declares one. */
+ defaultValue?: unknown
+}
+
+/** The answers a member has given, keyed by field. */
+export type WorkflowRunValues = Record
+
+/** The key the platform reads a run's kickoff prompt from. */
+const PROMPT_FIELD_KEY = 'prompt'
+
+/**
+ * The bounds the server puts on one projected form, mirrored so the two projections agree.
+ *
+ * The server owns these numbers — `maxFormFields`, `maxFormOptions`, `maxFieldValueLength` and the
+ * 200-byte bound it puts on a field key, a field label and a select choice alike in
+ * `internal/core/form_descriptor.go` and `workflow_collaboration.go` — and it validates the submitted
+ * input against the same document this form is projected from. A bound only one side applies is
+ * either a question the run cannot answer or a refusal of an answer the form accepted, so the form
+ * reads the server's limits rather than leaving them to drift. They count UTF-8 bytes, which is what
+ * Go's `len` counts, so a Chinese label is bounded at a third of the characters an ASCII one is.
+ */
+const MAX_FORM_FIELDS = 100
+const MAX_FORM_OPTIONS = 100
+const MAX_FIELD_LABEL = 200
+const MAX_FIELD_VALUE = 4000
+
+/** The grammar a field key may use, mirroring the server's `validOpaqueToken`. */
+const OPAQUE_TOKEN = /^[A-Za-z0-9._:@-]+$/
+
+/**
+ * The control each declared Start field type degrades to in the run form.
+ *
+ * This mirrors the server's own projection onto the `@` form, including its degradations, because
+ * the two have to agree: the server validates the submitted input against the same document, and a
+ * field only one side knows about is either a question with no answer or an answer with no question.
+ * A file reference and a JSON document both arrive as the text that carries them, since neither
+ * surface has an upload or structured-value control.
+ */
+const CONTROL_BY_FIELD_TYPE: Record = {
+ 'text-input': 'text',
+ paragraph: 'textarea',
+ select: 'select',
+ number: 'number',
+ checkbox: 'boolean',
+ file: 'text',
+ 'file-list': 'textarea',
+ json: 'textarea',
+}
+
+/** The control a global variable's value type degrades to, matching the Start variables' own. */
+const CONTROL_BY_VALUE_TYPE: Record = {
+ number: 'number',
+ integer: 'number',
+ boolean: 'boolean',
+ string: 'text',
+ secret: 'text',
+}
+
+/**
+ * Projects a frozen graph onto the questions the manual run form asks.
+ *
+ * The fields come from the document the run will execute, never from the live draft: a member who
+ * picked a version published three weeks ago answers that version's variables, and an edit saved
+ * since cannot change what the form asks, what it prefills, or what it insists on. The projection
+ * mirrors the server's `workflowInputFields` — the kickoff prompt first, then the Start node's
+ * declared variables, then the dotted global variables that stand in for run-time parameters — and
+ * the server's bounds on it, so the two sides never disagree about which fields exist.
+ *
+ * @param graph - The selected version's parsed graph envelope.
+ * @returns The fields in the order the form shows them. A graph that declares nothing yields an
+ * empty list rather than a partial one.
+ */
+export function workflowRunFields(graph: WorkflowGraphEnvelope): WorkflowRunField[] {
+ const start = startNodeData(graph)
+ const declared = declaredFields(start)
+ const globals = globalFields(graph, declared)
+ // The server stops adding its own fields once the author's fill the descriptor, because appending
+ // past the bound would make the descriptor invalid and turn every start into a 500. The kickoff
+ // prompt is the one platform field this form adds, so it is the one that gives way.
+ const full = declared.length + globals.length === MAX_FORM_FIELDS
+ const prompt = full ? [] : promptFields(graph, declared, start)
+ return [...prompt, ...declared, ...globals]
+}
+
+/**
+ * The answers the form opens with, taken from the version's own declared defaults.
+ *
+ * A field with no usable default is left out rather than seeded with an empty string, so the form
+ * can tell "the member has not answered" from "the member answered with nothing" — which is what
+ * makes the required-field gate below mean what it says.
+ *
+ * @param fields - The fields {@link workflowRunFields} projected.
+ * @returns The initial answers, keyed by field.
+ */
+export function initialWorkflowRunValues(fields: readonly WorkflowRunField[]): WorkflowRunValues {
+ const values: WorkflowRunValues = {}
+ for (const field of fields) {
+ if (field.defaultValue !== undefined && field.defaultValue !== null) {
+ values[field.key] = field.defaultValue
+ }
+ }
+ return values
+}
+
+/**
+ * The required fields the member has not answered yet.
+ *
+ * This is a courtesy, not the gate: the server re-checks the same questions against the same frozen
+ * document and answers `required_field_missing`. Asking here spares a round trip, and a disagreement
+ * between the two resolves in the server's favour, since it is the one that decides.
+ *
+ * @param fields - The fields {@link workflowRunFields} projected.
+ * @param values - The answers so far.
+ * @returns The unanswered required fields, in form order.
+ */
+export function missingWorkflowRunFields(
+ fields: readonly WorkflowRunField[],
+ values: WorkflowRunValues,
+): WorkflowRunField[] {
+ return fields.filter((field) => field.required && isUnanswered(values[field.key]))
+}
+
+/**
+ * Builds the run's `input` object from the member's answers.
+ *
+ * Unanswered fields are left out entirely rather than sent as `null` or `''`: the gate treats a
+ * missing key and a null one the same, and an absent key keeps the submitted object a statement of
+ * what the member actually chose. Every other answer is passed through as typed, because the
+ * controls produce the values the gate accepts and second-guessing them here would only hide a bug.
+ *
+ * @param fields - The fields {@link workflowRunFields} projected.
+ * @param values - The member's answers.
+ * @returns The `input` object for the run creation request.
+ */
+export function workflowRunInput(
+ fields: readonly WorkflowRunField[],
+ values: WorkflowRunValues,
+): Record {
+ const input: Record = {}
+ for (const field of fields) {
+ const value = values[field.key]
+ if (isUnanswered(value)) continue
+ input[field.key] = value
+ }
+ return input
+}
+
+/** True for an answer the form should treat as not given. */
+function isUnanswered(value: unknown): boolean {
+ if (value === undefined || value === null) return true
+ return typeof value === 'string' && value.trim() === ''
+}
+
+/** The Start node's data, or undefined for a graph that has no Start node. */
+function startNodeData(graph: WorkflowGraphEnvelope): WorkflowNodeData | undefined {
+ return graph.nodes.find((node) => node.data.kind === 'start')?.data
+}
+
+/** Maps every declared Start variable onto a field, dropping the ones the form cannot name. */
+function declaredFields(start: WorkflowNodeData | undefined): WorkflowRunField[] {
+ const fields: WorkflowRunField[] = []
+ for (const variable of start?.inputVariables ?? []) {
+ if (fields.length === MAX_FORM_FIELDS) break
+ const field = startVariableField(variable, start?.input ?? '')
+ if (field !== undefined) fields.push(field)
+ }
+ return fields
+}
+
+/**
+ * The platform's kickoff-prompt field, when it is the platform's to offer.
+ *
+ * The prompt is not a declared variable but the platform's own field, so its default is the Start
+ * node's initial prompt: an author who wrote one gets it prefilled and can edit it for this run,
+ * unless that prompt is over the bound a default may carry, in which case the field opens empty.
+ * The field is dropped when the workflow's declaration turned it off, and — like every other
+ * platform field — when the author declared that key in their own Start node, because a key the
+ * author declared is not the platform's to withdraw.
+ */
+function promptFields(
+ graph: WorkflowGraphEnvelope,
+ declared: readonly WorkflowRunField[],
+ start: WorkflowNodeData | undefined,
+): WorkflowRunField[] {
+ if (declared.some((field) => field.key === PROMPT_FIELD_KEY)) return []
+ if (!launchFieldAsks(graph.launchFields, PROMPT_FIELD_KEY)) return []
+ const field: WorkflowRunField = {
+ key: PROMPT_FIELD_KEY,
+ label: PROMPT_FIELD_KEY,
+ labelKey: 'workflows.launchFields.prompt',
+ required: launchFieldRequired(graph.launchFields, PROMPT_FIELD_KEY),
+ control: 'textarea',
+ options: [],
+ }
+ // The server clips a kickoff prompt past the bound, but half an instruction is not a default this
+ // form may open on: the member would be editing a sentence the author never finished writing.
+ // Opening empty instead leaves the run to fall back to the version's own prompt, which is all of
+ // it, and holds the platform's default to the same bound a declared one is held to. An absent
+ // prompt is no default at all rather than an empty one.
+ const initial = start?.input?.trim() ?? ''
+ if (initial !== '' && acceptsValue(field.control, [], initial)) field.defaultValue = initial
+ return [field]
+}
+
+/**
+ * The dotted global variables, as parameters one run may override.
+ *
+ * A global name always contains a dot — the editor's own guard, mirrored here — which is what keeps
+ * these keys from colliding with a declared variable or a platform field. A global is a
+ * workflow-wide constant this invocation may override, never a required answer, so leaving one
+ * untouched runs the workflow with exactly the constant it declares.
+ *
+ * They share the descriptor's field bound with the declared variables rather than getting one of
+ * their own, because the server appends them to the same list: the author's own questions come first
+ * and the globals fill whatever room is left.
+ */
+function globalFields(
+ graph: WorkflowGraphEnvelope,
+ declared: readonly WorkflowRunField[],
+): WorkflowRunField[] {
+ const fields: WorkflowRunField[] = []
+ const seen = new Set(declared.map((field) => field.key))
+ for (const variable of graph.globalVariables) {
+ if (declared.length + fields.length === MAX_FORM_FIELDS) break
+ if (!variable.name.includes('.') || !isFieldKey(variable.name) || seen.has(variable.name)) {
+ continue
+ }
+ seen.add(variable.name)
+ fields.push(globalVariableField(variable))
+ }
+ return fields
+}
+
+/** The UTF-8 byte length of a string, the unit every bound above counts in. */
+function utf8Length(value: string): number {
+ return new TextEncoder().encode(value).length
+}
+
+/**
+ * Whether a variable name is one the server would project as a field key.
+ *
+ * A name outside the grammar is dropped by the server rather than rewritten, because the confirmed
+ * values reach the workflow keyed by name: a sanitized key would feed the run the wrong variable. The
+ * form drops it for the same reason — a field only this side knows about is a question whose answer
+ * never arrives.
+ */
+function isFieldKey(name: string): boolean {
+ return utf8Length(name) <= MAX_FIELD_LABEL && OPAQUE_TOKEN.test(name)
+}
+
+/**
+ * The choices a declared select offers, filtered exactly the way the server filters them.
+ *
+ * The server drops a choice that is not a string, is empty, is over the same 200-byte bound a field
+ * key and a label carry, repeats one already offered, or falls past the descriptor's option bound. A
+ * choice the server dropped is not a value the gate accepts, so offering it would be a question whose
+ * only answer comes back `invalid_field_value` — and a declared default among the dropped ones would
+ * open the form on an answer it refuses. The graph is untrusted JSON and the codec validates its
+ * shapes rather than every choice, so the string test the server makes is made here too.
+ */
+function selectOptions(variable: WorkflowInputVariable): string[] {
+ const options: string[] = []
+ const seen = new Set()
+ for (const option of variable.options ?? []) {
+ if (typeof option !== 'string' || option === '') continue
+ if (utf8Length(option) > MAX_FIELD_LABEL || seen.has(option)) continue
+ if (options.length === MAX_FORM_OPTIONS) continue
+ seen.add(option)
+ options.push(option)
+ }
+ return options
+}
+
+/**
+ * Accepts a declared value as a default only when the control can render it.
+ *
+ * A default the control cannot show would seed the form with an answer the member never gave and
+ * the gate would reject, which is worse than opening the field empty. This mirrors the server's own
+ * check on the defaults it hands the `@` form, including its bound on one text value: a paragraph
+ * over it is dropped by the server, so it is not a default this form may open on.
+ */
+function acceptsValue(
+ control: WorkflowRunControl,
+ options: readonly string[],
+ value: unknown,
+): boolean {
+ switch (control) {
+ case 'number':
+ return typeof value === 'number' && Number.isFinite(value)
+ case 'boolean':
+ return typeof value === 'boolean'
+ case 'select':
+ return typeof value === 'string' && options.includes(value)
+ default:
+ return typeof value === 'string' && utf8Length(value) <= MAX_FIELD_VALUE
+ }
+}
+
+/** Maps one declared Start variable onto a form field, or drops it when it cannot be asked about. */
+function startVariableField(
+ variable: WorkflowInputVariable,
+ initialPrompt: string,
+): WorkflowRunField | undefined {
+ const key = variable.name
+ if (!isFieldKey(key)) return undefined
+ // The server decides whether the variable is a field at all from its own label — the declared
+ // display name, or the key when the author left one empty — and drops the whole field when that
+ // label is over the bound rather than clipping it. The check therefore reads the label untrimmed;
+ // only what the form shows is trimmed.
+ const declaredLabel = variable.displayName ?? ''
+ if (utf8Length(declaredLabel || key) > MAX_FIELD_LABEL) return undefined
+ const fieldType = resolveWorkflowInputFieldType(variable)
+ const options = fieldType === 'select' ? selectOptions(variable) : []
+ // A select with nothing to choose from cannot be answered at all, so it becomes a text field —
+ // the same degradation the server applies, which keeps the form usable instead of unanswerable.
+ const control =
+ fieldType === 'select' && options.length === 0 ? 'text' : CONTROL_BY_FIELD_TYPE[fieldType]
+ const field: WorkflowRunField = {
+ key,
+ label: declaredLabel.trim() || key,
+ required: variable.required === true,
+ control,
+ options,
+ }
+ if (acceptsValue(control, options, variable.value)) {
+ field.defaultValue = variable.value
+ } else if (key === PROMPT_FIELD_KEY && initialPrompt.trim() !== '') {
+ // The author declared the platform's own prompt key. The platform does not offer its field
+ // then, but it still hands its default to theirs — and only when they left the value empty.
+ const initial = initialPrompt.trim()
+ if (acceptsValue(control, options, initial)) field.defaultValue = initial
+ }
+ return field
+}
+
+/** Maps one dotted global variable onto a form field. */
+function globalVariableField(variable: WorkflowGlobalVariable): WorkflowRunField {
+ const control = CONTROL_BY_VALUE_TYPE[variable.valueType] ?? 'textarea'
+ const field: WorkflowRunField = {
+ key: variable.name,
+ label: variable.name,
+ required: false,
+ control,
+ options: [],
+ }
+ if (acceptsValue(control, [], variable.value)) field.defaultValue = variable.value
+ return field
+}
+
+/** Resolves whether the workflow's declaration still asks for one platform launch field. */
+function launchFieldAsks(fields: readonly WorkflowLaunchField[], key: string): boolean {
+ return fields.find((field) => field.key === key)?.enabled ?? true
+}
+
+/** Resolves whether the declaration insists on an answer, keeping the platform's own default. */
+function launchFieldRequired(fields: readonly WorkflowLaunchField[], key: string): boolean {
+ const declared = fields.find((field) => field.key === key)
+ if (declared?.required !== undefined) return declared.required
+ return WORKFLOW_LAUNCH_FIELD_DEFAULTS.find((entry) => entry.key === key)?.required ?? false
+}
diff --git a/frontend/src/features/workflows/translations.ts b/frontend/src/features/workflows/translations.ts
index 53bcab868..a6eb964ed 100644
--- a/frontend/src/features/workflows/translations.ts
+++ b/frontend/src/features/workflows/translations.ts
@@ -392,6 +392,17 @@ export const workflowTranslations = {
'workflows.run.runHint': '对已发布快照执行一次这个工作流',
'workflows.run.noSnapshotHint': '请先发布一个版本,才能运行这个工作流',
'workflows.run.noPublishedSnapshot': '这个工作流还没有已发布的版本,无法运行。',
+ 'workflows.run.startTitle': '运行这个工作流',
+ 'workflows.run.startDescription': '选择要运行的版本,填写该版本的输入后启动一次运行。',
+ 'workflows.run.project': '项目',
+ 'workflows.run.chooseProject': '选择一个项目',
+ 'workflows.run.projectRequired': '先选一个项目:运行的工作区建在这个项目里。',
+ 'workflows.run.noProjects': '这个空间还没有项目,先创建一个项目再运行。',
+ 'workflows.run.projectsUnavailable': '项目列表加载失败,请刷新后重试。',
+ 'workflows.run.version': '版本',
+ 'workflows.run.start': '开始运行',
+ 'workflows.run.noInput': '这个版本没有需要填写的输入。',
+ 'workflows.run.missingRequired': '还需要填写:{{fields}}',
'workflows.run.history': '运行历史',
'workflows.run.historyHint': '查看这个工作流的运行历史',
'workflows.run.historyEmpty': '还没有运行记录。',
@@ -425,6 +436,10 @@ export const workflowTranslations = {
'workflows.errors.nameBlank': '工作流名称不能为空。',
'workflows.errors.notFound': '未找到该工作流。',
'workflows.errors.stale': '该工作流已被他人修改,请刷新后重试。',
+ 'workflows.errors.requiredField': '还有必填项没有填写,请补齐后再运行。',
+ 'workflows.errors.invalidField': '有一项输入不符合所选版本的要求,请按字段提示修改后重试。',
+ 'workflows.errors.executorUnconfigured':
+ '所选版本引用的执行器槽位还没有配置。请先在模型设置里为每个槽位选择执行器,再回来运行。',
'workflows.errors.unknown': '操作失败,请稍后重试。',
},
'en-US': {
@@ -809,6 +824,20 @@ export const workflowTranslations = {
'workflows.run.runHint': 'Execute this workflow once against a published snapshot',
'workflows.run.noSnapshotHint': 'Publish a version before running this workflow',
'workflows.run.noPublishedSnapshot': 'This workflow has no published version to run against.',
+ 'workflows.run.startTitle': 'Run this workflow',
+ 'workflows.run.startDescription':
+ 'Choose the version to run, answer what it asks for, and start one run.',
+ 'workflows.run.project': 'Project',
+ 'workflows.run.chooseProject': 'Choose a Project',
+ 'workflows.run.projectRequired':
+ 'Choose a Project first: the run’s Workspace is provisioned in it.',
+ 'workflows.run.noProjects': 'This space has no Project yet. Create one, then start the run.',
+ 'workflows.run.projectsUnavailable':
+ 'The Project list could not be loaded. Refresh and try again.',
+ 'workflows.run.version': 'Version',
+ 'workflows.run.start': 'Start run',
+ 'workflows.run.noInput': 'This version asks for no input.',
+ 'workflows.run.missingRequired': 'Still needed: {{fields}}',
'workflows.run.history': 'Run history',
'workflows.run.historyHint': 'Browse this workflow’s run history',
'workflows.run.historyEmpty': 'No runs yet.',
@@ -841,6 +870,11 @@ export const workflowTranslations = {
'workflows.errors.nameBlank': 'Workflow name cannot be blank.',
'workflows.errors.notFound': 'Workflow not found.',
'workflows.errors.stale': 'This workflow changed elsewhere; reload and try again.',
+ 'workflows.errors.requiredField': 'A required field is still empty. Fill it in and run again.',
+ 'workflows.errors.invalidField':
+ 'One value does not fit the selected version. Fix the field it names and try again.',
+ 'workflows.errors.executorUnconfigured':
+ 'The selected version references an executor slot you have not configured. Choose an executor for each slot in model settings, then run again.',
'workflows.errors.unknown': 'Something went wrong. Try again in a moment.',
},
} satisfies TranslationBundle
diff --git a/frontend/src/test/README.en.md b/frontend/src/test/README.en.md
index 49434f603..f57b10454 100644
--- a/frontend/src/test/README.en.md
+++ b/frontend/src/test/README.en.md
@@ -16,7 +16,7 @@ Environment setup and test doubles shared by every test. The goal is that each t
| `issue-fixtures.ts` | `makeIssue` / `makeStatus` / `makeProject` / `makeAgentTarget`: real-contract issue, status, project and Task Mode directory fixtures. |
| `issue-fixtures.test.ts` | Verifies the fixtures' defaults and override precedence, which other tests rely on. |
| `msw-server.ts` | MSW node server: the mock-domain handlers plus a baseline `GET /api/v1/me → 401` (every render starts signed out) and `GET /auth/providers → ['github']` (a production-shaped gateway; tests of the developer login override it). |
-| `cloud-handlers.ts` | Shared MSW doubles for the session probe and the `cloud-dev` tenant space returned by `/me/spaces`, plus fixtures such as `TEST_USER`. |
+| `cloud-handlers.ts` | Shared MSW doubles for the session probe and the `cloud-dev` tenant space returned by `/me/spaces`, `projectFixture` / `installSpaceProjectHandlers` for that space's Projects, fixtures such as `TEST_USER`, and `chooseProject` for selecting the fixture Project in a run's start dialog. |
| `navigation.ts` | `installFakeNavigation()`: swaps external navigation and new-tab opening, recording `destinations` and `openedTabs`; restored when the test finishes. |
| `polling.ts` | `settleUntil(check)`: retries an assertion while `setInterval` is faked (a polling test advances its query that way) — Testing Library's `waitFor` polls on `setInterval` itself and cannot run then. |
| `polling.test.ts` | Verifies that it retries until the assertion holds and rethrows the last failure once the attempts run out, which other polling tests rely on. |
diff --git a/frontend/src/test/README.md b/frontend/src/test/README.md
index ed185e5cd..1ec43bb4b 100644
--- a/frontend/src/test/README.md
+++ b/frontend/src/test/README.md
@@ -16,7 +16,7 @@
| `issue-fixtures.ts` | `makeIssue` / `makeStatus` / `makeProject` / `makeAgentTarget`:构造真实契约形状的任务、状态列、项目及 Task Mode 目录夹具。 |
| `issue-fixtures.test.ts` | 验证夹具的默认值与 overrides 优先级,其它测试依赖这些行为。 |
| `msw-server.ts` | MSW node server:mock 域 handler 加上基线 `GET /api/v1/me → 401`(任何渲染默认未登录)与 `GET /auth/providers → ['github']`(生产形态的网关;开发者登录的测试会覆盖它)。 |
-| `cloud-handlers.ts` | 云流程共享 MSW 替身:会话探测及 `/me/spaces` 返回的 `cloud-dev` 租户空间,并提供 `TEST_USER` 等 fixture。 |
+| `cloud-handlers.ts` | 云流程共享 MSW 替身:会话探测及 `/me/spaces` 返回的 `cloud-dev` 租户空间,`projectFixture` / `installSpaceProjectHandlers` 描述该空间的项目,并提供 `TEST_USER` 等 fixture 与 `chooseProject`(在启动运行对话框里选中夹具项目)。 |
| `navigation.ts` | `installFakeNavigation()`:替换外部跳转与新标签页打开,记录 `destinations` 与 `openedTabs`;测试结束自动还原。 |
| `polling.ts` | `settleUntil(check)`:在 `setInterval` 被伪造(轮询测试要推进查询)时重试断言——此时 Testing Library 的 `waitFor` 自己也在用 `setInterval`,无法工作。 |
| `polling.test.ts` | 验证重试到成立、以及用尽重试后抛出最后一次失败,其它轮询测试依赖这些行为。 |
diff --git a/frontend/src/test/cloud-handlers.ts b/frontend/src/test/cloud-handlers.ts
index 229dc51b4..1831e4fbb 100644
--- a/frontend/src/test/cloud-handlers.ts
+++ b/frontend/src/test/cloud-handlers.ts
@@ -1,6 +1,10 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
+import { expect } from 'vitest'
import type {
GitCredential,
+ Project,
ProjectRepository,
RepositoryOperation,
Workflow,
@@ -164,6 +168,54 @@ export function installWorkflowPublish(
/** The Project the repository fixtures belong to. */
export const TEST_PROJECT_ID = '55555555-5555-5555-5555-555555555555'
+
+/** One Project of the shared `cloud-dev` space, active unless a test says otherwise. */
+export function projectFixture(overrides: Partial = {}): Project {
+ return {
+ id: TEST_PROJECT_ID,
+ tenantId: TEST_TENANT_ID,
+ spaceId: TEST_SPACE_ID,
+ name: 'Cloud Dev',
+ repositoryUrl: 'https://example.com/acme/cloud.git',
+ defaultBranch: 'main',
+ credentialRefId: null,
+ lifecycle: 'active',
+ ownerUserId: TEST_USER_ID,
+ version: 1,
+ createdAt: '2026-09-20T10:00:00+08:00',
+ deletedAt: null,
+ ...overrides,
+ }
+}
+
+/**
+ * Installs the space's Project list, which surfaces that pick a Project to act
+ * in read. {@link installCloudSpaceHandlers} supplies the space itself; this
+ * answers the projects query scoped by its id.
+ */
+export function installSpaceProjectHandlers(projects: Project[] = [projectFixture()]): void {
+ server.use(
+ http.get(`/api/v1/tenants/${TEST_TENANT_ID}/spaces/${TEST_SPACE_ID}/projects`, () =>
+ HttpResponse.json({ items: projects, nextCursor: '' }),
+ ),
+ )
+}
+
+/**
+ * Picks the fixture Project in a run's start dialog.
+ *
+ * A run must state the Project its Workspace provisions in, and the dialog offers no default, so a
+ * test that starts a run goes through this first. The trigger stays disabled until the space's
+ * Project list resolves, hence the wait before opening it.
+ */
+export async function chooseProject(name = 'Cloud Dev'): Promise {
+ const user = userEvent.setup()
+ const picker = await screen.findByRole('combobox', { name: '项目' })
+ await waitFor(() => expect(picker).toBeEnabled())
+ await user.click(picker)
+ await user.click(await screen.findByRole('option', { name }))
+}
+
/** The Binding the repository fixtures expose. */
export const TEST_BINDING_ID = '66666666-6666-6666-6666-666666666666'
/** The credential the repository fixtures bind and offer for publication. */
diff --git a/integration/workflow_run_test.go b/integration/workflow_run_test.go
index de9a3e2fc..324003550 100644
--- a/integration/workflow_run_test.go
+++ b/integration/workflow_run_test.go
@@ -93,6 +93,13 @@ func TestWorkflowRunLifecycle(t *testing.T) {
if run.S("snapshotId") != snapID {
t.Fatalf("run pinned wrong snapshot: %v", run)
}
+ // The run freezes the Project the request named, so its Workspace provisions there. The run
+ // summary does not project project_id, so the read-back acceptance reads the stored row.
+ var storedProject string
+ must(t, f.store.Pool.QueryRow("SELECT project_id::text FROM workflow_runs WHERE id=$1", run.S("id")).Scan(&storedProject))
+ if storedProject != pid {
+ t.Fatalf("run froze project %q; want %q", storedProject, pid)
+ }
states := run.O("nodeStates")
if states.O("start").S("status") != "succeeded" || states.O("agent-1").S("status") != "succeeded" {
t.Fatalf("run node_states missing nodes: %v", states)