From 1f5f05a1c013ace25467c25f1129524b9335c1b4 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 11:31:38 +0800 Subject: [PATCH 1/8] feat(workflows): project a published version's start inputs for a manual run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's run button sends an empty body, so a run executes whatever the published version happened to default to and the member never sees what that version asks for. The version's frozen graph is already client-visible — the snapshot list returns it — so the form can be projected here instead of from a new server route (ora-space/circus#148). run-input.ts is that projection and nothing else: given an envelope it answers which fields the version asks for, which are still unanswered, and what the request's `input` is. It mirrors the server's own projection (workflowInputFields, appendGlobalVariableFields, injectPlatformFields) so the form and the admission gate cannot disagree about what a variable is called, what type it takes or whether it is required. It holds no React and no Issues types, so the projection stays testable on its own and the Workflows feature does not grow a dependency on Issues. Evidence: 19 cases over envelopes built by the real parseWorkflowGraphValue — the prompt leads and is prefilled from the Start node's kickoff prompt, a declared variable wins over a same-named global, an author-declared `prompt` keeps its key and takes that same initial prompt, a select with no options degrades to text rather than failing closed, unnamed variables are skipped, `false` and `0` are sent while blank is not, and required gating reads the author's declaration over the platform fallback. Co-Authored-By: Claude Code --- .../workflows/runtime/run-input.test.ts | 341 ++++++++++++++++++ .../features/workflows/runtime/run-input.ts | 304 ++++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 frontend/src/features/workflows/runtime/run-input.test.ts create mode 100644 frontend/src/features/workflows/runtime/run-input.ts 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..6d4723f15 --- /dev/null +++ b/frontend/src/features/workflows/runtime/run-input.test.ts @@ -0,0 +1,341 @@ +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 ?? [], + }) +} + +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', + 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', required: false, control: 'textarea', options: [] }, + ]) + }) + + 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 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('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..9f516c481 --- /dev/null +++ b/frontend/src/features/workflows/runtime/run-input.ts @@ -0,0 +1,304 @@ +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 + /** 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 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. + * + * @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) + return [...promptFields(graph, declared, start), ...declared, ...globalFields(graph, declared)] +} + +/** + * 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 ?? []) { + 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. + * 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, + required: launchFieldRequired(graph.launchFields, PROMPT_FIELD_KEY), + control: 'textarea', + options: [], + } + const initial = start?.input?.trim() ?? '' + if (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. + */ +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 (!variable.name.includes('.') || variable.name.trim() === '' || seen.has(variable.name)) { + continue + } + seen.add(variable.name) + fields.push(globalVariableField(variable)) + } + return fields +} + +/** + * 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. + */ +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' + } +} + +/** 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.trim() + if (key === '') return undefined + const fieldType = resolveWorkflowInputFieldType(variable) + const options = + fieldType === 'select' ? (variable.options ?? []).filter((option) => option !== '') : [] + // 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: variable.displayName?.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 +} From a00ecc203ad9cf7e9f14742c73450eba6590f5fb Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 11:31:53 +0800 Subject: [PATCH 2/8] feat(workflows): start a run from the selected version's own form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar's run button created a run immediately with an empty body. A run executes one frozen version, so the member has to see which version and answer what it asks for before anything is created; the button now opens a dialog that does both (ora-space/circus#148). The dialog reads the versions from the snapshot list, opens on the newest, and remounts the form per version — which is what discards a previous version's answers instead of carrying them onto fields that may no longer exist. The request object is built once per set of answers and reused for every attempt, so a retry replays one idempotency key and reaches the run the first attempt created rather than starting a second one. A refusal stays on the form: the dialog closes only once the server has returned a run, and the member navigates to that run's page. The three fault codes a direct run can raise through the admission gate (required_field_missing, invalid_field_value, workflow_executor_unconfigured) now have messages. workflow_version_fields_changed is deliberately absent: the gate raises it on the @ confirm path, which a manual run never takes. `repository`, `branch` and `context_refs` are not asked for. The platform defaults all three from the issue context, which a manual run has none of, so where they belong in a direct run's input is an open contract question for ora-space/circus#69 rather than something to settle here. `prompt` is asked for because it is well defined without an issue: the Start node's kickoff prompt is its default. Evidence: the dialog's own cases cover the newest version and its prefilled defaults, the version switch replacing the fields, the required gate refusing to submit, a server refusal reported on the form with nothing created, and a retry replaying one idempotency key. The toolbar cases now go through the dialog and assert the body the server actually receives — `snapshotId` plus the answers — and the app-routes case still proves the started run lands on the real run page under the workspace slug rather than the tenant UUID. Co-Authored-By: Claude Code --- .../workflows/editor/workflow-editor.test.tsx | 10 +- .../editor/workflow-run-dialog.test.tsx | 285 ++++++++++++++++++ .../workflows/editor/workflow-run-dialog.tsx | 244 +++++++++++++++ .../editor/workflow-run-tools.test.tsx | 12 +- .../workflows/editor/workflow-run-tools.tsx | 49 +-- .../src/features/workflows/error-messages.ts | 3 + .../src/features/workflows/translations.ts | 22 ++ 7 files changed, 591 insertions(+), 34 deletions(-) create mode 100644 frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx create mode 100644 frontend/src/features/workflows/editor/workflow-run-dialog.tsx diff --git a/frontend/src/features/workflows/editor/workflow-editor.test.tsx b/frontend/src/features/workflows/editor/workflow-editor.test.tsx index 8d2ce065f..a0afa6cdb 100644 --- a/frontend/src/features/workflows/editor/workflow-editor.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-editor.test.tsx @@ -1047,10 +1047,12 @@ describe('WorkflowEditor run tools', () => { }), ) fireEvent.click(runButton) + // 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. + fireEvent.click(await screen.findByRole('button', { name: '开始运行' })) - // 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 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([{ 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..2c7bd596b --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx @@ -0,0 +1,285 @@ +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 { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' +import { WorkflowRunDialog } from '@/features/workflows/editor/workflow-run-dialog' +import { TEST_TENANT_ID, installCloudSpaceHandlers } 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, + 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, with the started-run callback under the caller's control. + * + * The versions arrive over the network from whatever `installDialogHandlers` was given, so mounting + * takes no list of its own. + */ +function renderDialog(onStarted = vi.fn<(run: WorkflowRun) => void>()) { + installCloudSpaceHandlers('admin') + renderWithProviders( + {}} + onStarted={onStarted} + />, + ) + 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('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() + + fireEvent.change(await screen.findByLabelText(/^topic/), { target: { value: 'release' } }) + fireEvent.click(startButton()) + + await waitFor(() => expect(onStarted).toHaveBeenCalledTimes(1)) + expect(requests.bodies).toEqual([{ 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() + + // The version list is a listbox, not a native ` 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, + snapshot, + onCancel, + onStarted, +}: { + tenantId: string + workflowId: string + snapshot: WorkflowSnapshot + onCancel: () => void + onStarted: (run: WorkflowRun) => void +}) { + const { t } = useTranslation() + const createRun = useCreateWorkflowRun(tenantId) + const fields = useMemo( + () => workflowRunFields(parseWorkflowGraphValue(snapshot.graph)), + [snapshot.graph], + ) + const descriptor = useMemo(() => runFormDescriptor(fields), [fields]) + const [values, setValues] = useState(() => initialWorkflowRunValues(fields)) + const missing = missingWorkflowRunFields(fields, values) + const request = useMemo( + () => ({ workflowId, snapshotId: snapshot.id, input: workflowRunInput(fields, values) }), + [workflowId, 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) => field.label).join('、'), + })} +

