diff --git a/frontend/src/features/workflows/api.ts b/frontend/src/features/workflows/api.ts index 88df80a81..d8defcb90 100644 --- a/frontend/src/features/workflows/api.ts +++ b/frontend/src/features/workflows/api.ts @@ -574,10 +574,9 @@ export interface CreateWorkflowRunInput { /** * The Project the run's Workspace provisions in. The contract requires it — a run that names * none is refused with 409 `workflow_run_project_required` — so the mutation refuses a caller - * that has none instead of sending a body the contract does not describe. Optional here only - * because the run form that picks a Project is not built yet. + * that has none instead of sending a body the contract does not describe. */ - projectId?: string + projectId: string /** Optional display name; defaults to the workflow's name. */ name?: string /** The exact snapshot to execute, or omit to pin the latest published one. */ @@ -597,8 +596,8 @@ export interface CreateWorkflowRunInput { * minting a second execution. * * The run's Project is required by the contract — a run that names none is refused with 409 - * `workflow_run_project_required` — and this editor states none yet: the run form that picks a - * Project is not built, so a run started here is refused until that lands. + * `workflow_run_project_required` — and the run form cannot submit before one is chosen, so the + * refusal here is the backstop for a caller that builds its own request. * * @param tenantId - Tenant the workflow belongs to. * @returns The create mutation; `data` is the stored run. @@ -613,7 +612,7 @@ export function useCreateWorkflowRun(tenantId: string | undefined) { // aggregate that can never provision the Workspace its agent nodes need, so the service // answers this same code (409) and there is no value the client could put in the field // instead — the caller either names a Project or names none. - if (input.projectId === undefined || input.projectId === '') { + if (input.projectId === '') { throw new Error('workflow_run_project_required') } return postApiV1TenantsTidWorkflowsWfidRuns( diff --git a/frontend/src/features/workflows/editor/workflow-editor.test.tsx b/frontend/src/features/workflows/editor/workflow-editor.test.tsx index 8d2ce065f..bbe9c3876 100644 --- a/frontend/src/features/workflows/editor/workflow-editor.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-editor.test.tsx @@ -4,7 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Workflow, WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' import { createDefaultWorkflowCapabilities } from '@/features/workflows/runtime/capabilities' import { WorkflowEditor } from '@/features/workflows/editor/workflow-editor' -import { installCloudSpaceHandlers, TEST_TENANT_ID, workflowFixture } from '@/test/cloud-handlers' +import { + TEST_PROJECT_ID, + TEST_TENANT_ID, + chooseProject, + installCloudSpaceHandlers, + installSpaceProjectHandlers, + workflowFixture, +} from '@/test/cloud-handlers' import { server } from '@/test/msw-server' import { stubViewportGeometry } from '@/test/viewport' import { renderWithProviders } from '@/test/render' @@ -201,8 +208,12 @@ function renderEditor({ ), ) installCloudSpaceHandlers('admin') + // The run dialog lists the space's Projects, and the editor mounts in the `cloud-dev` space the + // fixtures declare, so the space's Project list has to answer before a run can name one. + installSpaceProjectHandlers() renderWithProviders( , + { slug: 'cloud-dev' }, ) return bodies } @@ -1022,7 +1033,7 @@ describe('WorkflowEditor run tools', () => { expect(screen.getByText('还没有运行记录。')).toBeInTheDocument() }) - it('starts no run from the toolbar, which cannot state the Project a run freezes', async () => { + it('starts the published version in the Project the member picks', async () => { const published: WorkflowSnapshot = { id: 'snap-1', tenantId: TEST_TENANT_ID, @@ -1047,10 +1058,16 @@ describe('WorkflowEditor run tools', () => { }), ) fireEvent.click(runButton) - - // The run form that picks the Project is not built yet, so the mutation refuses the run before - // the request is sent and this capture stays empty. - await waitFor(() => expect(runButton).toBeEnabled()) - expect(bodies).toEqual([]) + // The toolbar button opens the start dialog; the run is created by its own submit, once the + // member has seen — and answered — what the published version asks for. The Project is one of + // those answers: nothing else could name where the run's Workspace provisions. + await chooseProject() + fireEvent.click(await screen.findByRole('button', { name: '开始运行' })) + + // The version's Start node declares nothing, so there is nothing to answer and the run carries + // the empty input the server reads as "use this version's own defaults". + await waitFor(() => + expect(bodies).toEqual([{ projectId: TEST_PROJECT_ID, snapshotId: 'snap-1', input: {} }]), + ) }) }) diff --git a/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx new file mode 100644 index 000000000..e3ae4dd8d --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx @@ -0,0 +1,349 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' +import type { Project, WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' +import { WorkflowRunDialog } from '@/features/workflows/editor/workflow-run-dialog' +import { + TEST_PROJECT_ID, + TEST_TENANT_ID, + installCloudSpaceHandlers, + chooseProject, + installSpaceProjectHandlers, + projectFixture, +} from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { renderWithProviders } from '@/test/render' + +const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' + +/** One Start variable as the editor writes it into the graph. */ +interface StartVariable { + name: string + displayName?: string + fieldType?: string + valueType: string + required?: boolean + options?: string[] + value?: unknown +} + +/** A published version whose Start node declares `variables`. */ +function snapshotOf(options: { + id: string + version: number + variables?: StartVariable[] + prompt?: string +}): WorkflowSnapshot { + return { + id: options.id, + tenantId: TEST_TENANT_ID, + workflowId: WORKFLOW_ID, + name: `版本 ${options.version}`, + version: options.version, + graph: { + 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 }, + }, + createdAt: '2026-09-20T10:00:00+08:00', + } +} + +/** A created run row, so the success path has something to hand back. */ +function runFixture(): WorkflowRun { + return { + id: 'run-new', + tenantId: TEST_TENANT_ID, + workflowId: WORKFLOW_ID, + snapshotId: 'snap-2', + name: '巡检运行', + workflowName: '安全审查', + status: 'pending', + input: {}, + nodeStates: {}, + rounds: [], + error: '', + definitionSnapshot: null, + cancelRequestedAt: null, + startedAt: null, + finishedAt: null, + createdAt: '2026-09-20T10:00:00+08:00', + updatedAt: '2026-09-20T10:00:00+08:00', + } +} + +interface DialogRequests { + bodies: unknown[] + keys: Array +} + +/** + * Serves the two endpoints the dialog reads, recording what the create request carried. + * + * The response is whatever `respond` returns, so one helper covers the accepted start, a refusal + * the member has to act on, and a transport failure a retry follows. + */ +function installDialogHandlers( + snapshots: WorkflowSnapshot[], + respond: () => Response = () => HttpResponse.json({ resource: runFixture() }), +): DialogRequests { + const requests: DialogRequests = { bodies: [], keys: [] } + server.use( + http.get(`/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/snapshots`, () => + HttpResponse.json({ items: snapshots, nextCursor: '' }), + ), + http.post( + `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/runs`, + async ({ request }) => { + requests.bodies.push(await request.json()) + requests.keys.push(request.headers.get('Idempotency-Key')) + return respond() + }, + ), + ) + return requests +} + +/** + * Mounts the dialog open in the `cloud-dev` space, with the started-run callback under the caller's + * control. + * + * The versions and the space's Projects arrive over the network from whatever the handlers were + * given, so mounting takes no lists of its own; `projects` is the space's own list, which a test + * empties to exercise the guidance a Project-less space shows. + */ +function renderDialog( + onStarted = vi.fn<(run: WorkflowRun) => void>(), + projects: Project[] = [projectFixture()], +) { + installCloudSpaceHandlers('admin') + installSpaceProjectHandlers(projects) + renderWithProviders( + {}} + onStarted={onStarted} + />, + { slug: 'cloud-dev' }, + ) + return onStarted +} + +/** The start button, which is the dialog's own submit rather than the toolbar's. */ +function startButton(): HTMLElement { + return screen.getByRole('button', { name: '开始运行' }) +} + +/** + * The start button, once the version list has resolved and the form is mounted. + * + * The dialog reads the published versions over the network, so on the first tick it has nothing to + * offer and renders no submit at all. + */ +function findStartButton(): Promise { + return screen.findByRole('button', { name: '开始运行' }) +} + +describe('WorkflowRunDialog', () => { + it('opens on the newest version and prefills the defaults it declares', async () => { + const versions = [ + snapshotOf({ + id: 'snap-2', + version: 2, + prompt: '总结这次发布', + variables: [ + { name: 'topic', fieldType: 'text-input', valueType: 'string', value: 'release' }, + ], + }), + snapshotOf({ id: 'snap-1', version: 1 }), + ] + installDialogHandlers(versions) + renderDialog() + + // The version selector opens on the newest version, and the fields it asks for are that + // version's own — the prompt it declares plus the variable, already holding its default. + expect(await screen.findByText('v2 · 版本 2')).toBeInTheDocument() + expect(screen.getByDisplayValue('总结这次发布')).toBeInTheDocument() + expect(screen.getByDisplayValue('release')).toBeInTheDocument() + }) + + it('labels the platform’s own field in the reader’s language', async () => { + installDialogHandlers([snapshotOf({ id: 'snap-2', version: 2, prompt: '总结这次发布' })]) + renderDialog() + + // The projection names the prompt field by its translation key, because the platform owns that + // wording and the runtime layer does not know what language the member reads. The surface is + // what turns the key into text — the same key the editor's launch-field table shows. + expect(await screen.findByLabelText(/^提示词/)).toBeInTheDocument() + expect(screen.queryByLabelText(/^prompt/)).not.toBeInTheDocument() + }) + + it('sends the selected version and the answers read from it', async () => { + const versions = [ + snapshotOf({ + id: 'snap-2', + version: 2, + variables: [{ name: 'topic', fieldType: 'text-input', valueType: 'string' }], + }), + ] + const requests = installDialogHandlers(versions) + const onStarted = renderDialog() + + await chooseProject() + fireEvent.change(await screen.findByLabelText(/^topic/), { target: { value: 'release' } }) + fireEvent.click(startButton()) + + await waitFor(() => expect(onStarted).toHaveBeenCalledTimes(1)) + expect(requests.bodies).toEqual([ + { projectId: TEST_PROJECT_ID, snapshotId: 'snap-2', input: { topic: 'release' } }, + ]) + }) + + it('asks the switched-to version’s fields and sends that version', async () => { + const versions = [ + snapshotOf({ + id: 'snap-2', + version: 2, + variables: [{ name: 'topic', fieldType: 'text-input', valueType: 'string' }], + }), + snapshotOf({ id: 'snap-1', version: 1 }), + ] + const requests = installDialogHandlers(versions) + renderDialog() + + await chooseProject() + // The version list is a listbox, not a native ` 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 ( +
+ + +
+ ) +} + +/** 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))} +

+ )} + + + + + + ) +} + +/** + * 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 ( <> + { @@ -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)