+ )} + {createRun.error && ( +

+ {t(workflowErrorKey(createRun.error.response?.data?.code))} +

+ )} + + + + + + ) +} + +/** + * 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[]): FormDescriptor { + return { + formRef: 'workflow-run', + fields: fields.map((field) => ({ + key: field.key, + label: field.label, + 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..be44f5cb0 100644 --- a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx @@ -183,13 +183,12 @@ describe('WorkflowRunTools', () => { await waitFor(() => expect(runButton).toBeEnabled()) fireEvent.click(runButton) + fireEvent.click(await screen.findByRole('button', { name: '开始运行' })) - // 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) + // 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, so the server can freeze that document onto it. + expect(bodies).toEqual([{ snapshotId: 'snap-1', input: {} }]) }) it('lists run history and opens a run from a row', async () => { @@ -239,6 +238,7 @@ describe('workflow run navigation through the app routes', () => { await waitFor(() => expect(runButton).toBeEnabled()) fireEvent.click(runButton) + 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. 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/translations.ts b/frontend/src/features/workflows/translations.ts index 53bcab868..9d3104e70 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -392,6 +392,12 @@ export const workflowTranslations = { 'workflows.run.runHint': '对已发布快照执行一次这个工作流', 'workflows.run.noSnapshotHint': '请先发布一个版本,才能运行这个工作流', 'workflows.run.noPublishedSnapshot': '这个工作流还没有已发布的版本,无法运行。', + 'workflows.run.startTitle': '运行这个工作流', + 'workflows.run.startDescription': '选择要运行的版本,填写该版本的输入后启动一次运行。', + 'workflows.run.version': '版本', + 'workflows.run.start': '开始运行', + 'workflows.run.noInput': '这个版本没有需要填写的输入。', + 'workflows.run.missingRequired': '还需要填写:{{fields}}', 'workflows.run.history': '运行历史', 'workflows.run.historyHint': '查看这个工作流的运行历史', 'workflows.run.historyEmpty': '还没有运行记录。', @@ -425,6 +431,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 +819,13 @@ 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.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 +858,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 From 08ac5c07d78c1f9a0a9ea0f2a85b46271afff084 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 17:14:38 +0800 Subject: [PATCH 3/8] fix(workflows): bound the run form's projection the way the server does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual run form projects the fields from the version's frozen graph itself, and it applied none of the bounds the server applies to the same projection (ora-space/circus#148). It asked about variables the server drops, opened on defaults the gate refuses, and had no field bound at all — so the two sides could disagree about which fields exist, which is either a question whose answer never reaches the run or an answer the run refuses. The bounds are the server's own: `maxFormFields` (100), the 200-byte bound it puts on a field key and on a field label alike, and `maxFieldValueLength` (4000). A variable whose name is outside `validOpaqueToken` is now dropped rather than trimmed into acceptance, because the server drops it — the confirmed values reach the workflow keyed by name, so a rewritten key would feed the run the wrong variable. A variable whose label is over the bound is dropped rather than clipped, which is also what the server does, and the check reads the declared display name untrimmed because that is the value the server decides from. All three count UTF-8 bytes, which is what Go's `len` counts, so the label and value checks are byte checks: sixty-seven Chinese characters are over the label bound where two hundred ASCII ones are not, and 1334 Chinese characters are over the value bound where 4000 ASCII ones are not. The descriptor bound is shared: the declared variables fill it first and the dotted globals take whatever room is left, because the server appends them to one list. The kickoff prompt is the platform's own field and the last one added, so it is what gives way when the author's fields already fill the descriptor — the same point at which the server stops adding its own fields rather than emit an invalid descriptor. Evidence: seven cases in run-input.test.ts cover the hundred-and-first variable being projected and the hundredth not, the prompt giving way, a global left out of a full descriptor, the name grammar (including a padded name, which the server does not trim into a field), the label bound on both sides of the byte boundary, and the value bound on both sides of it. Each rule was falsified by removing it: counting characters instead of bytes fails exactly the two byte-sensitive cases, dropping the grammar fails the two name cases, and removing the descriptor bound fails the three bound cases. Co-Authored-By: Claude Code --- .../workflows/runtime/run-input.test.ts | 122 ++++++++++++++++++ .../features/workflows/runtime/run-input.ts | 71 ++++++++-- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/workflows/runtime/run-input.test.ts b/frontend/src/features/workflows/runtime/run-input.test.ts index 6d4723f15..77b8876f4 100644 --- a/frontend/src/features/workflows/runtime/run-input.test.ts +++ b/frontend/src/features/workflows/runtime/run-input.test.ts @@ -50,6 +50,13 @@ function graphOf(options: { }) } +/** + * 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 ' })) @@ -251,6 +258,121 @@ describe('workflow run form projection', () => { }) }) +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('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), + }) + }) +}) + describe('workflow run form defaults', () => { it('seeds only the defaults the form can render', () => { const fields = workflowRunFields( diff --git a/frontend/src/features/workflows/runtime/run-input.ts b/frontend/src/features/workflows/runtime/run-input.ts index 9f516c481..9f7f758bf 100644 --- a/frontend/src/features/workflows/runtime/run-input.ts +++ b/frontend/src/features/workflows/runtime/run-input.ts @@ -37,6 +37,24 @@ 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`, `maxFieldValueLength` and the 200-byte bound it + * puts on a field key and on a field label 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_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. * @@ -73,7 +91,8 @@ const CONTROL_BY_VALUE_TYPE: Record = { * 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. + * 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 @@ -82,7 +101,13 @@ const CONTROL_BY_VALUE_TYPE: Record = { export function workflowRunFields(graph: WorkflowGraphEnvelope): WorkflowRunField[] { const start = startNodeData(graph) const declared = declaredFields(start) - return [...promptFields(graph, declared, start), ...declared, ...globalFields(graph, declared)] + 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] } /** @@ -163,6 +188,7 @@ function startNodeData(graph: WorkflowGraphEnvelope): WorkflowNodeData | undefin 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) } @@ -204,6 +230,10 @@ function promptFields( * 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, @@ -212,7 +242,8 @@ function globalFields( const fields: WorkflowRunField[] = [] const seen = new Set(declared.map((field) => field.key)) for (const variable of graph.globalVariables) { - if (!variable.name.includes('.') || variable.name.trim() === '' || seen.has(variable.name)) { + 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) @@ -221,12 +252,30 @@ function globalFields( 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) +} + /** * 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. + * 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, @@ -241,7 +290,7 @@ function acceptsValue( case 'select': return typeof value === 'string' && options.includes(value) default: - return typeof value === 'string' + return typeof value === 'string' && utf8Length(value) <= MAX_FIELD_VALUE } } @@ -250,8 +299,14 @@ function startVariableField( variable: WorkflowInputVariable, initialPrompt: string, ): WorkflowRunField | undefined { - const key = variable.name.trim() - if (key === '') return 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' ? (variable.options ?? []).filter((option) => option !== '') : [] @@ -261,7 +316,7 @@ function startVariableField( fieldType === 'select' && options.length === 0 ? 'text' : CONTROL_BY_FIELD_TYPE[fieldType] const field: WorkflowRunField = { key, - label: variable.displayName?.trim() || key, + label: declaredLabel.trim() || key, required: variable.required === true, control, options, From af31568935df4b4e686f3faad6a6f9a37de287a9 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 17:38:43 +0800 Subject: [PATCH 4/8] fix(workflows): offer only the run form's select choices the server keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual run form reads a select's choices from the version's frozen graph and filtered them itself, where the server filters them too (ora-space/circus#148): `inputOptions` drops a choice that is not a string, is empty, is over the same 200-byte bound a field key and a field label carry, repeats one already offered, or falls past `maxFormOptions` (100). The form kept every non-empty one. A choice the server dropped is not a value its gate accepts — a select's answer is matched against the choices the field kept — so the form was offering answers whose only outcome is `400 invalid_field_value`, and a declared default among them opened the form on an answer it refuses. The filter mirrors `inputOptions` in the same order, so the two lists agree choice for choice, and the default check follows from it: a value that is no longer one of the choices is no longer a default either. The byte bound is the same one the key and the label are held to, so two hundred ASCII characters are a legal choice where sixty-seven Chinese ones are not. --- .../workflows/runtime/run-input.test.ts | 62 +++++++++++++++++++ .../features/workflows/runtime/run-input.ts | 41 +++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/workflows/runtime/run-input.test.ts b/frontend/src/features/workflows/runtime/run-input.test.ts index 77b8876f4..a7245f39e 100644 --- a/frontend/src/features/workflows/runtime/run-input.test.ts +++ b/frontend/src/features/workflows/runtime/run-input.test.ts @@ -351,6 +351,50 @@ describe('workflow run form bounds', () => { 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({ @@ -395,6 +439,24 @@ describe('workflow run form defaults', () => { 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({ diff --git a/frontend/src/features/workflows/runtime/run-input.ts b/frontend/src/features/workflows/runtime/run-input.ts index 9f7f758bf..bc5b3bf9e 100644 --- a/frontend/src/features/workflows/runtime/run-input.ts +++ b/frontend/src/features/workflows/runtime/run-input.ts @@ -40,15 +40,16 @@ 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`, `maxFieldValueLength` and the 200-byte bound it - * puts on a field key and on a field label 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. + * 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 @@ -269,6 +270,29 @@ 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. * @@ -308,8 +332,7 @@ function startVariableField( const declaredLabel = variable.displayName ?? '' if (utf8Length(declaredLabel || key) > MAX_FIELD_LABEL) return undefined const fieldType = resolveWorkflowInputFieldType(variable) - const options = - fieldType === 'select' ? (variable.options ?? []).filter((option) => option !== '') : [] + 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 = From 7a9923dd15cf8abcce3bd002ad56ae23ba15e0f2 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 20:03:13 +0800 Subject: [PATCH 5/8] fix(workflows): hold the run form's kickoff prompt default to the value bound The platform's own prompt field prefilled whatever the Start node carried, while every declared default was held to the 4000-byte bound a field value may carry. A prompt past that bound now opens the field empty instead: the server would have clipped it, and half an instruction is not a default a member should be handed to edit. An empty field leaves the run to fall back to the version's own prompt, which is all of it. --- .../features/workflows/runtime/run-input.test.ts | 14 ++++++++++++++ .../src/features/workflows/runtime/run-input.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/workflows/runtime/run-input.test.ts b/frontend/src/features/workflows/runtime/run-input.test.ts index a7245f39e..1ad55e33d 100644 --- a/frontend/src/features/workflows/runtime/run-input.test.ts +++ b/frontend/src/features/workflows/runtime/run-input.test.ts @@ -415,6 +415,20 @@ describe('workflow run form bounds', () => { 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', () => { diff --git a/frontend/src/features/workflows/runtime/run-input.ts b/frontend/src/features/workflows/runtime/run-input.ts index bc5b3bf9e..4064c5b92 100644 --- a/frontend/src/features/workflows/runtime/run-input.ts +++ b/frontend/src/features/workflows/runtime/run-input.ts @@ -200,7 +200,8 @@ function declaredFields(start: WorkflowNodeData | undefined): WorkflowRunField[] * 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. + * 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. @@ -219,8 +220,13 @@ function promptFields( 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 !== '') field.defaultValue = initial + if (initial !== '' && acceptsValue(field.control, [], initial)) field.defaultValue = initial return [field] } From 38772d0b7618262fc3eb5916b10ca645730bfbd0 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Fri, 16 Oct 2026 20:14:48 +0800 Subject: [PATCH 6/8] fix(workflows): label the run form's platform field in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kickoff prompt is the platform's own field, so its wording is the platform's too — the run form showed the literal key `prompt` where the @ form and the editor's launch-field table both say 提示词. The projection now marks such a field with the translation key it is named by, and the dialog resolves it, so the label above the control and the "still missing" line below it agree. --- .../editor/workflow-run-dialog.test.tsx | 11 ++++++++ .../workflows/editor/workflow-run-dialog.tsx | 27 ++++++++++++++++--- .../workflows/runtime/run-input.test.ts | 25 ++++++++++++++++- .../features/workflows/runtime/run-input.ts | 10 +++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx index 2c7bd596b..14f20fbde 100644 --- a/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx @@ -168,6 +168,17 @@ describe('WorkflowRunDialog', () => { 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({ diff --git a/frontend/src/features/workflows/editor/workflow-run-dialog.tsx b/frontend/src/features/workflows/editor/workflow-run-dialog.tsx index d3a92bfb6..6da1ebb21 100644 --- a/frontend/src/features/workflows/editor/workflow-run-dialog.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-dialog.tsx @@ -22,8 +22,10 @@ import { import { DynamicFormRenderer } from '@/features/issues/components/dynamic-form-renderer' import type { FormDescriptor } from '@/features/issues/types' import { useCreateWorkflowRun, useWorkflowSnapshots } from '@/features/workflows/api' +import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import { workflowErrorKey } from '@/features/workflows/error-messages' import { parseWorkflowGraphValue } from '@/features/workflows/runtime/graph-codec' +import type { WorkflowNodeTranslator } from '@/features/workflows/runtime/node-factory' import { initialWorkflowRunValues, missingWorkflowRunFields, @@ -163,12 +165,13 @@ function WorkflowRunForm({ 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), [fields]) + const descriptor = useMemo(() => runFormDescriptor(fields, translate), [fields, translate]) const [values, setValues] = useState(() => initialWorkflowRunValues(fields)) const missing = missingWorkflowRunFields(fields, values) const request = useMemo( @@ -199,7 +202,7 @@ function WorkflowRunForm({ {missing.length > 0 && (

{t('workflows.run.missingRequired', { - fields: missing.map((field) => field.label).join('、'), + fields: missing.map((field) => fieldLabel(field, translate)).join('、'), })}

)} @@ -221,6 +224,19 @@ function WorkflowRunForm({ ) } +/** + * 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. * @@ -229,12 +245,15 @@ function WorkflowRunForm({ * 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[]): FormDescriptor { +function runFormDescriptor( + fields: readonly WorkflowRunField[], + translate: WorkflowNodeTranslator, +): FormDescriptor { return { formRef: 'workflow-run', fields: fields.map((field) => ({ key: field.key, - label: field.label, + label: fieldLabel(field, translate), type: field.control, required: field.required, options: field.options.map((option) => ({ value: option, label: option })), diff --git a/frontend/src/features/workflows/runtime/run-input.test.ts b/frontend/src/features/workflows/runtime/run-input.test.ts index 1ad55e33d..d50b9049c 100644 --- a/frontend/src/features/workflows/runtime/run-input.test.ts +++ b/frontend/src/features/workflows/runtime/run-input.test.ts @@ -65,6 +65,7 @@ describe('workflow run form projection', () => { { key: 'prompt', label: 'prompt', + labelKey: 'workflows.launchFields.prompt', required: false, control: 'textarea', options: [], @@ -81,10 +82,32 @@ describe('workflow run form projection', () => { const fields = workflowRunFields(graphOf({ prompt: ' ' })) expect(fields).toEqual([ - { key: 'prompt', label: 'prompt', required: false, control: 'textarea', options: [] }, + { + 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 }] })) diff --git a/frontend/src/features/workflows/runtime/run-input.ts b/frontend/src/features/workflows/runtime/run-input.ts index 4064c5b92..4c4acb5e9 100644 --- a/frontend/src/features/workflows/runtime/run-input.ts +++ b/frontend/src/features/workflows/runtime/run-input.ts @@ -22,6 +22,15 @@ 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 @@ -216,6 +225,7 @@ function promptFields( 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: [], From 086b46c32dc2c048f8ce5c30ba044d350065e3c8 Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Tue, 20 Oct 2026 22:08:38 +0800 Subject: [PATCH 7/8] feat(workflows): start a run in the Project the member picks A workflow belongs to a space, so nothing but the member can name the Project the run's Workspace provisions in. The start dialog now lists the space's live Projects and states the chosen one on the create request; Start stays disabled until a Project is chosen, and a space with no Project says so instead of offering an empty picker. CreateWorkflowRunInput requires projectId, as the contract does. --- frontend/src/features/workflows/api.ts | 11 +- .../workflows/editor/workflow-editor.test.tsx | 23 ++- .../editor/workflow-run-dialog.test.tsx | 77 ++++++++-- .../workflows/editor/workflow-run-dialog.tsx | 134 ++++++++++++++++-- .../editor/workflow-run-tools.test.tsx | 54 ++++--- .../src/features/workflows/translations.ts | 12 ++ frontend/src/test/README.en.md | 2 +- frontend/src/test/README.md | 2 +- frontend/src/test/cloud-handlers.ts | 52 +++++++ 9 files changed, 312 insertions(+), 55 deletions(-) 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 a0afa6cdb..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, @@ -1048,11 +1059,15 @@ describe('WorkflowEditor run tools', () => { ) fireEvent.click(runButton) // 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. + // 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([{ snapshotId: 'snap-1', input: {} }])) + 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 index 14f20fbde..e3ae4dd8d 100644 --- a/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-dialog.test.tsx @@ -2,9 +2,16 @@ 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 { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' +import type { Project, WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' import { WorkflowRunDialog } from '@/features/workflows/editor/workflow-run-dialog' -import { TEST_TENANT_ID, installCloudSpaceHandlers } from '@/test/cloud-handlers' +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' @@ -71,6 +78,7 @@ function runFixture(): WorkflowRun { rounds: [], error: '', definitionSnapshot: null, + cancelRequestedAt: null, startedAt: null, finishedAt: null, createdAt: '2026-09-20T10:00:00+08:00', @@ -111,13 +119,19 @@ function installDialogHandlers( } /** - * Mounts the dialog open, with the started-run callback under the caller's control. + * Mounts the dialog open in the `cloud-dev` space, with the started-run callback under the caller's + * control. * - * The versions arrive over the network from whatever `installDialogHandlers` was given, so mounting - * takes no list of its own. + * 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>()) { +function renderDialog( + onStarted = vi.fn<(run: WorkflowRun) => void>(), + projects: Project[] = [projectFixture()], +) { installCloudSpaceHandlers('admin') + installSpaceProjectHandlers(projects) renderWithProviders( void>()) { onOpenChange={() => {}} onStarted={onStarted} />, + { slug: 'cloud-dev' }, ) return onStarted } @@ -190,11 +205,14 @@ describe('WorkflowRunDialog', () => { 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([{ snapshotId: 'snap-2', input: { topic: 'release' } }]) + 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 () => { @@ -209,6 +227,7 @@ describe('WorkflowRunDialog', () => { 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, @@ -154,12 +256,14 @@ function versionLabel(versions: readonly WorkflowSnapshot[], snapshotId: string) function WorkflowRunForm({ tenantId, workflowId, + projectId, snapshot, onCancel, onStarted, }: { tenantId: string workflowId: string + projectId: string snapshot: WorkflowSnapshot onCancel: () => void onStarted: (run: WorkflowRun) => void @@ -175,8 +279,13 @@ function WorkflowRunForm({ const [values, setValues] = useState(() => initialWorkflowRunValues(fields)) const missing = missingWorkflowRunFields(fields, values) const request = useMemo( - () => ({ workflowId, snapshotId: snapshot.id, input: workflowRunInput(fields, values) }), - [workflowId, snapshot.id, fields, values], + () => ({ + 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. */ @@ -215,7 +324,10 @@ function WorkflowRunForm({ - 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 be44f5cb0..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,19 +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) + 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, so the server can freeze that document onto it. - expect(bodies).toEqual([{ snapshotId: 'snap-1', input: {} }]) + // 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 () => { @@ -221,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({ @@ -232,20 +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/translations.ts b/frontend/src/features/workflows/translations.ts index 9d3104e70..a6eb964ed 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -394,6 +394,11 @@ export const workflowTranslations = { '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': '这个版本没有需要填写的输入。', @@ -822,6 +827,13 @@ export const workflowTranslations = { '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.', 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. */ From 2f310eb47269701338d214a4fe04350413e830ba Mon Sep 17 00:00:00 2001 From: zhangshilong <1850284800@qq.com> Date: Tue, 20 Oct 2026 22:08:40 +0800 Subject: [PATCH 8/8] test(workflow-run): read the frozen Project back from the run row The acceptance reads back what the run froze. The run summary does not project project_id, so the check reads the stored row directly. --- integration/workflow_run_test.go | 7 +++++++ 1 file changed, 7 insertions(+) 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)