diff --git a/frontend/src/features/workflows/aggregator-translations.ts b/frontend/src/features/workflows/aggregator-translations.ts new file mode 100644 index 00000000..ec37c553 --- /dev/null +++ b/frontend/src/features/workflows/aggregator-translations.ts @@ -0,0 +1,31 @@ +/** + * Messages for the variable aggregator node. + * + * They live beside the feature's main bundle for the same reason the history and launch-field + * bundles do: the group is self-contained — the node's palette identity and the ordered candidate + * list — and the main bundle is held to a line budget. `translations.ts` spreads both locales in. + */ +export const aggregatorTranslations = { + 'zh-CN': { + 'workflows.node.aggregator.label': '变量聚合', + 'workflows.node.aggregator.description': '按顺序取第一个已赋值的候选变量', + 'workflows.aggregator.field.variables': '候选变量', + 'workflows.aggregator.field.candidate': '候选', + 'workflows.aggregator.addCandidate': '添加候选', + 'workflows.aggregator.removeCandidate': '删除候选', + 'workflows.aggregator.moveCandidateUp': '上移候选', + 'workflows.aggregator.moveCandidateDown': '下移候选', + 'workflows.aggregator.selectorPlaceholder': '节点.变量,例如 writer.output', + }, + 'en-US': { + 'workflows.node.aggregator.label': 'Variable aggregator', + 'workflows.node.aggregator.description': 'Pass through the first assigned candidate, in order', + 'workflows.aggregator.field.variables': 'Candidates', + 'workflows.aggregator.field.candidate': 'Candidate', + 'workflows.aggregator.addCandidate': 'Add candidate', + 'workflows.aggregator.removeCandidate': 'Remove candidate', + 'workflows.aggregator.moveCandidateUp': 'Move candidate up', + 'workflows.aggregator.moveCandidateDown': 'Move candidate down', + 'workflows.aggregator.selectorPlaceholder': 'node.variable, for example writer.output', + }, +} diff --git a/frontend/src/features/workflows/editor/node-metadata.ts b/frontend/src/features/workflows/editor/node-metadata.ts index 7ddfa5e7..f36284ac 100644 --- a/frontend/src/features/workflows/editor/node-metadata.ts +++ b/frontend/src/features/workflows/editor/node-metadata.ts @@ -2,9 +2,11 @@ import { ArrowRight, Boxes, Braces, + CornerDownRight, GitBranch, GitMerge, Layers, + Merge, Play, Repeat, UserCheck, @@ -38,6 +40,11 @@ const NODE_METADATA: Record = { icon: GitBranch, tone: 'bg-amber-500/12 text-amber-700 dark:text-amber-400', }, + aggregator: { + kind: 'aggregator', + icon: Merge, + tone: 'bg-orange-500/12 text-orange-700 dark:text-orange-400', + }, tool: { kind: 'tool', icon: Braces, tone: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-400' }, junction: { kind: 'junction', @@ -50,6 +57,11 @@ const NODE_METADATA: Record = { icon: Repeat, tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400', }, + loopExit: { + kind: 'loopExit', + icon: CornerDownRight, + tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400', + }, iteration: { kind: 'iteration', icon: Layers, diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts index 527cb97c..7d35f696 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts @@ -83,6 +83,28 @@ function requireNode(nodes: WorkflowCanvasNode[], id: string): WorkflowCanvasNod } describe('useWorkflowDraft', () => { + it('adds a Loop as one atomic group with its starter body', () => { + // A bare Loop saves but never publishes: the body needs a Start. The palette entry therefore + // emits the container, its Start, its starter Agent and the internal edge together. + const { result } = setup(fixtureWithGraph([outputNode()])) + + act(() => { + result.current.addNode('loop', { x: 0, y: 0 }) + }) + + expect( + result.current.nodes.map((node) => [node.id, node.data.kind, node.data.containerId]), + ).toEqual([ + ['output-1', 'output', undefined], + ['loop-1', 'loop', undefined], + ['loop-1-start', 'start', 'loop-1'], + ['loop-1-agent', 'agent', 'loop-1'], + ]) + expect(result.current.edges.map((edge) => [edge.source, edge.target])).toEqual([ + ['loop-1-start', 'loop-1-agent'], + ]) + }) + it('adds a member through the entry seam, widening the frame and wiring the entry edge', () => { const { result } = setup(draftFixture()) diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.ts b/frontend/src/features/workflows/editor/use-workflow-draft.ts index 86e63313..9a157f5a 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.ts @@ -25,6 +25,7 @@ import { type WorkflowIterationInsertion, } from '@/features/workflows/runtime/iteration-graph' import { + createWorkflowLoopGroup, createWorkflowNode, nextWorkflowNodeSequence, type WorkflowNodeTranslator, @@ -300,6 +301,16 @@ function appendNode( kind, current.nodes.map((node) => node.id), ) + if (kind === 'loop') { + // A Loop without a body is a saveable but unrunnable container; the factory emits the body + // start, a starter agent and the round contract together so the palette entry is executable. + const group = createWorkflowLoopGroup({ kind, sequence, position, translate }) + return { + ...current, + nodes: [...current.nodes, ...group.nodes], + edges: [...current.edges, ...group.edges], + } + } const node = createWorkflowNode({ kind, sequence, position, translate }) return { ...current, nodes: [...current.nodes, node] } } diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx new file mode 100644 index 00000000..192d261b --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' +import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields' +import { renderWithProviders } from '@/test/render' + +/** The node the panel edits; candidates are the only thing it reads. */ +function aggregatorData(variables: string[][]): WorkflowNodeData { + return { + kind: 'aggregator', + title: '变量聚合', + description: '', + aggregatorConfig: { variables }, + } +} + +/** + * Renders the panel and tracks the latest node it published. + * + * `published.data` is what a real editor would persist, so each assertion reads the shape the save + * path would write instead of a captured mock call. + */ +function renderPanel(variables: string[][]) { + const published = { data: aggregatorData(variables) } + const onChange = (next: WorkflowNodeData): void => { + published.data = next + } + const view = renderWithProviders( + , + ) + return { published, onChange, view } +} + +describe('AggregatorNodeFields', () => { + it('adds a candidate and writes its root selector', () => { + const { published, onChange, view } = renderPanel([]) + + fireEvent.click(screen.getByRole('button', { name: '添加候选' })) + expect(published.data.aggregatorConfig?.variables).toEqual([[]]) + + view.rerender() + fireEvent.change(screen.getByLabelText('候选 1'), { target: { value: 'writer.output' } }) + expect(published.data.aggregatorConfig?.variables).toEqual([['writer', 'output']]) + }) + + it('moves a candidate up to change the published priority', () => { + const { published } = renderPanel([ + ['a', 'output'], + ['b', 'output'], + ]) + + fireEvent.click(screen.getByRole('button', { name: '上移候选 2' })) + + expect(published.data.aggregatorConfig?.variables).toEqual([ + ['b', 'output'], + ['a', 'output'], + ]) + }) + + it('keeps the declared order when a candidate is removed', () => { + const { published } = renderPanel([ + ['a', 'output'], + ['b', 'output'], + ]) + + fireEvent.click(screen.getByRole('button', { name: '删除候选 1' })) + + expect(published.data.aggregatorConfig?.variables).toEqual([['b', 'output']]) + }) +}) diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx new file mode 100644 index 00000000..867ca949 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx @@ -0,0 +1,112 @@ +import { useTranslation } from 'react-i18next' +import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { + selectorToText, + textToSelector, +} from '@/features/workflows/editor/workflow-variable-select' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' + +/** Props every Aggregator panel component takes: the node data and the writer for the whole node. */ +export interface AggregatorFieldsProps { + data: WorkflowNodeData + onChange: (data: WorkflowNodeData) => void +} + +/** + * Edits the aggregator's ordered candidates. + * + * Order is the contract: the first assigned candidate is the one the node publishes, so the panel + * never reorders the list on its own and exposes the position as a plain number. A selector is + * `node.root` text until the shared picker replaces it; the publish gate refuses an undeclared or + * nested selector, so a typo is caught before the graph runs. + */ +export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) { + const { t } = useTranslation() + const config = data.aggregatorConfig + if (config === undefined) { + return null + } + const update = (variables: string[][]): void => { + onChange({ ...data, aggregatorConfig: { variables } }) + } + // Order is the published priority, so moving a candidate is a first-class edit rather than a + // re-sort the panel could infer. + const move = (from: number, to: number): void => { + const next = [...config.variables] + const moved = next.splice(from, 1)[0] + if (moved === undefined) { + return + } + next.splice(to, 0, moved) + update(next) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a candidate has no stable identity: its selector is the editable value, so a content key collides on two blank rows. */} + {config.variables.map((selector, index) => ( +
+ {index + 1} + + update( + config.variables.map((current, i) => + i === index ? textToSelector(event.target.value) : current, + ), + ) + } + /> + + + +
+ ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} diff --git a/frontend/src/features/workflows/editor/workflow-choice-select.tsx b/frontend/src/features/workflows/editor/workflow-choice-select.tsx new file mode 100644 index 00000000..a28c1222 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-choice-select.tsx @@ -0,0 +1,70 @@ +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' +import { workflowChoiceLabel, type WorkflowChoice } from '@/features/workflows/runtime/capabilities' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { LocalizedSelectValue } from '@/features/workflows/editor/workflow-variable-select' +import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' + +/** A select whose menu text and closed-state label both come from one choice list. */ +export function WorkflowChoiceSelect({ + id, + value, + options, + disabled, + ariaLabel, + className, + onChange, +}: { + id: string + value: string + options: readonly WorkflowChoice[] + disabled?: boolean + ariaLabel?: string + className?: string + onChange: (value: string | null) => void +}) { + const translate = useWorkflowTranslator() + return ( + + ) +} + +/** A {@link WorkflowChoiceSelect} wrapped in the inspector's label block. */ +export function LocalizedChoiceSelect({ + id, + label, + value, + options, + disabled, + onChange, +}: { + id: string + label: string + value: string + options: readonly WorkflowChoice[] + disabled?: boolean + onChange: (value: string | null) => void +}) { + return ( + + + + ) +} diff --git a/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx b/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx index 5db95ffd..5610b534 100644 --- a/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx +++ b/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx @@ -11,19 +11,13 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Select, SelectTrigger, SelectValue } from '@/components/ui/select' import { normalizeWorkflowGlobalVariables } from '@/features/workflows/runtime/variable-catalog' -import { - WORKFLOW_VARIABLE_VALUE_TYPES, - type WorkflowGlobalVariable, - type WorkflowVariableValueType, +import type { + WorkflowGlobalVariable, + WorkflowVariableValueType, } from '@/features/workflows/runtime/types' +import { WorkflowVariableTypeMenu } from '@/features/workflows/editor/workflow-variable-select' import { formatWorkflowVariableValue, isWorkflowVariableValueType, @@ -256,13 +250,7 @@ function GlobalVariableRow({ - - {WORKFLOW_VARIABLE_VALUE_TYPES.map((valueType) => ( - - {valueType} - - ))} - + } + if (data.kind === 'aggregator') { + return + } if (data.kind === 'tool') { return } @@ -189,6 +194,9 @@ function NodeKindFields({ /> ) } + if (data.kind === 'loopExit') { + return + } if (data.kind === 'subflow') { return } diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx index 2a051546..52e3a385 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx @@ -13,10 +13,11 @@ import { ConditionNodeFields } from '@/features/workflows/editor/workflow-condit import { HumanNodeFields, JunctionNodeFields, - LoopNodeFields, + LoopExitNodeFields, SubflowNodeFields, ToolNodeFields, } from '@/features/workflows/editor/workflow-kind-fields' +import { LoopNodeFields } from '@/features/workflows/editor/workflow-loop-fields' import { IterationNodeFields } from '@/features/workflows/editor/workflow-iteration-fields' import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' import { renderWithProviders } from '@/test/render' @@ -324,7 +325,8 @@ describe('LoopNodeFields', () => { outputs: [], } - it('clamps the iteration ceiling and writes the carried initial value', () => { + /** Renders the panel with the shared config; `rerenderPanel` rebinds it to the captured node. */ + function setUpLoopPanel() { const { data, onChange } = captureOnChange() const view = renderWithProviders( { onChange={onChange} />, ) + const rerenderPanel = (): void => { + view.rerender() + } + return { data, rerenderPanel } + } + + it('clamps the round ceiling and writes a variable first-round value', () => { + const { data, rerenderPanel } = setUpLoopPanel() + expect(screen.getByLabelText('轮次耗尽时')).toHaveTextContent('失败') fireEvent.change(screen.getByLabelText('最大轮次'), { target: { value: '55' } }) expect(data.loopConfig?.maxIterations).toBe(55) - view.rerender() - fireEvent.change(screen.getByLabelText('初始值'), { target: { value: 'ready' } }) - expect(data.loopConfig?.variables[0]?.initial).toEqual({ - kind: 'constant', - value: 'ready', + rerenderPanel() + fireEvent.change(screen.getByLabelText('初值 1'), { target: { value: 'ready' } }) + expect(data.loopConfig?.variables[0]?.initial).toEqual({ kind: 'constant', value: 'ready' }) + }) + + it('adds a carried variable and records its feedback selector', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加变量' })) + expect(data.loopConfig?.variables).toHaveLength(2) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('变量名 2'), { target: { value: 'draft' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('反馈 2'), { target: { value: 'writer.output' } }) + expect(data.loopConfig?.variables[1]).toMatchObject({ + name: 'draft', + feedback: ['writer', 'output'], + }) + + fireEvent.click(screen.getByRole('button', { name: '删除变量 1' })) + expect(data.loopConfig?.variables).toHaveLength(1) + expect(data.loopConfig?.variables[0]?.name).toBe('draft') + }) + + it('adds a named output with its selector', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加输出' })) + expect(data.loopConfig?.outputs).toHaveLength(1) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('输出名 1'), { target: { value: 'result' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('取值 1'), { target: { value: 'writer.output' } }) + expect(data.loopConfig?.outputs[0]).toEqual({ + name: 'result', + variableSelector: ['writer', 'output'], }) }) + it('adds a round-end rule and records its selector, operator and value', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加条件' })) + expect(data.loopConfig?.until.conditions).toHaveLength(1) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('变量 1'), { target: { value: 'writer.output' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('值 1'), { target: { value: 'done' } }) + expect(data.loopConfig?.until.conditions[0]).toEqual({ + variableSelector: ['writer', 'output'], + operator: 'equals', + value: 'done', + }) + + fireEvent.click(screen.getByRole('button', { name: '删除条件 1' })) + expect(data.loopConfig?.until.conditions).toHaveLength(0) + }) + it('hides the config inputs for a legacy loop without executable config', () => { const onChange = vi.fn<(data: WorkflowNodeData) => void>() renderWithProviders() expect(screen.getByLabelText('最大轮次')).toBeDisabled() - expect(screen.getByLabelText('初始值')).toBeDisabled() + expect(screen.getByLabelText('轮次耗尽时')).toBeDisabled() + expect(screen.queryByLabelText('变量名 1')).not.toBeInTheDocument() expect(screen.getByText('此旧循环节点缺少可执行配置,请删除后重新添加。')).toBeInTheDocument() }) }) +describe('LoopExitNodeFields', () => { + it('explains that the marker has no configuration of its own', () => { + renderWithProviders() + + expect(screen.getByText(/循环体内的终端节点/)).toBeInTheDocument() + }) +}) + describe('IterationNodeFields', () => { const ITERATION_CONFIG: WorkflowIterationConfig = { iteratorSelector: ['iteration-1', 'output'], diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx index 7a281394..dc2fd9e8 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx @@ -19,15 +19,13 @@ import type { WorkflowNodeData, WorkflowToolParameter, } from '@/features/workflows/runtime/types' -import type { TranslationKey } from '@/i18n/i18n-instance' import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' -import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' -import { - LocalizedSelectValue, - comparisonValueToText, -} from '@/features/workflows/editor/workflow-variable-select' +import type { TranslationKey } from '@/i18n/i18n-instance' +import { LocalizedChoiceSelect } from '@/features/workflows/editor/workflow-choice-select' +import { LocalizedSelectValue } from '@/features/workflows/editor/workflow-variable-select' +import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' /** * Everything the kind-specific panels may read beyond the selected node itself. @@ -214,33 +212,17 @@ export function JunctionNodeFields({ const { t } = useTranslation() return ( <> - - - + value={data.waitStrategy ?? 'all'} + options={WORKFLOW_JUNCTION_WAIT_CHOICES} + onChange={(strategy) => { + if (strategy === 'all' || strategy === 'any' || strategy === 'count') { + onChange({ ...data, waitStrategy: strategy }) + } + }} + /> {data.waitStrategy === 'count' && ( )} - - - + value={data.failureStrategy ?? 'fail'} + options={WORKFLOW_JUNCTION_FAILURE_CHOICES} + onChange={(strategy) => { + if (strategy === 'fail' || strategy === 'continue') { + onChange({ ...data, failureStrategy: strategy }) + } + }} + /> ) } @@ -337,90 +303,13 @@ export function HumanNodeFields({ ) } -/** Edits a Loop container: iteration ceiling, carried initial value, and a behavior note. */ -export function LoopNodeFields({ - data, - onChange, -}: Pick) { +/** Explains the loopExit marker: it ends the round and carries no configuration of its own. */ +export function LoopExitNodeFields() { const { t } = useTranslation() - const loopConfig = data.loopConfig - const carriedVariable = loopConfig?.variables[0] - // A constant may have been imported as a number, boolean, or object, so the - // shared JSON value renderer turns it back into editable text safely. - const initialValue = - carriedVariable?.initial.kind === 'constant' - ? comparisonValueToText(carriedVariable.initial.value) - : '' - const updateMaxIterations = (value: number): void => { - if (loopConfig === undefined) { - return - } - onChange({ - ...data, - loopConfig: { - ...loopConfig, - maxIterations: Math.min(100, Math.max(1, Math.trunc(value))), - }, - }) - } - const updateInitialValue = (value: string): void => { - if (loopConfig === undefined || carriedVariable === undefined) { - return - } - onChange({ - ...data, - loopConfig: { - ...loopConfig, - variables: [ - { - ...carriedVariable, - initial: { kind: 'constant', value }, - }, - ...loopConfig.variables.slice(1), - ], - }, - }) - } return ( - <> - - { - const parsed = Number(event.target.value) - if (Number.isFinite(parsed)) { - updateMaxIterations(parsed) - } - }} - /> - - - updateInitialValue(event.target.value)} - /> - -

- {t( - loopConfig === undefined - ? 'workflows.loop.legacyUnsupported' - : 'workflows.loop.defaultBehavior', - )} -

- +

+ {t('workflows.node.loopExit.description')} +

) } diff --git a/frontend/src/features/workflows/editor/workflow-loop-fields.tsx b/frontend/src/features/workflows/editor/workflow-loop-fields.tsx new file mode 100644 index 00000000..077003bb --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-loop-fields.tsx @@ -0,0 +1,510 @@ +import { useTranslation } from 'react-i18next' +import { Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + createDefaultWorkflowCapabilities, + type WorkflowChoice, +} from '@/features/workflows/runtime/capabilities' +import type { + WorkflowConditionComparison, + WorkflowLoopConfig, + WorkflowLoopVariable, + WorkflowNodeData, + WorkflowOutputBinding, +} from '@/features/workflows/runtime/types' +import type { TranslationKey } from '@/i18n/i18n-instance' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { + LocalizedChoiceSelect, + WorkflowChoiceSelect, +} from '@/features/workflows/editor/workflow-choice-select' +import { + WorkflowVariableTypeMenu, + comparisonValueToText, + parseComparisonValue, + selectorToText, + textToSelector, +} from '@/features/workflows/editor/workflow-variable-select' + +/** Props every Loop panel component takes: the node data and the writer for the whole node. */ +interface LoopFieldsProps { + data: WorkflowNodeData + onChange: (data: WorkflowNodeData) => void +} +/** Exhaustion policies of a Loop, keyed to the graph's enum names. */ +const WORKFLOW_LOOP_BEHAVIOR_CHOICES = [ + { value: 'fail', labelKey: 'workflows.loop.behavior.fail' }, + { value: 'succeed', labelKey: 'workflows.loop.behavior.succeed' }, +] as const satisfies readonly WorkflowChoice[] + +/** Edits a Loop container: budget, carried initial value, and the behavior note. */ +export function LoopNodeFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + return ( + <> + + + + +

+ {t( + loopConfig === undefined + ? 'workflows.loop.legacyUnsupported' + : 'workflows.loop.defaultBehavior', + )} +

+ + ) +} + +/** Edits the round ceiling and what an exhausted budget does. */ +function LoopBudgetFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + const updateMaxIterations = (value: number): void => { + if (loopConfig === undefined) { + return + } + onChange({ + ...data, + loopConfig: { + ...loopConfig, + maxIterations: Math.min(100, Math.max(1, Math.trunc(value))), + }, + }) + } + const updateMaxIterationsBehavior = (value: string | null): void => { + if (loopConfig === undefined || (value !== 'fail' && value !== 'succeed')) { + return + } + onChange({ + ...data, + loopConfig: { ...loopConfig, maxIterationsBehavior: value }, + }) + } + return ( + <> + + { + const parsed = Number(event.target.value) + if (Number.isFinite(parsed)) { + updateMaxIterations(parsed) + } + }} + /> + + + + ) +} + +/** Parses a typed constant: text stays text for string types, otherwise it is JSON-read. */ +function loopConstantValue(text: string, valueType: string): unknown { + return valueType === 'string' || valueType === 'secret' ? text : parseComparisonValue(text) +} + +/** Builds the empty carried variable a new row starts from. */ +function blankLoopVariable(): WorkflowLoopVariable { + return { name: '', valueType: 'string', initial: { kind: 'constant', value: '' }, feedback: [] } +} + +/** Edits every carried variable: its name, type, first-round value and feedback selector. */ +function LoopVariablesFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const update = (variables: WorkflowLoopVariable[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, variables } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a carried variable has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.variables.map((variable, index) => ( + + update(loopConfig.variables.map((current, i) => (i === index ? next : current))) + } + onRemove={() => update(loopConfig.variables.filter((_, i) => i !== index))} + /> + ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} + +/** One carried variable's fields; the index only labels the controls for assistive technology. */ +function LoopVariableRow({ + index, + variable, + onChange, + onRemove, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const label = (key: TranslationKey): string => `${t(key)} ${index + 1}` + return ( +
+
+ onChange({ ...variable, name: event.target.value })} + /> + +
+
+ + +
+ + + onChange({ ...variable, feedback: textToSelector(event.target.value) }) + } + /> +
+ ) +} + +/** + * The first-round value: a typed constant, or a reference resolved once before the loop starts. + * Switching kinds replaces the value, because a constant and a selector cannot be converted into + * each other without guessing what the author meant. + */ +function LoopInitialValueInput({ + index, + variable, + onChange, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void +}) { + const { t } = useTranslation() + const label = `${t('workflows.loop.field.variableInitial')} ${index + 1}` + if (variable.initial.kind === 'constant') { + return ( + + onChange({ + ...variable, + initial: { + kind: 'constant', + value: loopConstantValue(event.target.value, variable.valueType), + }, + }) + } + /> + ) + } + return ( + + onChange({ + ...variable, + initial: { kind: 'variable', selector: textToSelector(event.target.value) }, + }) + } + /> + ) +} + +/** How a Loop's round-end rules combine. */ +const LOOP_UNTIL_LOGIC_CHOICES = [ + { value: 'and', labelKey: 'workflows.condition.logicAnd' }, + { value: 'or', labelKey: 'workflows.condition.logicOr' }, +] as const satisfies readonly WorkflowChoice[] + +/** Operators that answer a question about the value, so they take no expected value. */ +const LOOP_OPERATORS_WITHOUT_VALUE = new Set(['exists', 'not_exists', 'empty', 'not_empty']) + +/** Edits the round-end condition: how rules combine and the rules themselves. */ +function LoopUntilFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const until = loopConfig.until + const update = (next: WorkflowLoopConfig['until']): void => { + onChange({ ...data, loopConfig: { ...loopConfig, until: next } }) + } + return ( + +
+ { + if (logic === 'and' || logic === 'or') { + update({ ...until, logic }) + } + }} + /> + {/* oxlint-disable react/no-array-index-key -- a rule has no stable id; its selector is the editable identity, so a content key collides on a blank new rule. */} + {until.conditions.map((condition, index) => ( + + update({ + ...until, + conditions: until.conditions.map((current, i) => (i === index ? next : current)), + }) + } + onRemove={() => + update({ + ...until, + conditions: until.conditions.filter((_, i) => i !== index), + }) + } + /> + ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} + +/** One round-end comparison: a variable, an operator and, for value operators, the expected value. */ +function LoopUntilRow({ + index, + condition, + onChange, + onRemove, +}: { + index: number + condition: WorkflowConditionComparison + onChange: (condition: WorkflowConditionComparison) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const capabilities = createDefaultWorkflowCapabilities() + return ( +
+
+ IF {index + 1} + +
+ + onChange({ ...condition, variableSelector: textToSelector(event.target.value) }) + } + /> + { + if (operator !== null) { + onChange({ ...condition, operator }) + } + }} + /> + {LOOP_OPERATORS_WITHOUT_VALUE.has(condition.operator) ? null : ( + + onChange({ ...condition, value: parseComparisonValue(event.target.value) }) + } + /> + )} +
+ ) +} + +/** Edits the named results the loop publishes when it succeeds. */ +function LoopOutputsFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const update = (outputs: WorkflowOutputBinding[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, outputs } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.outputs.map((output, index) => ( + // oxlint-disable-next-line react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. +
+ + update( + loopConfig.outputs.map((current, i) => + i === index ? { ...current, name: event.target.value } : current, + ), + ) + } + /> + + update( + loopConfig.outputs.map((current, i) => + i === index + ? { ...current, variableSelector: textToSelector(event.target.value) } + : current, + ), + ) + } + /> + +
+ ))} +
+ +
+ ) +} diff --git a/frontend/src/features/workflows/editor/workflow-node-card.tsx b/frontend/src/features/workflows/editor/workflow-node-card.tsx index dd86301a..60b1fbe2 100644 --- a/frontend/src/features/workflows/editor/workflow-node-card.tsx +++ b/frontend/src/features/workflows/editor/workflow-node-card.tsx @@ -86,13 +86,15 @@ export const WorkflowNodeCard = memo(function WorkflowNodeCard({ } /> - + {data.kind !== 'loopExit' && ( + + )} ) }) diff --git a/frontend/src/features/workflows/editor/workflow-variable-select.tsx b/frontend/src/features/workflows/editor/workflow-variable-select.tsx index 90a8825b..9e11cb21 100644 --- a/frontend/src/features/workflows/editor/workflow-variable-select.tsx +++ b/frontend/src/features/workflows/editor/workflow-variable-select.tsx @@ -1,6 +1,13 @@ import { Variable as VariableIcon } from 'lucide-react' -import { SelectGroup, SelectItem, SelectLabel, SelectValue } from '@/components/ui/select' +import { + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectValue, +} from '@/components/ui/select' import { workflowChoiceLabel, type WorkflowChoice } from '@/features/workflows/runtime/capabilities' +import { WORKFLOW_VARIABLE_VALUE_TYPES } from '@/features/workflows/runtime/types' import type { WorkflowDecoratedVariable, WorkflowVariableCatalogEntry, @@ -240,6 +247,19 @@ export function parseComparisonValue(text: string): unknown { return text } +/** The type menu every workflow variable declaration shares: globals and Loop carried values. */ +export function WorkflowVariableTypeMenu() { + return ( + + {WORKFLOW_VARIABLE_VALUE_TYPES.map((valueType) => ( + + {valueType} + + ))} + + ) +} + /** Renders a comparison value back into its editable text form. */ export function comparisonValueToText(value: unknown): string { if (value === undefined || value === null) { diff --git a/frontend/src/features/workflows/loop-translations.ts b/frontend/src/features/workflows/loop-translations.ts new file mode 100644 index 00000000..eec6add2 --- /dev/null +++ b/frontend/src/features/workflows/loop-translations.ts @@ -0,0 +1,86 @@ +/** + * Messages for the Loop container and its early-exit marker. + * + * They live beside the feature's main bundle for the same reason the history and launch-field + * bundles do: the group is self-contained — the container's palette identity, the starter body the + * factory builds, the inspector fields, and the `loopExit` marker — and keeping it here stops the + * main bundle outgrowing the line budget that keeps its keys scannable. `translations.ts` spreads + * both locales in, so the feature still ships one bundle. + */ +export const loopTranslations = { + 'zh-CN': { + 'workflows.node.loop.label': '循环', + 'workflows.node.loop.description': '重复执行直到满足条件', + 'workflows.node.loopExit.label': '早退', + 'workflows.node.loopExit.description': '循环体内的终端节点:被选中时提前结束循环', + 'workflows.loop.roundStart': '轮次开始', + 'workflows.loop.loopAgent': '循环 Agent', + 'workflows.loop.promptTemplate': '改进当前值并输出新值:{{#NODE_ID.value#}}', + 'workflows.loop.field.maxIterations': '最大轮次', + 'workflows.loop.field.maxIterationsBehavior': '轮次耗尽时', + 'workflows.loop.behavior.fail': '失败', + 'workflows.loop.behavior.succeed': '导出最后一轮并成功', + 'workflows.loop.field.initialValue': '初始值', + 'workflows.loop.field.variables': '携带变量', + 'workflows.loop.field.variableName': '变量名', + 'workflows.loop.field.variableType': '类型', + 'workflows.loop.field.variableInitialKind': '初值来源', + 'workflows.loop.initialKind.constant': '常量', + 'workflows.loop.initialKind.variable': '变量', + 'workflows.loop.field.variableInitial': '初值', + 'workflows.loop.field.variableFeedback': '反馈', + 'workflows.loop.addVariable': '添加变量', + 'workflows.loop.removeVariable': '删除变量', + 'workflows.loop.field.until': '结束条件', + 'workflows.loop.addUntilRule': '添加条件', + 'workflows.loop.removeUntilRule': '删除条件', + 'workflows.loop.field.outputs': '输出', + 'workflows.loop.field.outputName': '输出名', + 'workflows.loop.field.outputSelector': '取值', + 'workflows.loop.addOutput': '添加输出', + 'workflows.loop.removeOutput': '删除输出', + 'workflows.loop.selectorPlaceholder': '节点.变量,例如 writer.output', + 'workflows.loop.defaultBehavior': + '每轮将 Agent 输出反馈为下一轮的 value;输出非空时结束,并导出为 result。', + 'workflows.loop.legacyUnsupported': '此旧循环节点缺少可执行配置,请删除后重新添加。', + }, + 'en-US': { + 'workflows.node.loop.label': 'Loop', + 'workflows.node.loop.description': 'Repeat until the exit condition is met', + 'workflows.node.loopExit.label': 'Early exit', + 'workflows.node.loopExit.description': + 'Terminal node inside a Loop body that ends the loop when selected', + 'workflows.loop.roundStart': 'Round start', + 'workflows.loop.loopAgent': 'Loop Agent', + 'workflows.loop.promptTemplate': + 'Improve the current value and output the new value: {{#NODE_ID.value#}}', + 'workflows.loop.field.maxIterations': 'Maximum rounds', + 'workflows.loop.field.maxIterationsBehavior': 'When rounds run out', + 'workflows.loop.behavior.fail': 'Fail', + 'workflows.loop.behavior.succeed': 'Publish the final round', + 'workflows.loop.field.initialValue': 'Initial value', + 'workflows.loop.field.variables': 'Carried variables', + 'workflows.loop.field.variableName': 'Variable name', + 'workflows.loop.field.variableType': 'Type', + 'workflows.loop.field.variableInitialKind': 'First value from', + 'workflows.loop.initialKind.constant': 'Constant', + 'workflows.loop.initialKind.variable': 'Variable', + 'workflows.loop.field.variableInitial': 'First value', + 'workflows.loop.field.variableFeedback': 'Feedback', + 'workflows.loop.addVariable': 'Add variable', + 'workflows.loop.removeVariable': 'Remove variable', + 'workflows.loop.field.until': 'Exit condition', + 'workflows.loop.addUntilRule': 'Add condition', + 'workflows.loop.removeUntilRule': 'Remove condition', + 'workflows.loop.field.outputs': 'Outputs', + 'workflows.loop.field.outputName': 'Output name', + 'workflows.loop.field.outputSelector': 'Value', + 'workflows.loop.addOutput': 'Add output', + 'workflows.loop.removeOutput': 'Remove output', + 'workflows.loop.selectorPlaceholder': 'node.variable, for example writer.output', + 'workflows.loop.defaultBehavior': + 'Each Agent output feeds the next round as value; a non-empty output stops the Loop and is exported as result.', + 'workflows.loop.legacyUnsupported': + 'This legacy Loop lacks executable configuration. Delete it and add a new Loop.', + }, +} diff --git a/frontend/src/features/workflows/node-translations.ts b/frontend/src/features/workflows/node-translations.ts new file mode 100644 index 00000000..2ee76faa --- /dev/null +++ b/frontend/src/features/workflows/node-translations.ts @@ -0,0 +1,55 @@ +/** + * Palette identity of every workflow node kind. + * + * The node catalog stores these keys rather than resolved text, so a language switch never renames a + * node the author already wrote. They live beside the feature's main bundle because the block is + * self-contained and the main bundle is held to a line budget; `translations.ts` spreads both + * locales in. + */ +export const nodeTranslations = { + 'zh-CN': { + // Node catalog: palette labels and descriptions for every executable kind. + 'workflows.node.start.label': '开始', + 'workflows.node.start.description': '定义工作流输入', + 'workflows.node.agent.label': 'Agent', + 'workflows.node.agent.description': '交给模型自主执行', + 'workflows.node.condition.label': '条件分支', + 'workflows.node.condition.description': '根据规则选择路径', + 'workflows.node.tool.label': '工具', + 'workflows.node.tool.description': '调用终端或插件', + 'workflows.node.junction.label': '汇合', + 'workflows.node.junction.description': '等待多个执行分支完成', + 'workflows.node.human.label': '人工确认', + 'workflows.node.human.description': '等待人工决策后继续', + 'workflows.node.loop.label': '循环', + 'workflows.node.loop.description': '重复执行直到满足条件', + 'workflows.node.iteration.label': '迭代', + 'workflows.node.iteration.description': '对数组逐项执行区域内节点', + 'workflows.node.subflow.label': '子流程', + 'workflows.node.subflow.description': '封装复杂业务步骤', + 'workflows.node.output.label': '输出', + 'workflows.node.output.description': '返回最终结果', + }, + 'en-US': { + 'workflows.node.start.label': 'Start', + 'workflows.node.start.description': 'Define workflow inputs', + 'workflows.node.agent.label': 'Agent', + 'workflows.node.agent.description': 'Delegate autonomous work to a model', + 'workflows.node.condition.label': 'Condition', + 'workflows.node.condition.description': 'Route execution based on rules', + 'workflows.node.tool.label': 'Tool', + 'workflows.node.tool.description': 'Call a terminal or plugin', + 'workflows.node.junction.label': 'Merge', + 'workflows.node.junction.description': 'Wait for multiple branches to complete', + 'workflows.node.human.label': 'Human confirmation', + 'workflows.node.human.description': 'Pause for a human decision', + 'workflows.node.loop.label': 'Loop', + 'workflows.node.loop.description': 'Repeat until the exit condition is met', + 'workflows.node.iteration.label': 'Iteration', + 'workflows.node.iteration.description': 'Run the region once per array element', + 'workflows.node.subflow.label': 'Subflow', + 'workflows.node.subflow.description': 'Encapsulate a complex business step', + 'workflows.node.output.label': 'Output', + 'workflows.node.output.description': 'Return the final result', + }, +} diff --git a/frontend/src/features/workflows/operator-translations.ts b/frontend/src/features/workflows/operator-translations.ts new file mode 100644 index 00000000..848eea87 --- /dev/null +++ b/frontend/src/features/workflows/operator-translations.ts @@ -0,0 +1,41 @@ +/** + * Comparison operator labels for Condition nodes and Loop exit rules. + * + * The list is the consumer's vocabulary: the wire spellings are stable identifiers, so they live + * next to the capability table and stay out of the feature's main bundle, which is held to a line + * budget. `translations.ts` spreads both locales in. + */ +export const operatorTranslations = { + 'zh-CN': { + 'workflows.operator.equals': '等于', + 'workflows.operator.not-equals': '不等于', + 'workflows.operator.contains': '包含', + 'workflows.operator.not-contains': '不包含', + 'workflows.operator.starts-with': '开头是', + 'workflows.operator.ends-with': '结尾是', + 'workflows.operator.greater-than': '大于', + 'workflows.operator.greater-than-or-equal': '大于或等于', + 'workflows.operator.less-than': '小于', + 'workflows.operator.less-than-or-equal': '小于或等于', + 'workflows.operator.empty': '为空', + 'workflows.operator.not-empty': '不为空', + 'workflows.operator.exists': '已赋值', + 'workflows.operator.not-exists': '未赋值', + }, + 'en-US': { + 'workflows.operator.equals': 'equals', + 'workflows.operator.not-equals': 'does not equal', + 'workflows.operator.contains': 'contains', + 'workflows.operator.not-contains': 'does not contain', + 'workflows.operator.starts-with': 'starts with', + 'workflows.operator.ends-with': 'ends with', + 'workflows.operator.greater-than': 'is greater than', + 'workflows.operator.greater-than-or-equal': 'is greater than or equal to', + 'workflows.operator.less-than': 'is less than', + 'workflows.operator.less-than-or-equal': 'is less than or equal to', + 'workflows.operator.empty': 'is empty', + 'workflows.operator.not-empty': 'is not empty', + 'workflows.operator.exists': 'is assigned', + 'workflows.operator.not-exists': 'is not assigned', + }, +} diff --git a/frontend/src/features/workflows/runtime/capabilities.ts b/frontend/src/features/workflows/runtime/capabilities.ts index 76ad98bd..b4529c7c 100644 --- a/frontend/src/features/workflows/runtime/capabilities.ts +++ b/frontend/src/features/workflows/runtime/capabilities.ts @@ -142,10 +142,16 @@ export const DEMO_CONDITION_OPERATORS: WorkflowChoice[] = [ { value: 'not_equals', labelKey: 'workflows.operator.not-equals' }, { value: 'contains', labelKey: 'workflows.operator.contains' }, { value: 'not_contains', labelKey: 'workflows.operator.not-contains' }, + { value: 'starts_with', labelKey: 'workflows.operator.starts-with' }, + { value: 'ends_with', labelKey: 'workflows.operator.ends-with' }, { value: 'greater_than', labelKey: 'workflows.operator.greater-than' }, + { value: 'greater_than_or_equal', labelKey: 'workflows.operator.greater-than-or-equal' }, { value: 'less_than', labelKey: 'workflows.operator.less-than' }, + { value: 'less_than_or_equal', labelKey: 'workflows.operator.less-than-or-equal' }, { value: 'empty', labelKey: 'workflows.operator.empty' }, { value: 'not_empty', labelKey: 'workflows.operator.not-empty' }, + { value: 'exists', labelKey: 'workflows.operator.exists' }, + { value: 'not_exists', labelKey: 'workflows.operator.not-exists' }, ] /** diff --git a/frontend/src/features/workflows/runtime/condition-cases.test.ts b/frontend/src/features/workflows/runtime/condition-cases.test.ts index 42e3bb2d..9299bbdb 100644 --- a/frontend/src/features/workflows/runtime/condition-cases.test.ts +++ b/frontend/src/features/workflows/runtime/condition-cases.test.ts @@ -177,10 +177,16 @@ describe('condition operator catalog', () => { 'not_equals', 'contains', 'not_contains', + 'starts_with', + 'ends_with', 'greater_than', + 'greater_than_or_equal', 'less_than', + 'less_than_or_equal', 'empty', 'not_empty', + 'exists', + 'not_exists', ]) }) }) diff --git a/frontend/src/features/workflows/runtime/condition-cases.ts b/frontend/src/features/workflows/runtime/condition-cases.ts index 8d0425d6..06df62bd 100644 --- a/frontend/src/features/workflows/runtime/condition-cases.ts +++ b/frontend/src/features/workflows/runtime/condition-cases.ts @@ -12,10 +12,16 @@ export const WORKFLOW_CONDITION_OPERATORS: readonly string[] = [ 'not_equals', 'contains', 'not_contains', + 'starts_with', + 'ends_with', 'greater_than', + 'greater_than_or_equal', 'less_than', + 'less_than_or_equal', 'empty', 'not_empty', + 'exists', + 'not_exists', ] /** Operators whose meaning does not require a literal comparison value. */ diff --git a/frontend/src/features/workflows/runtime/node-catalog.test.ts b/frontend/src/features/workflows/runtime/node-catalog.test.ts index 27b0d0ac..850602e8 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.test.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.test.ts @@ -29,16 +29,22 @@ describe('workflow node catalog', () => { expect(supportsWorkflowNodeScope('start', 'iteration')).toBe(false) }) + it('offers only the early-exit marker inside a loop body', () => { + expect(workflowPaletteNodeTypes('loop').map((entry) => entry.kind)).toEqual(['loopExit']) + }) + it('allows only the nodes a region can run in the iteration scope', () => { expect(workflowPaletteNodeTypes('iteration').map((entry) => entry.kind)).toEqual([ 'agent', 'condition', + 'aggregator', ]) }) it('lists the workflow palette in menu order', () => { - expect(workflowPaletteNodeTypes('workflow').map((entry) => entry.kind)).toEqual([ - ...WORKFLOW_NODE_PALETTE, - ]) + // The palette also carries the loop-only marker, which the workflow scope filters out. + expect(workflowPaletteNodeTypes('workflow').map((entry) => entry.kind)).toEqual( + WORKFLOW_NODE_PALETTE.filter((kind) => supportsWorkflowNodeScope(kind, 'workflow')), + ) }) }) diff --git a/frontend/src/features/workflows/runtime/node-catalog.ts b/frontend/src/features/workflows/runtime/node-catalog.ts index 1e858da1..8e717aac 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.ts @@ -1,7 +1,7 @@ import type { WorkflowNodeKind } from '@/features/workflows/runtime/types' /** Editor scopes in which a node kind can be authored. */ -export type WorkflowNodeScope = 'workflow' | 'iteration' +export type WorkflowNodeScope = 'workflow' | 'iteration' | 'loop' /** Inspector sections a node kind exposes. */ export type WorkflowConfigField = @@ -9,6 +9,7 @@ export type WorkflowConfigField = | 'initialPrompt' | 'tool' | 'condition' + | 'aggregator' | 'approvalPrompt' | 'waitStrategy' | 'failureStrategy' @@ -16,6 +17,7 @@ export type WorkflowConfigField = | 'exitCondition' | 'maxIterations' | 'loopInitialValue' + | 'loopBehavior' | 'iteration' /** @@ -40,9 +42,12 @@ export const WORKFLOW_NODE_PALETTE: readonly WorkflowNodeKind[] = [ 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', + // Offered only by a loop body's insert menu: the marker is meaningless outside a container. + 'loopExit', ] /** Builds one definition, deriving the translation keys from the kind. */ @@ -70,10 +75,12 @@ const NODE_TYPE_DEFINITIONS: Record { 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', diff --git a/frontend/src/features/workflows/runtime/node-factory.ts b/frontend/src/features/workflows/runtime/node-factory.ts index a99e75f1..af5329fe 100644 --- a/frontend/src/features/workflows/runtime/node-factory.ts +++ b/frontend/src/features/workflows/runtime/node-factory.ts @@ -40,6 +40,7 @@ export const AUTHORABLE_NODE_KINDS: readonly WorkflowNodeKind[] = [ 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', @@ -97,9 +98,13 @@ function seedNodeData( return { input: '' } case 'output': case 'loop': + case 'loopExit': // The Loop's own `loopConfig` is supplied by the atomic group factory, which creates - // its starter children at the same time. + // its starter children at the same time. The early-exit marker carries no data at all. return {} + case 'aggregator': + // An empty list is a legal draft but not publishable: the author picks candidates next. + return { aggregatorConfig: { variables: [] } } case 'condition': return { condition: translate('workflows.node.condition.label'), diff --git a/frontend/src/features/workflows/runtime/types.ts b/frontend/src/features/workflows/runtime/types.ts index 253036cf..433ff111 100644 --- a/frontend/src/features/workflows/runtime/types.ts +++ b/frontend/src/features/workflows/runtime/types.ts @@ -8,10 +8,12 @@ export const WORKFLOW_NODE_KINDS = [ 'start', 'agent', 'condition', + 'aggregator', 'tool', 'junction', 'human', 'loop', + 'loopExit', 'iteration', 'subflow', 'output', @@ -173,6 +175,11 @@ export interface WorkflowConditionCase { conditions: WorkflowConditionComparison[] } +/** Ordered root selectors of an Aggregator node; the first assigned candidate is passed through. */ +export interface WorkflowAggregatorConfig { + variables: string[][] +} + /** One named result an Output node exposes, resolved from the run variable pool at completion. */ export interface WorkflowOutputBinding { name: string @@ -191,6 +198,8 @@ export interface WorkflowLoopVariable { /** Executable bounded Loop configuration shared with the Rust snapshot decoder. */ export interface WorkflowLoopConfig { maxIterations: number + /** What an exhausted round budget does: fail (default) or publish the final round. */ + maxIterationsBehavior?: 'fail' | 'succeed' variables: WorkflowLoopVariable[] until: { logic: WorkflowConditionLogic @@ -267,6 +276,8 @@ export interface WorkflowNodeData extends Record { condition?: string agentConfig?: WorkflowAgentConfig conditionBranches?: WorkflowConditionBranch[] + /** Aggregator node: ordered root selectors (backend data.aggregatorConfig). */ + aggregatorConfig?: WorkflowAggregatorConfig /** Legacy executable cases retained so existing saved graphs remain readable. */ conditionCases?: WorkflowConditionCase[] /** Executable cases for Condition nodes, matching the backend `data.cases` wire format. */ diff --git a/frontend/src/features/workflows/translations.ts b/frontend/src/features/workflows/translations.ts index 67fed10f..a4f2a4bf 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -1,6 +1,10 @@ import { publishErrorTranslations } from '@/features/workflows/publish-error-translations' import { historyTranslations } from '@/features/workflows/history-translations' import { launchFieldTranslations } from '@/features/workflows/launch-field-translations' +import { aggregatorTranslations } from '@/features/workflows/aggregator-translations' +import { loopTranslations } from '@/features/workflows/loop-translations' +import { nodeTranslations } from '@/features/workflows/node-translations' +import { operatorTranslations } from '@/features/workflows/operator-translations' import type { TranslationBundle } from '@/i18n/resource-bundle' /** @@ -24,32 +28,10 @@ export const workflowTranslations = { ...historyTranslations['zh-CN'], ...publishErrorTranslations['zh-CN'], ...launchFieldTranslations['zh-CN'], - // Node catalog: palette labels and descriptions for every executable kind. - 'workflows.node.start.label': '开始', - 'workflows.node.start.description': '定义工作流输入', - 'workflows.node.agent.label': 'Agent', - 'workflows.node.agent.description': '交给模型自主执行', - 'workflows.node.condition.label': '条件分支', - 'workflows.node.condition.description': '根据规则选择路径', - 'workflows.node.tool.label': '工具', - 'workflows.node.tool.description': '调用终端或插件', - 'workflows.node.junction.label': '汇合', - 'workflows.node.junction.description': '等待多个执行分支完成', - 'workflows.node.human.label': '人工确认', - 'workflows.node.human.description': '等待人工决策后继续', - 'workflows.node.loop.label': '循环', - 'workflows.node.loop.description': '重复执行直到满足条件', - 'workflows.node.iteration.label': '迭代', - 'workflows.node.iteration.description': '对数组逐项执行区域内节点', - 'workflows.node.subflow.label': '子流程', - 'workflows.node.subflow.description': '封装复杂业务步骤', - 'workflows.node.output.label': '输出', - 'workflows.node.output.description': '返回最终结果', - - // Loop container: the starter group a factory builds atomically. - 'workflows.loop.roundStart': '轮次开始', - 'workflows.loop.loopAgent': '循环 Agent', - 'workflows.loop.promptTemplate': '改进当前值并输出新值:{{#NODE_ID.value#}}', + ...aggregatorTranslations['zh-CN'], + ...loopTranslations['zh-CN'], + ...nodeTranslations['zh-CN'], + ...operatorTranslations['zh-CN'], // Palette. 'workflows.palette.title': '节点', @@ -146,14 +128,6 @@ export const workflowTranslations = { 'workflows.role.documentation-agent': '文档', // Condition operators, referenced by the capability catalog. - 'workflows.operator.equals': '等于', - 'workflows.operator.not-equals': '不等于', - 'workflows.operator.contains': '包含', - 'workflows.operator.not-contains': '不包含', - 'workflows.operator.greater-than': '大于', - 'workflows.operator.less-than': '小于', - 'workflows.operator.empty': '为空', - 'workflows.operator.not-empty': '不为空', // Tool operations, referenced by the capability catalog. 'workflows.operation.run-command': '执行命令', @@ -204,11 +178,6 @@ export const workflowTranslations = { 'workflows.junction.field.waitCount': '完成数量', 'workflows.junction.field.failureStrategy': '失败策略', 'workflows.human.field.approvalPrompt': '审批说明', - 'workflows.loop.field.maxIterations': '最大轮次', - 'workflows.loop.field.initialValue': '初始值', - 'workflows.loop.defaultBehavior': - '每轮将 Agent 输出反馈为下一轮的 value;输出非空时结束,并导出为 result。', - 'workflows.loop.legacyUnsupported': '此旧循环节点缺少可执行配置,请删除后重新添加。', 'workflows.iteration.hint': '对迭代源数组的每个元素执行一轮区域内的节点;每轮绑定 {iter}.item 与 {iter}.index,完成后暴露 output / entries / failed_count 三个变量。', 'workflows.iteration.field.iterator': '迭代源', @@ -449,32 +418,10 @@ export const workflowTranslations = { ...historyTranslations['en-US'], ...publishErrorTranslations['en-US'], ...launchFieldTranslations['en-US'], - 'workflows.node.start.label': 'Start', - 'workflows.node.start.description': 'Define workflow inputs', - 'workflows.node.agent.label': 'Agent', - 'workflows.node.agent.description': 'Delegate autonomous work to a model', - 'workflows.node.condition.label': 'Condition', - 'workflows.node.condition.description': 'Route execution based on rules', - 'workflows.node.tool.label': 'Tool', - 'workflows.node.tool.description': 'Call a terminal or plugin', - 'workflows.node.junction.label': 'Merge', - 'workflows.node.junction.description': 'Wait for multiple branches to complete', - 'workflows.node.human.label': 'Human confirmation', - 'workflows.node.human.description': 'Pause for a human decision', - 'workflows.node.loop.label': 'Loop', - 'workflows.node.loop.description': 'Repeat until the exit condition is met', - 'workflows.node.iteration.label': 'Iteration', - 'workflows.node.iteration.description': 'Run the region once per array element', - 'workflows.node.subflow.label': 'Subflow', - 'workflows.node.subflow.description': 'Encapsulate a complex business step', - 'workflows.node.output.label': 'Output', - 'workflows.node.output.description': 'Return the final result', - - // Loop container: the starter group a factory builds atomically. - 'workflows.loop.roundStart': 'Round start', - 'workflows.loop.loopAgent': 'Loop Agent', - 'workflows.loop.promptTemplate': - 'Improve the current value and output the new value: {{#NODE_ID.value#}}', + ...aggregatorTranslations['en-US'], + ...loopTranslations['en-US'], + ...nodeTranslations['en-US'], + ...operatorTranslations['en-US'], 'workflows.palette.title': 'Nodes', 'workflows.palette.dragHint': 'Drag onto the canvas or click to add at the center', @@ -569,15 +516,6 @@ export const workflowTranslations = { 'workflows.role.debugger': 'Debugger', 'workflows.role.documentation-agent': 'Documentation agent', - 'workflows.operator.equals': 'equals', - 'workflows.operator.not-equals': 'does not equal', - 'workflows.operator.contains': 'contains', - 'workflows.operator.not-contains': 'does not contain', - 'workflows.operator.greater-than': 'is greater than', - 'workflows.operator.less-than': 'is less than', - 'workflows.operator.empty': 'is empty', - 'workflows.operator.not-empty': 'is not empty', - 'workflows.operation.run-command': 'Run command', 'workflows.operation.read-file': 'Read file', 'workflows.operation.write-file': 'Write file', @@ -623,12 +561,6 @@ export const workflowTranslations = { 'workflows.junction.field.waitCount': 'Completion count', 'workflows.junction.field.failureStrategy': 'Failure strategy', 'workflows.human.field.approvalPrompt': 'Approval prompt', - 'workflows.loop.field.maxIterations': 'Maximum rounds', - 'workflows.loop.field.initialValue': 'Initial value', - 'workflows.loop.defaultBehavior': - 'Each Agent output feeds the next round as value; a non-empty output stops the Loop and is exported as result.', - 'workflows.loop.legacyUnsupported': - 'This legacy Loop lacks executable configuration. Delete it and add a new Loop.', 'workflows.iteration.hint': 'Runs the region once per element of the iterator array; each round binds {iter}.item and {iter}.index, and completion exposes output / entries / failed_count.', 'workflows.iteration.field.iterator': 'Iterator source', diff --git a/frontend/src/i18n/i18n.test.ts b/frontend/src/i18n/i18n.test.ts index 61ab7dc4..619a54ec 100644 --- a/frontend/src/i18n/i18n.test.ts +++ b/frontend/src/i18n/i18n.test.ts @@ -79,10 +79,12 @@ describe('the shipped resources', () => { 'start', 'agent', 'condition', + 'aggregator', 'tool', 'junction', 'human', 'loop', + 'loopExit', 'iteration', 'subflow', 'output', diff --git a/internal/core/workflow_aggregator_graph.go b/internal/core/workflow_aggregator_graph.go new file mode 100644 index 00000000..98f47480 --- /dev/null +++ b/internal/core/workflow_aggregator_graph.go @@ -0,0 +1,113 @@ +package core + +// The variable aggregator: an ordered pass-through that collapses mutually exclusive branch outputs +// into one variable (desktop `workflow_run/engine/aggregator.rs`, V1 contract). Selectors are an +// ordered non-empty list of root selectors; the node publishes the first **assigned** candidate as +// `{aggregator}.output`, and zero assigned candidates is a stable failure. +// +// "Assigned" is key existence in the committed pool, never truthiness: `null`, `false`, `0`, `""`, an +// empty list and an empty object all count, exactly as they do on desktop. The node never reads branch +// decisions; which branch ran is expressed by the committed states the selector resolves against. + +// aggregatorSelector is one parsed candidate: a node and a root, with no nested path. Nested paths are +// refused at publish because a root selector is the only shape the aggregator can statically type. +type aggregatorSelector struct { + nodeID string + root string +} + +// validateAggregatorConfig checks `data.aggregatorConfig` against the executable contract. Every +// problem names the node and the field, so the editor can point at the list rather than at the graph. +func validateAggregatorConfig(nodeID string, data Object, declaredRoots map[string]map[string]bool) []workflowGraphProblem { + raw, present := data["aggregatorConfig"] + if !present || raw == nil { + return []workflowGraphProblem{{Code: "aggregator_config_missing", NodeID: nodeID, Field: "aggregatorConfig"}} + } + config, ok := asWorkflowObject(raw) + if !ok { + return []workflowGraphProblem{{Code: "aggregator_config_missing", NodeID: nodeID, Field: "aggregatorConfig"}} + } + rawSelectors, present := config["variables"] + if !present || rawSelectors == nil { + return []workflowGraphProblem{{Code: "aggregator_variables_not_a_list", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + selectors, ok := rawSelectors.([]any) + if !ok { + return []workflowGraphProblem{{Code: "aggregator_variables_not_a_list", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + if len(selectors) == 0 { + return []workflowGraphProblem{{Code: "aggregator_variables_empty", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + problems := []workflowGraphProblem{} + seen := map[string]bool{} + for _, item := range selectors { + selector, ok := parseWorkflowSelector(item) + if !ok || len(selector.nested) != 0 { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_unsupported", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + continue + } + qualified := selector.qualified() + if seen[qualified] { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_duplicate", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + continue + } + seen[qualified] = true + if !declaredRoots[selector.nodeID][selector.root] { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_undeclared", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + } + } + return problems +} + +// parseAggregatorSelectors compiles the wire list the way the scheduler executes it. It runs on frozen +// snapshots, so it reports a document the gate would have refused instead of assuming the shape. +func parseAggregatorSelectors(data Object) ([]aggregatorSelector, error) { + config, ok := asWorkflowObject(data["aggregatorConfig"]) + if !ok { + return nil, errString("aggregator node has no config") + } + raw, ok := config["variables"].([]any) + if !ok { + return nil, errString("aggregator variables must be a list") + } + selectors := make([]aggregatorSelector, 0, len(raw)) + for _, item := range raw { + selector, ok := parseWorkflowSelector(item) + if !ok || len(selector.nested) != 0 { + return nil, errString("aggregator selector must be a node id and a root") + } + selectors = append(selectors, aggregatorSelector{nodeID: selector.nodeID, root: selector.root}) + } + if len(selectors) == 0 { + return nil, errString("aggregator must declare at least one selector") + } + return selectors, nil +} + +// errString is a tiny error type so the parser stays dependency-free and its messages reach the node +// state unchanged. +type errString string + +func (e errString) Error() string { return string(e) } + +// evaluateWorkflowAggregator resolves the declared candidates in order and returns the first +// assigned value. Presence is key existence in the committed pool, so null, false, 0, "" and empty +// containers all count as assigned — desktop's V1 contract, not a truthiness test. Unassigned +// candidates are skipped; an empty result is reported as not assigned so the caller can fail with +// the stable no-match reason. +func evaluateWorkflowAggregator(node workflowNodeView, states map[string]workflowNodeState) (any, bool, error) { + selectors, err := parseAggregatorSelectors(node.Data) + if err != nil { + return nil, false, err + } + for _, candidate := range selectors { + value, present, err := resolveWorkflowSelector(workflowSelector{nodeID: candidate.nodeID, root: candidate.root}, states) + if err != nil { + return nil, false, err + } + if present { + return value, true, nil + } + } + return nil, false, nil +} diff --git a/internal/core/workflow_aggregator_graph_test.go b/internal/core/workflow_aggregator_graph_test.go new file mode 100644 index 00000000..0b167d0e --- /dev/null +++ b/internal/core/workflow_aggregator_graph_test.go @@ -0,0 +1,83 @@ +package core + +import ( + "reflect" + "testing" +) + +// The aggregator's publish gate: an ordered list of root selectors, each naming a declared producer, +// with no nested path and no duplicate. The scheduler reads the same list, so a refused document is +// exactly one the run could not execute. + +// aggregatorTestGraph is start feeding two agents that both merge into one aggregator and then an +// output — the branch fan-in the node exists for. +func aggregatorTestGraph(config Object) Object { + agent := func(id string) Object { + return Object{"id": id, "data": Object{"kind": "agent", "title": id, "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}} + } + return Object{ + "nodes": []any{ + Object{"id": "s", "data": Object{"kind": "start", "title": "开始", "inputVariables": []any{}}}, + agent("a1"), + agent("a2"), + Object{"id": "agg", "data": Object{"kind": "aggregator", "title": "合并", "aggregatorConfig": config}}, + Object{"id": "o", "data": Object{"kind": "output"}}, + }, + "edges": []any{ + Object{"source": "s", "target": "a1"}, + Object{"source": "s", "target": "a2"}, + Object{"source": "a1", "target": "agg"}, + Object{"source": "a2", "target": "agg"}, + Object{"source": "agg", "target": "o"}, + }, + } +} + +// aggregatorProblems projects problems as `code@node#field` strings for exact comparison. +func aggregatorProblems(t *testing.T, graph Object) []string { + t.Helper() + out := []string{} + for _, problem := range validateWorkflowGraph(decodeGraph(t, graph)) { + entry := problem.Code + if problem.NodeID != "" { + entry += "@" + problem.NodeID + } + if problem.Field != "" { + entry += "#" + problem.Field + } + out = append(out, entry) + } + return out +} + +// TestWorkflowPublishValidatesAggregatorConfig is the configuration matrix. +func TestWorkflowPublishValidatesAggregatorConfig(t *testing.T) { + valid := Object{"variables": []any{[]any{"a1", "output"}, []any{"a2", "output"}}} + if got := aggregatorProblems(t, aggregatorTestGraph(valid)); len(got) != 0 { + t.Fatalf("a well-formed aggregator must publish, got %v", got) + } + cases := []struct { + name string + config any + want []string + }{ + {name: "missing config", config: nil, want: []string{"aggregator_config_missing@agg#aggregatorConfig"}}, + {name: "variables not a list", config: Object{"variables": "a1.output"}, want: []string{"aggregator_variables_not_a_list@agg#aggregatorConfig.variables"}}, + {name: "empty selector list", config: Object{"variables": []any{}}, want: []string{"aggregator_variables_empty@agg#aggregatorConfig.variables"}}, + {name: "one-segment selector", config: Object{"variables": []any{[]any{"a1"}}}, want: []string{"aggregator_selector_unsupported@agg#aggregatorConfig.variables"}}, + {name: "nested selector", config: Object{"variables": []any{[]any{"a1", "output", "text"}}}, want: []string{"aggregator_selector_unsupported@agg#aggregatorConfig.variables"}}, + {name: "duplicate selector", config: Object{"variables": []any{[]any{"a1", "output"}, []any{"a1", "output"}}}, want: []string{"aggregator_selector_duplicate@agg#aggregatorConfig.variables"}}, + {name: "undeclared producer", config: Object{"variables": []any{[]any{"missing", "output"}}}, want: []string{"aggregator_selector_undeclared@agg#aggregatorConfig.variables"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + graph := aggregatorTestGraph(nil) + if tc.config != nil { + graph = aggregatorTestGraph(tc.config.(Object)) + } + if got := aggregatorProblems(t, graph); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/core/workflow_aggregator_scheduler_db_test.go b/internal/core/workflow_aggregator_scheduler_db_test.go new file mode 100644 index 00000000..79761cba --- /dev/null +++ b/internal/core/workflow_aggregator_scheduler_db_test.go @@ -0,0 +1,122 @@ +package core + +import ( + "context" + "testing" +) + +// The aggregator's scheduler behaviour over PostgreSQL: the first assigned root is passed through as +// `{aggregator}.output`, presence is key existence (a null counts), and an empty candidate set is the +// stable no-match failure. + +// aggregatorRunGraph is start → aggregator → output with two declared start variables, so a run can +// assign either, both or neither candidate. +func aggregatorRunGraph(selectors ...[]any) Object { + start := wfNode("s", "start", "开始") + start["data"].(Object)["inputVariables"] = []any{ + Object{"name": "a", "valueType": "string"}, + Object{"name": "b", "valueType": "string"}, + } + agg := wfNode("agg", "aggregator", "合并") + choices := make([]any, 0, len(selectors)) + for _, selector := range selectors { + choices = append(choices, selector) + } + agg["data"].(Object)["aggregatorConfig"] = Object{"variables": choices} + return wfGraph( + []Object{start, agg, wfNode("o", "output", "输出")}, + [][3]string{{"s", "agg", ""}, {"agg", "o", ""}}, + ) +} + +// TestWorkflowAdvanceAggregatorPassesFirstAssignedCandidate pins the order contract: the first +// assigned selector wins even when a later candidate is also assigned. +func TestWorkflowAdvanceAggregatorPassesFirstAssignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "b"}, []any{"s", "a"}), + Object{"a": "from-a", "b": "from-b"}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + agg, ok := asWorkflowObject(states["agg"]) + if !ok { + t.Fatalf("aggregator has no committed state: %v", states) + } + if got := agg.O("output")["output"]; got != "from-b" { + t.Fatalf("aggregator output = %v; want the first declared candidate from-b", got) + } + if got := wfNodeStatus(t, states, "o"); got != "succeeded" { + t.Fatalf("downstream output status = %q; want succeeded", got) + } +} + +// TestWorkflowAdvanceAggregatorSkipsAnUnassignedCandidate pins the assignment rule: a declared but +// unproduced root is skipped, not treated as a null value. +func TestWorkflowAdvanceAggregatorSkipsAnUnassignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + Object{"b": "only-b"}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if got := agg.O("output")["output"]; got != "only-b" { + t.Fatalf("aggregator output = %v; want the only assigned candidate only-b", got) + } +} + +// TestWorkflowAdvanceAggregatorCountsANullAsAssigned pins the falsy rule: an explicit null is assigned +// (key existence), so the aggregator passes it through instead of failing no-match. +func TestWorkflowAdvanceAggregatorCountsANullAsAssigned(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + Object{"a": nil}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded for an assigned null", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if value, present := agg.O("output")["output"]; !present || value != nil { + t.Fatalf("aggregator output = %v (present %v); want an assigned null", value, present) + } +} + +// TestWorkflowAdvanceAggregatorFailsWithoutAssignedCandidate pins the no-match failure: no candidate +// produced anything fails the node and the run with the stable reason. +func TestWorkflowAdvanceAggregatorFailsWithoutAssignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "aggregator_no_match" { + t.Fatalf("run settled %q error %q; want failed/aggregator_no_match", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if agg.S("status") != "failed" || agg.O("output").S("reason") != "aggregator_no_match" { + t.Fatalf("aggregator state = %v; want failed with its reason", states["agg"]) + } + if _, ran := states["o"]; ran { + t.Fatalf("a node downstream of the failed aggregator committed: %v", states) + } +} diff --git a/internal/core/workflow_collaboration.go b/internal/core/workflow_collaboration.go index 38f382d0..e7d870ce 100644 --- a/internal/core/workflow_collaboration.go +++ b/internal/core/workflow_collaboration.go @@ -821,9 +821,14 @@ func foldLegacyVersionAnswer(graph, values Object) { } } -// workflowStartNode returns the `data` of the graph's first Start node. The graph is a document the API -// never interprets, so its shape is read defensively: anything unexpected contributes nothing rather -// than failing the request. The first Start node wins, matching the editor, which allows at most one. +// workflowStartNode returns the `data` of the graph's first outer Start node. The graph is a document +// the API never interprets, so its shape is read defensively: anything unexpected contributes nothing +// rather than failing the request. The first outer Start node wins, matching the editor, which allows +// at most one. +// +// A loop body carries its own Start node, and that node is not the run's entry point: its variables +// never enter the @ form and its prompt is never the kickoff. Skipping container members here is what +// keeps the launcher fields reading only the document's outer start. // // Reading the prompt and the variables through this one lookup is what keeps them from ever coming // from two different nodes in a graph that somehow carries more than one. @@ -835,7 +840,7 @@ func workflowStartNode(graph Object) Object { continue } data, ok := node["data"].(map[string]any) - if !ok || data["kind"] != "start" { + if !ok || data["kind"] != "start" || Object(data).S("containerId") != "" { continue } return Object(data) diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 7a7f2e5b..62b891fe 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -20,15 +20,20 @@ import ( // the whole point of the draft/published distinction, and moving an install or completeness check // into the save path is the mistake this file exists to prevent. -// workflowExecutableNodeKinds is the subset of the editor's node kinds Cloud executes in its first -// phase (#67 结论 4): start, agent, condition and output. Everything else stays editable and -// saveable, but publishing refuses it rather than freezing a graph whose nodes would be dropped. +// workflowExecutableNodeKinds is the subset of the editor's node kinds Cloud executes. It started as +// the root decision's phase-1 set (#67 结论 4: start, agent, condition, output) and grows one +// decision at a time: `loop` and `loopExit` joined it with +// specs decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md, and `aggregator` with the +// variable-aggregator decision. Everything else stays editable and saveable, but publishing refuses +// it rather than freezing a graph whose nodes would be dropped. var workflowExecutableNodeKinds = map[string]bool{ - "start": true, - "agent": true, - "condition": true, - "output": true, -} + "start": true, + "agent": true, + "condition": true, + "aggregator": true, + "output": true, + "loop": true, + "loopExit": true,} // workflowAgentSchemaVersion is the only `agentConfig.schemaVersion` Cloud executes. Version 4 // introduces the interactive completion declaration Cloud refuses by name; a version Cloud has @@ -277,9 +282,14 @@ func validateWorkflowGraph(graph Object) []workflowGraphProblem { problems = append(problems, workflowGraphProblem{Code: "edges_not_a_list"}) } globals := workflowGlobalVariables(graph) + edges, _ := graph["edges"].([]any) problems = append(problems, validateWorkflowGlobals(graph)...) problems = append(problems, validateWorkflowNodes(nodes, globals)...) problems = append(problems, validateWorkflowEdges(graph, nodes)...) + // Container structure is checked after the edges exist and before the cycle walk: a body whose + // membership or boundaries are wrong is described by its own codes, and the cycle walk reads the + // same union of scopes either way. + problems = append(problems, validateWorkflowContainers(nodes, edges)...) problems = append(problems, validateWorkflowAcyclic(graph)...) problems = append(problems, validateWorkflowSlots(graph, nodes)...) problems = append(problems, validateWorkflowReservedFields(nodes, globals)...) @@ -311,7 +321,9 @@ func validateWorkflowReservedFields(nodes []any, globals []Object) []workflowGra } o := Object(node) data := o.O("data") - if data.S("kind") != "start" { + // A loop body's Start is not the form's Start: its variables never enter the `@` form, so a + // reserved name there cannot collide with the platform's field. + if data.S("kind") != "start" || data.S("containerId") != "" { continue } declared, _ := data["inputVariables"].([]any) @@ -370,13 +382,21 @@ func validateWorkflowAcyclic(graph Object) []workflowGraphProblem { return []workflowGraphProblem{{Code: "graph_cycle", NodeID: source}} } -// validateWorkflowNodes checks node identity, kind, agent configuration and the start-node count. The -// editor allows at most one Start node, and a run needs exactly one entry point, so zero and two are -// both refused: a graph with none is unfinished, and one with two has no defined place to begin. +// validateWorkflowNodes checks node identity, kind, per-kind configuration and the start-node count. +// The editor allows at most one Start node in the outer graph, and a run needs exactly one entry +// point, so zero and two are both refused: a graph with none is unfinished, and one with two has no +// defined place to begin. A loop body's own start is a second `start` in the document; it is counted +// and checked by validateWorkflowContainers, not here, and it never becomes the run's entry point. +// +// Selectors are validated against the roots visible in the node's scope, not the flat declaration +// set: a body node may read its loop's carried variables, and an outer node may read the loop's +// published outputs. See workflowVisibleRoots. func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem { problems := []workflowGraphProblem{} seen := map[string]bool{} declaredRoots := workflowDeclaredRoots(nodes, globals) + containers := workflowNodeContainers(nodes) + loops := workflowLoopRootsByName(nodes) starts := 0 for _, raw := range nodes { node, ok := raw.(map[string]any) @@ -396,13 +416,16 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem } seen[id] = true kind := o.O("data").S("kind") + visibleRoots := workflowVisibleRoots(declaredRoots, loops, containers[id]) switch { case kind == "": problems = append(problems, workflowGraphProblem{Code: "node_kind_missing", NodeID: id}) case !workflowExecutableNodeKinds[kind]: problems = append(problems, workflowGraphProblem{Code: "node_kind_unsupported", NodeID: id, Kind: kind}) case kind == "start": - starts++ + if containers[id] == "" { + starts++ + } case kind == "agent": // Only an agent node is asked about its configuration, and only once its kind is known to // be one Cloud executes: a `tool` node carrying `skills` is already refused for the kind, @@ -410,7 +433,14 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem // delete anyway. problems = append(problems, validateAgentConfig(id, o.O("data"))...) case kind == "condition": - problems = append(problems, validateConditionConfig(id, o.O("data"), declaredRoots)...) + problems = append(problems, validateConditionConfig(id, o.O("data"), visibleRoots)...) + case kind == "aggregator": + problems = append(problems, validateAggregatorConfig(id, o.O("data"), visibleRoots)...) + case kind == "loop" && containers[id] == "": + // The loop's own initial values resolve in the outer scope, while feedback, until and + // outputs resolve against a round pool; validateLoopConfig derives both from the flat + // declarations instead of the node's container visibility. + problems = append(problems, validateLoopConfig(id, o.O("data"), declaredRoots, loops)...) } } if starts != 1 { @@ -609,6 +639,24 @@ func workflowDeclaredRoots(nodes []any, globals []Object) map[string]map[string] } case "output": set["output"] = true + case "aggregator": + // The aggregator publishes exactly one root: the value of the first assigned candidate. + set["output"] = true + case "loop": + // A loop exposes two root groups under the same writer: its carried variables (visible + // only inside the body) and its named outputs (visible only outside). They share a name + // space here because the visibility filter separates them by scope; the runtime never + // writes both under one key. + for _, variable := range workflowObjectEntries(data.O("loopConfig")["variables"]) { + if name := variable.S("name"); name != "" { + set[name] = true + } + } + for _, output := range workflowObjectEntries(data.O("loopConfig")["outputs"]) { + if name := output.S("name"); name != "" { + set[name] = true + } + } } roots[id] = set } diff --git a/internal/core/workflow_loop_graph.go b/internal/core/workflow_loop_graph.go new file mode 100644 index 00000000..f4c9c90e --- /dev/null +++ b/internal/core/workflow_loop_graph.go @@ -0,0 +1,509 @@ +package core + +import ( + "math" + "strings" +) + +// Loop containers are the first composite nodes Cloud executes +// (specs decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md). This file owns the +// structural rules that make a loop a loop: its body is a separate DAG whose membership is declared by +// `data.containerId`, only the body may read the loop's carried variables, and only the outer graph may +// read its published outputs. The publish gate and the scheduler read these rules, so a graph Cloud +// publishes is a graph Cloud can run. +// +// A loop's body start is a second `start` node in the document. Every reader that used to assume one +// flat graph (the run's launcher fields, the reserved-field walk, the start-node count) therefore has +// to look at the outer scope only; the helpers below are the one place that distinction is made. + +const ( + // workflowLoopMinIterations and workflowLoopMaxIterations mirror desktop's 1..=100 ceiling + // (desktop `loop_config.rs`), so a loop Cloud accepts can produce the same round budget. + workflowLoopMinIterations = 1 + workflowLoopMaxIterations = 100 +) + +// workflowLoopRoots separates the two root groups a loop publishes, because their visibility differs. +// A carried variable is visible only inside the loop's body; a named output only outside it. Keeping +// them apart is what lets the gate refuse `[loop, draft]` from the outer graph and `[loop, result]` +// from the body without knowing what the runtime did. +type workflowLoopRoots struct { + carried map[string]bool + outputs map[string]bool +} + +// workflowNodeContainers maps every node id to the id of the container it belongs to, or "" for the +// outer graph. A `containerId` naming a node that does not exist is still returned: the structure +// validator reports it rather than silently reading the node as outer. +func workflowNodeContainers(nodes []any) map[string]string { + containers := make(map[string]string, len(nodes)) + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + if id := node.S("id"); id != "" { + containers[id] = workflowContainerID(node) + } + } + return containers +} + +// workflowLoopRootsByName reads each loop's declared carried and output names. It reads names only, +// so a malformed config still declares nothing here while validateLoopConfig reports the shape error; +// the two functions must not both report the same fault. +func workflowLoopRootsByName(nodes []any) map[string]workflowLoopRoots { + out := map[string]workflowLoopRoots{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + data := node.O("data") + if data.S("kind") != "loop" { + continue + } + id := node.S("id") + if id == "" { + continue + } + config := data.O("loopConfig") + roots := workflowLoopRoots{carried: map[string]bool{}, outputs: map[string]bool{}} + for _, variable := range workflowObjectEntries(config["variables"]) { + if name := variable.S("name"); name != "" { + roots.carried[name] = true + } + } + for _, output := range workflowObjectEntries(config["outputs"]) { + if name := output.S("name"); name != "" { + roots.outputs[name] = true + } + } + out[id] = roots + } + return out +} + +// workflowVisibleRoots returns the declared roots visible to a node in containerID, copying the base +// map so the caller's declarations are never mutated. A loop's carried roots are removed outside its +// body, and its named outputs are removed inside it: those are the two roots the other scope cannot +// read. Selectors are validated against this projection rather than the flat declaration set, so an +// outer selector on a carried variable is refused the same way desktop's Undeclared pool answers. +func workflowVisibleRoots( + declared map[string]map[string]bool, + loops map[string]workflowLoopRoots, + containerID string, +) map[string]map[string]bool { + out := make(map[string]map[string]bool, len(declared)) + for writer, roots := range declared { + set := make(map[string]bool, len(roots)) + for root := range roots { + set[root] = true + } + out[writer] = set + } + for loopID, groups := range loops { + set, ok := out[loopID] + if !ok { + continue + } + if loopID == containerID { + for name := range groups.outputs { + delete(set, name) + } + continue + } + for name := range groups.carried { + delete(set, name) + } + } + return out +} + +// workflowLoopBodyContainers returns the ids whose body belongs to a loop node, in document order. +func workflowLoopBodyContainers(nodes []any) []string { + out := []string{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + data := node.O("data") + if data.S("kind") == "loop" && node.S("id") != "" && workflowContainerID(node) == "" { + out = append(out, node.S("id")) + } + } + return out +} + +// validateWorkflowContainers checks the structure of every loop container: membership, the one body +// start, nested composites and outputs, cross-scope edges and loopExit terminality. It reports one +// problem per violated rule and node, in document order, so a client renders a stable list. +func validateWorkflowContainers(nodes, edges []any) []workflowGraphProblem { + problems := []workflowGraphProblem{} + type nodeInfo struct { + kind string + container string + } + info := map[string]nodeInfo{} + order := []string{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + id := node.S("id") + if id == "" { + continue + } + data := node.O("data") + info[id] = nodeInfo{kind: data.S("kind"), container: workflowContainerID(node)} + order = append(order, id) + } + // Membership rules: a body node names a loop that exists, and the kinds with their own scope or + // terminal semantics cannot be nested in v1. A loopExit outside any body has no loop to exit. + for _, id := range order { + node := info[id] + if node.kind == "loopExit" && node.container == "" { + problems = append(problems, workflowGraphProblem{Code: "loop_exit_outside_body", NodeID: id}) + } + if node.container == "" { + continue + } + owner, known := info[node.container] + if known && owner.kind == "iteration" { + // Iteration regions are their own contract; the loop rules must not report their + // members as mismatched loop containers. + continue + } + if !known || owner.kind != "loop" { + problems = append(problems, workflowGraphProblem{Code: "loop_member_container_mismatch", NodeID: id}) + continue + } + switch node.kind { + case "loop", "iteration", "subflow": + problems = append(problems, workflowGraphProblem{Code: "loop_nested_composite_unsupported", NodeID: id, Kind: node.kind}) + case "output": + problems = append(problems, workflowGraphProblem{Code: "loop_body_output_unsupported", NodeID: id}) + } + } + // Edge rules: an edge may not cross a container boundary. Both endpoints are known here because + // validateWorkflowEdges reports unknown endpoints separately; skipping them keeps one mistake + // from being reported as a container error too. + crossContainer := map[string]bool{} + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok { + continue + } + source, target := edge.S("source"), edge.S("target") + sourceNode, sourceOK := info[source] + targetNode, targetOK := info[target] + if !sourceOK || !targetOK { + continue + } + if sourceNode.container != targetNode.container { + problems = append(problems, workflowGraphProblem{Code: "loop_cross_container_edge", NodeID: source}) + crossContainer[source] = true + crossContainer[target] = true + } + } + // loopExit is a terminal marker: it has no outgoing edge in any container. + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok { + continue + } + if info[edge.S("source")].kind == "loopExit" { + problems = append(problems, workflowGraphProblem{Code: "loop_exit_has_outgoing_edge", NodeID: edge.S("source")}) + } + } + // Per-loop body rules: exactly one start, and every member reachable from it. Reachability is + // skipped when the start count is wrong because there is no defined place to begin. + for _, loopID := range workflowLoopBodyContainers(nodes) { + members := []string{} + starts := []string{} + for _, id := range order { + if info[id].container != loopID { + continue + } + members = append(members, id) + if info[id].kind == "start" { + starts = append(starts, id) + } + } + if len(starts) != 1 { + problems = append(problems, workflowGraphProblem{Code: "loop_body_start_count", NodeID: loopID}) + continue + } + reachable := map[string]bool{starts[0]: true} + queue := []string{starts[0]} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok || edge.S("source") != current { + continue + } + next := edge.S("target") + if info[next].container != loopID || reachable[next] { + continue + } + reachable[next] = true + queue = append(queue, next) + } + } + for _, member := range members { + // A nested composite or an Output member is already refused for what it is; reporting it + // as unreachable too would ask the author to fix a node they have to move or delete. + switch info[member].kind { + case "loop", "iteration", "subflow", "output": + continue + } + if !reachable[member] && !crossContainer[member] { + problems = append(problems, workflowGraphProblem{Code: "loop_body_unreachable", NodeID: member}) + } + } + } + return problems +} + +// validateLoopConfig checks `data.loopConfig` against the shape the scheduler executes. `declared` is +// the flat declaration set and `loops` the visibility groups: the initial value is resolved in the +// outer scope at entry, while feedback, until and outputs are resolved against the round pool, which +// adds the carried roots and drops the loop's own outputs. +func validateLoopConfig( + nodeID string, + data Object, + declared map[string]map[string]bool, + loops map[string]workflowLoopRoots, +) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := data["loopConfig"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_config_missing", NodeID: nodeID, Field: "loopConfig"}) + } + config, ok := asWorkflowObject(raw) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_config_missing", NodeID: nodeID, Field: "loopConfig"}) + } + outer := workflowVisibleRoots(declared, loops, "") + body := workflowVisibleRoots(declared, loops, nodeID) + + problems = append(problems, validateLoopMaxIterations(nodeID, config)...) + problems = append(problems, validateLoopVariables(nodeID, config, outer)...) + problems = append(problems, validateLoopUntil(nodeID, config, body)...) + problems = append(problems, validateLoopOutputs(nodeID, config, body)...) + return problems +} + +// validateLoopMaxIterations checks the round budget and the exhaustion policy. Both decide whether a +// run stops with a failure or publishes its last round, so a value the scheduler would have to guess +// at is refused rather than defaulted. +func validateLoopMaxIterations(nodeID string, config Object) []workflowGraphProblem { + problems := []workflowGraphProblem{} + maxIterations, numeric := workflowNumberOf(config["maxIterations"]) + if !numeric || maxIterations != math.Trunc(maxIterations) || + maxIterations < workflowLoopMinIterations || maxIterations > workflowLoopMaxIterations { + problems = append(problems, workflowGraphProblem{Code: "loop_max_iterations_invalid", NodeID: nodeID, Field: "loopConfig.maxIterations"}) + } + if value, present := config["maxIterationsBehavior"]; present && value != nil { + behavior, text := value.(string) + if !text || (behavior != "fail" && behavior != "succeed") { + problems = append(problems, workflowGraphProblem{Code: "loop_max_iterations_behavior_invalid", NodeID: nodeID, Field: "loopConfig.maxIterationsBehavior"}) + } + } + return problems +} + +// validateLoopVariables checks the carried variables: names that address a selector segment, a +// supported type, an initial value that type accepts, and a feedback selector the round pool can +// resolve. Initial is resolved in the outer scope, feedback in the body. +func validateLoopVariables(nodeID string, config Object, outer map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := config["variables"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_variables_not_a_list", NodeID: nodeID, Field: "loopConfig.variables"}) + } + variables, ok := raw.([]any) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_variables_not_a_list", NodeID: nodeID, Field: "loopConfig.variables"}) + } + names := map[string]bool{} + for _, item := range variables { + variable, ok := asWorkflowObject(item) + if !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_invalid", NodeID: nodeID, Field: "loopConfig.variables"}) + continue + } + name := variable.S("name") + if !validWorkflowLoopName(name) { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_invalid", NodeID: nodeID, Field: "loopConfig.variables.name"}) + } else if names[name] { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_duplicate", NodeID: nodeID, Field: "loopConfig.variables.name"}) + } + names[name] = true + valueType := variable.S("valueType") + if !workflowSupportedVariableType(valueType) { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_type_unsupported", NodeID: nodeID, Field: "loopConfig.variables.valueType"}) + continue + } + problems = append(problems, validateLoopInitial(nodeID, variable, valueType, outer)...) + problems = append(problems, validateLoopSelector(nodeID, variable["feedback"], "loopConfig.variables.feedback", "loop_feedback_invalid", outer)...) + } + return problems +} + +// validateLoopInitial checks one carried variable's first-round value. A constant is checked against +// the declared type (the scheduler normalizes it the same way at entry); a variable reference is +// resolved once in the outer scope, so it may name an ancestor's root but not a carried one. +func validateLoopInitial(nodeID string, variable Object, valueType string, outer map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + initial, ok := asWorkflowObject(variable["initial"]) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial"}) + } + switch initial.S("kind") { + case "constant": + value, present := initial["value"] + if !present { + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.value"}) + break + } + if _, ok := normalizeWorkflowValue(value, valueType); !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.value"}) + } + case "variable": + problems = append(problems, validateLoopSelector(nodeID, initial["selector"], "loopConfig.variables.initial.selector", "loop_initial_invalid", outer)...) + default: + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.kind"}) + } + return problems +} + +// validateLoopUntil checks the round-end condition. The comparison rules are the condition node's own +// validator, so an operator or selector this gate admits is one the evaluator can apply; only the +// envelope (logic and list shape) has a loop-specific code. +func validateLoopUntil(nodeID string, config Object, body map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + until, ok := asWorkflowObject(config["until"]) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until"}) + } + switch until.S("logic") { + case "and", "or": + default: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.logic"}) + } + switch conditions := until["conditions"].(type) { + case nil: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.conditions"}) + case []any: + for _, rule := range conditions { + problems = append(problems, validateConditionRule(nodeID, rule, body)...) + } + default: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.conditions"}) + } + return problems +} + +// validateLoopOutputs checks the names a loop publishes. Each selector is resolved against the final +// round's pool, so it may name a body node or a carried root but not the loop's own outputs. +func validateLoopOutputs(nodeID string, config Object, body map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := config["outputs"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_outputs_not_a_list", NodeID: nodeID, Field: "loopConfig.outputs"}) + } + outputs, ok := raw.([]any) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_outputs_not_a_list", NodeID: nodeID, Field: "loopConfig.outputs"}) + } + names := map[string]bool{} + for _, item := range outputs { + output, ok := asWorkflowObject(item) + if !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_output_invalid", NodeID: nodeID, Field: "loopConfig.outputs"}) + continue + } + name := output.S("name") + if !validWorkflowLoopName(name) { + problems = append(problems, workflowGraphProblem{Code: "loop_output_invalid", NodeID: nodeID, Field: "loopConfig.outputs.name"}) + } else if names[name] { + problems = append(problems, workflowGraphProblem{Code: "loop_output_duplicate", NodeID: nodeID, Field: "loopConfig.outputs.name"}) + } + names[name] = true + problems = append(problems, validateLoopSelector(nodeID, output["variableSelector"], "loopConfig.outputs.variableSelector", "loop_output_invalid", body)...) + } + return problems +} + +// validateLoopSelector checks one selector against a visibility projection. A malformed selector gets +// the caller's shape code; a well-formed one that names an undeclared writer or root gets the shared +// undeclared code, so the author learns the same thing whichever field carried the reference. +func validateLoopSelector( + nodeID string, + value any, + field string, + shapeCode string, + visible map[string]map[string]bool, +) []workflowGraphProblem { + parts, ok := workflowSelectorParts(value) + if !ok { + return []workflowGraphProblem{{Code: shapeCode, NodeID: nodeID, Field: field}} + } + if !visible[parts[0]][parts[1]] { + return []workflowGraphProblem{{Code: "loop_selector_undeclared", NodeID: nodeID, Field: field}} + } + return nil +} + +// workflowSelectorParts reads a Dify-style selector as its string segments, desktop's +// `VariableSelector::try_from_parts`: at least a node and a root, every segment non-empty. +func workflowSelectorParts(value any) ([]string, bool) { + raw, ok := value.([]any) + if !ok || len(raw) < 2 { + return nil, false + } + parts := make([]string, 0, len(raw)) + for _, item := range raw { + text, ok := item.(string) + if !ok || text == "" { + return nil, false + } + parts = append(parts, text) + } + return parts, true +} + +// validWorkflowLoopName reports whether a carried-variable or output name can address a selector +// segment: non-blank, no surrounding whitespace and no dot (desktop's loop `validate_name`). +func validWorkflowLoopName(name string) bool { + return name != "" && strings.TrimSpace(name) == name && !strings.Contains(name, ".") +} + +// workflowObjectEntries reads a JSON list of objects, dropping entries that are not objects. It is for +// readers that only collect names, where a malformed entry is reported by the shape validator. +func workflowObjectEntries(raw any) []Object { + list, _ := raw.([]any) + out := make([]Object, 0, len(list)) + for _, item := range list { + if entry, ok := asWorkflowObject(item); ok { + out = append(out, entry) + } + } + return out +} + +// workflowContainerID returns the container a node belongs to: the explicit `data.containerId` that +// loop bodies use, or React Flow's top-level `parentId` that iteration regions persist. Graph decode +// normalises both here so every membership decision — validation, visibility, scheduling — reads one +// value instead of re-deriving it (iteration ADR D1). +func workflowContainerID(node Object) string { + if id := node.O("data").S("containerId"); id != "" { + return id + } + return node.S("parentId") +} diff --git a/internal/core/workflow_loop_graph_test.go b/internal/core/workflow_loop_graph_test.go new file mode 100644 index 00000000..f10ad71a --- /dev/null +++ b/internal/core/workflow_loop_graph_test.go @@ -0,0 +1,455 @@ +package core + +import ( + "reflect" + "testing" +) + +// The loop container is the first graph scope Cloud publishes and executes. These tests are the +// publish gate's side of the while-loop decision: a well-formed loop passes, and every structural or +// configuration fault the scheduler would otherwise have to guess at is refused with a code, a node +// and a field. + +// loopTestGraph builds the smallest executable loop: an outer start → loop → output, and a body +// start → agent inside the loop. The loop's config is the argument so a test can replace or damage it. +func loopTestGraph(loopConfig Object) Object { + loopData := Object{"kind": "loop", "title": "循环 1"} + if loopConfig != nil { + loopData["loopConfig"] = loopConfig + } + return Object{ + "nodes": []any{ + Object{"id": "start-1", "type": "workflow", "data": Object{"kind": "start", "title": "开始 1", "inputVariables": []any{}}}, + Object{"id": "loop-1", "type": "workflow", "data": loopData}, + Object{"id": "entry", "type": "workflow", "data": Object{"kind": "start", "title": "入口", "containerId": "loop-1", "inputVariables": []any{}}}, + Object{"id": "worker", "type": "workflow", "data": Object{"kind": "agent", "title": "写手", "containerId": "loop-1", "agentConfig": Object{"schemaVersion": 3, "prompt": "write"}}}, + Object{"id": "out", "type": "workflow", "data": Object{"kind": "output", "title": "输出 1"}}, + }, + "edges": []any{ + Object{"source": "start-1", "target": "loop-1"}, + Object{"source": "entry", "target": "worker"}, + Object{"source": "loop-1", "target": "out"}, + }, + } +} + +// validLoopConfig is the configuration the scheduler executes: one carried string, an until that +// reads the body agent, and one published output. +func validLoopConfig() Object { + return Object{ + "maxIterations": 3, + "variables": []any{ + Object{ + "name": "draft", + "valueType": "string", + "initial": Object{"kind": "constant", "value": ""}, + "feedback": []any{"worker", "output"}, + }, + }, + "until": Object{ + "logic": "and", + "conditions": []any{ + Object{"variableSelector": []any{"worker", "output"}, "operator": "equals", "value": "done"}, + }, + }, + "outputs": []any{ + Object{"name": "result", "variableSelector": []any{"worker", "output"}}, + }, + } +} + +// loopProblemStrings projects problems as stable `code@node#field` strings so a case can state the +// complete expected list without depending on the problem struct's unused fields. +func loopProblemStrings(t *testing.T, graph Object) []string { + t.Helper() + out := []string{} + for _, problem := range validateWorkflowGraph(decodeGraph(t, graph)) { + entry := problem.Code + if problem.NodeID != "" { + entry += "@" + problem.NodeID + } + if problem.Field != "" { + entry += "#" + problem.Field + } + out = append(out, entry) + } + return out +} + +// TestWorkflowPublishAcceptsAWellFormedLoop is the positive control: without it every refusal below +// could pass on a graph that never validates. +func TestWorkflowPublishAcceptsAWellFormedLoop(t *testing.T) { + if got := loopProblemStrings(t, loopTestGraph(validLoopConfig())); len(got) != 0 { + t.Fatalf("a well-formed loop must publish, got problems %v", got) + } +} + +// TestWorkflowPublishAcceptsTheEditorStarterGroup pins the contract between the palette and the gate: +// the exact shape `createWorkflowLoopGroup` emits — including the default agent contract the editor +// starts from — must publish without a single change. A drift here would make every Loop added from +// the palette unpublishable while the editor still looked fine. +func TestWorkflowPublishAcceptsTheEditorStarterGroup(t *testing.T) { + agent := Object{ + "id": "loop-1-agent", + "data": Object{ + "kind": "agent", + "title": "循环 Agent 1", + "containerId": "loop-1", + "agentConfig": Object{ + "schemaVersion": 3, + "executor": Object{"agentCli": "claude-code", "modelId": "claude-sonnet-4-5"}, + "roleId": "member", + "skills": []any{}, + "mcps": []any{}, + "prompt": "改进当前值并输出新值:{{#loop-1.value#}}", + }, + }, + } + graph := Object{ + "nodes": []any{ + Object{"id": "start-1", "data": Object{"kind": "start", "title": "开始", "inputVariables": []any{}}}, + Object{"id": "loop-1", "data": Object{"kind": "loop", "title": "循环 1", "loopConfig": Object{ + "maxIterations": 3, + "variables": []any{Object{ + "name": "value", "valueType": "string", + "initial": Object{"kind": "constant", "value": ""}, + "feedback": []any{"loop-1-agent", "output"}, + }}, + "until": Object{"logic": "and", "conditions": []any{ + Object{"variableSelector": []any{"loop-1-agent", "output"}, "operator": "not_empty"}, + }}, + "outputs": []any{Object{"name": "result", "variableSelector": []any{"loop-1-agent", "output"}}}, + }}}, + Object{"id": "loop-1-start", "data": Object{"kind": "start", "title": "轮次开始", "containerId": "loop-1", "inputVariables": []any{}}}, + agent, + Object{"id": "output-1", "data": Object{"kind": "output", "title": "输出"}}, + }, + "edges": []any{ + Object{"source": "start-1", "target": "loop-1"}, + Object{"source": "loop-1", "target": "output-1"}, + Object{"source": "loop-1-start", "target": "loop-1-agent"}, + }, + } + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("the palette's starter group must publish, got problems %v", got) + } +} + +// TestWorkflowPublishRefusesInvalidLoopContainers is the structural matrix: membership, body start, +// nested composites, body output, cross-scope edges, loopExit terminality and reachability. +func TestWorkflowPublishRefusesInvalidLoopContainers(t *testing.T) { + cases := []struct { + name string + mutate func(graph Object) + want []string + }{ + { + name: "body without a start", + mutate: func(graph Object) { + graph["nodes"] = removeNode(graph["nodes"], "entry") + graph["edges"] = removeEdge(graph["edges"], "entry", "worker") + }, + want: []string{"loop_body_start_count@loop-1"}, + }, + { + name: "body with two starts", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "entry-2", "data": Object{"kind": "start", "containerId": "loop-1"}}) + }, + want: []string{"loop_body_start_count@loop-1"}, + }, + { + name: "nested loop inside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "loop-2", "data": Object{"kind": "loop", "containerId": "loop-1"}}) + }, + want: []string{"loop_nested_composite_unsupported@loop-2"}, + }, + { + name: "output inside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "out-2", "data": Object{"kind": "output", "containerId": "loop-1"}}) + graph["edges"] = appendEdge(graph["edges"], "worker", "out-2") + }, + want: []string{"loop_body_output_unsupported@out-2"}, + }, + { + name: "edge crossing the container boundary", + mutate: func(graph Object) { + graph["edges"] = appendEdge(graph["edges"], "loop-1", "worker") + }, + want: []string{"loop_cross_container_edge@loop-1"}, + }, + { + name: "loopExit outside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "exit-1", "data": Object{"kind": "loopExit"}}) + }, + want: []string{"loop_exit_outside_body@exit-1"}, + }, + { + name: "loopExit with an outgoing edge", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "exit-1", "data": Object{"kind": "loopExit", "containerId": "loop-1"}}) + graph["edges"] = appendEdge(graph["edges"], "entry", "exit-1") + graph["edges"] = appendEdge(graph["edges"], "exit-1", "worker") + }, + want: []string{"loop_exit_has_outgoing_edge@exit-1"}, + }, + { + name: "unreachable body member", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "stray", "data": Object{"kind": "agent", "containerId": "loop-1", "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}}) + }, + want: []string{"loop_body_unreachable@stray"}, + }, + { + name: "member naming a container that is not a loop", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "lost", "data": Object{"kind": "agent", "containerId": "out", "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}}) + }, + want: []string{"loop_member_container_mismatch@lost"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + tc.mutate(graph) + if got := loopProblemStrings(t, graph); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} + +// TestWorkflowPublishRefusesInvalidLoopConfig is the configuration matrix: the round budget, the +// exhaustion policy, carried names and types, the initial value, feedback, until and outputs. +func TestWorkflowPublishRefusesInvalidLoopConfig(t *testing.T) { + cases := []struct { + name string + mutate func(config Object) + want []string + }{ + { + name: "missing loopConfig", + mutate: nil, + want: []string{"loop_config_missing@loop-1#loopConfig"}, + }, + { + name: "non-integer maxIterations", + mutate: func(config Object) { config["maxIterations"] = 2.5 }, + want: []string{"loop_max_iterations_invalid@loop-1#loopConfig.maxIterations"}, + }, + { + name: "maxIterations above the ceiling", + mutate: func(config Object) { config["maxIterations"] = 101 }, + want: []string{"loop_max_iterations_invalid@loop-1#loopConfig.maxIterations"}, + }, + { + name: "unknown exhaustion policy", + mutate: func(config Object) { config["maxIterationsBehavior"] = "ignore" }, + want: []string{"loop_max_iterations_behavior_invalid@loop-1#loopConfig.maxIterationsBehavior"}, + }, + { + name: "variable name with a dot", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["name"] = "draft.value" + }, + want: []string{"loop_variable_invalid@loop-1#loopConfig.variables.name"}, + }, + { + name: "duplicate variable names", + mutate: func(config Object) { + config["variables"] = append(config["variables"].([]any), Object{"name": "draft", "valueType": "string", "initial": Object{"kind": "constant", "value": ""}, "feedback": []any{"worker", "output"}}) + }, + want: []string{"loop_variable_duplicate@loop-1#loopConfig.variables.name"}, + }, + { + name: "unsupported variable type", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["valueType"] = "date" + }, + want: []string{"loop_variable_type_unsupported@loop-1#loopConfig.variables.valueType"}, + }, + { + name: "constant that does not match its type", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["initial"] = Object{"kind": "constant", "value": 3} + }, + want: []string{"loop_initial_invalid@loop-1#loopConfig.variables.initial.value"}, + }, + { + name: "initial selector on an undeclared root", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["initial"] = Object{"kind": "variable", "selector": []any{"missing", "value"}} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.variables.initial.selector"}, + }, + { + name: "feedback on an undeclared root", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["feedback"] = []any{"missing", "value"} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.variables.feedback"}, + }, + { + name: "until logic that is neither and nor or", + mutate: func(config Object) { + config["until"].(Object)["logic"] = "xor" + }, + want: []string{"loop_until_invalid@loop-1#loopConfig.until.logic"}, + }, + { + name: "until comparison with an unknown operator", + mutate: func(config Object) { + config["until"].(Object)["conditions"].([]any)[0].(Object)["operator"] = "bogus" + }, + want: []string{"condition_operator_unsupported@loop-1#cases.conditions.operator"}, + }, + { + name: "duplicate output names", + mutate: func(config Object) { + config["outputs"] = append(config["outputs"].([]any), Object{"name": "result", "variableSelector": []any{"worker", "output"}}) + }, + want: []string{"loop_output_duplicate@loop-1#loopConfig.outputs.name"}, + }, + { + name: "output selector on the loop's own output", + mutate: func(config Object) { + config["outputs"].([]any)[0].(Object)["variableSelector"] = []any{"loop-1", "result"} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.outputs.variableSelector"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var config Object + if tc.mutate != nil { + config = validLoopConfig() + tc.mutate(config) + } + if got := loopProblemStrings(t, loopTestGraph(config)); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} + +// TestWorkflowPublishResolvesLoopRootsPerScope is the visibility rule: a carried variable is readable +// inside the body and refused outside it, and a published output is the reverse. +func TestWorkflowPublishResolvesLoopRootsPerScope(t *testing.T) { + t.Run("body reads a carried value", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "containerId": "loop-1", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "draft"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "worker", "check") + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("a body node may read its loop's carried variable, got %v", got) + } + }) + t.Run("outer graph cannot read a carried value", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "draft"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "loop-1", "check") + want := []string{"condition_selector_undeclared@check#cases.conditions.variableSelector"} + if got := loopProblemStrings(t, graph); !reflect.DeepEqual(got, want) { + t.Fatalf("problems = %v, want %v", got, want) + } + }) + t.Run("outer graph reads the loop's published output", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "result"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "loop-1", "check") + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("the outer graph may read a loop's published output, got %v", got) + } + }) +} + +// TestWorkflowStartNodeIgnoresABodyStart keeps the launcher fields reading the outer graph: a body +// start appears first in the document here, which is exactly the order that would have been read as +// the run's entry point before containers existed. +func TestWorkflowStartNodeIgnoresABodyStart(t *testing.T) { + graph := Object{ + "nodes": []any{ + Object{"id": "entry", "data": Object{"kind": "start", "containerId": "loop-1", "input": "body prompt", "inputVariables": []any{Object{"name": "inner"}}}}, + Object{"id": "start-1", "data": Object{"kind": "start", "input": "run prompt", "inputVariables": []any{Object{"name": "outer"}}}}, + }, + } + decoded := decodeGraph(t, graph) + if got := workflowStartPrompt(decoded); got != "run prompt" { + t.Fatalf("start prompt = %q, want the outer start's prompt", got) + } + variables := workflowStartVariables(decoded) + if len(variables) != 1 || variables[0].S("name") != "outer" { + t.Fatalf("start variables = %v, want only the outer declaration", variables) + } + if got := readWorkflowGraph(decoded).Start; got != "start-1" { + t.Fatalf("run entry point = %q, want the outer start-1", got) + } +} + +// appendNode and appendEdge keep the structural cases readable without rebuilding the graph. +func appendNode(nodes any, node Object) []any { + list, _ := nodes.([]any) + return append(list, node) +} + +// appendEdge appends one edge, preserving the document's list type. +func appendEdge(edges any, source, target string) []any { + list, _ := edges.([]any) + return append(list, Object{"source": source, "target": target}) +} + +// removeNode drops one node by id from a document list. +func removeNode(nodes any, id string) []any { + list, _ := nodes.([]any) + out := make([]any, 0, len(list)) + for _, item := range list { + node, ok := asWorkflowObject(item) + if ok && node.S("id") == id { + continue + } + out = append(out, item) + } + return out +} + +// removeEdge drops the edge with the given endpoints. +func removeEdge(edges any, source, target string) []any { + list, _ := edges.([]any) + out := make([]any, 0, len(list)) + for _, item := range list { + edge, ok := asWorkflowObject(item) + if ok && edge.S("source") == source && edge.S("target") == target { + continue + } + out = append(out, item) + } + return out +} + +// TestWorkflowContainerIDReadsBothMembershipKeys pins the iteration ADR's D1 normalization: an +// explicit body container wins, React Flow's parentId is the fallback, and a loose node has none. +func TestWorkflowContainerIDReadsBothMembershipKeys(t *testing.T) { + explicit := Object{"id": "member", "parentId": "iteration-1", "data": Object{"containerId": "loop-1"}} + if got := workflowContainerID(explicit); got != "loop-1" { + t.Fatalf("explicit containerId = %q; want loop-1", got) + } + inherited := Object{"id": "member", "parentId": "iteration-1", "data": Object{}} + if got := workflowContainerID(inherited); got != "iteration-1" { + t.Fatalf("parentId fallback = %q; want iteration-1", got) + } + loose := Object{"id": "member", "data": Object{}} + if got := workflowContainerID(loose); got != "" { + t.Fatalf("loose node container = %q; want empty", got) + } +} diff --git a/internal/core/workflow_loop_run.go b/internal/core/workflow_loop_run.go new file mode 100644 index 00000000..6e20c36a --- /dev/null +++ b/internal/core/workflow_loop_run.go @@ -0,0 +1,654 @@ +package core + +import ( + "fmt" +) + +// The loop round engine. A loop is a container node whose body is a separate DAG (specs +// decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md). The active round's member +// states live in the run's flat node_states map, which is what lets the existing session, retry, +// branch and viewer paths keep working untouched: a body agent is an ordinary agent node while it +// runs. Completed rounds are archived on the loop node's own state. +// +// The engine is a pure advance function over committed state: it evaluates carried values, drives the +// body projection, folds a completed round into feedback, until and outputs, and either starts the +// next round or settles the loop. Nothing is remembered between passes. + +// workflowLoopRunState is the durable round progress of one loop node. +type workflowLoopRunState struct { + Round int64 + MaxIterations int64 + // Carried is the value set the active round entered with, keyed by the declared variable name. + Carried map[string]any + // Completed archives every finished round: {index, carried, states}. The final round is archived + // too; its member states also stay in the flat map so the run viewer can show them. + Completed []any + // Reason is the successful completion reason: condition_satisfied, max_iterations or loop_exit. + Reason string +} + +// object renders the loop progress in the shape the run viewer reads. +func (s *workflowLoopRunState) object() Object { + carried := Object{} + for name, value := range s.Carried { + carried[name] = value + } + completed := s.Completed + if completed == nil { + completed = []any{} + } + out := Object{ + "round": s.Round, + "maxIterations": s.MaxIterations, + "carried": carried, + "completed": completed, + } + if s.Reason != "" { + out["reason"] = s.Reason + } + return out +} + +// workflowLoopRunStateFromJSON reads committed loop progress back. A missing round reads as round 1 so +// a truncated document resumes at the first round instead of inventing a later one. +func workflowLoopRunStateFromJSON(raw Object) *workflowLoopRunState { + state := &workflowLoopRunState{ + Round: raw.N("round"), + MaxIterations: raw.N("maxIterations"), + Carried: map[string]any{}, + Reason: raw.S("reason"), + } + if state.Round < 1 { + state.Round = 1 + } + if carried, ok := asWorkflowObject(raw["carried"]); ok { + for name, value := range carried { + state.Carried[name] = value + } + } + if completed, ok := raw["completed"].([]any); ok { + state.Completed = completed + } + return state +} + +// workflowLoopVariableConfig is one carried variable after parsing: its first-round initializer and +// the selector that supplies the next round's value. +type workflowLoopVariableConfig struct { + Name string + ValueType string + InitialKind string + InitialValue any + InitialSelector workflowSelector + Feedback workflowSelector +} + +// workflowLoopOutput is one named result the loop publishes when it succeeds. +type workflowLoopOutput struct { + Name string + Selector workflowSelector +} + +// workflowLoopConfig is the executable shape of `data.loopConfig`. It is parsed defensively: a +// snapshot that predates the publish gate must fail the node with an explanation, never panic or +// silently run with defaults. +type workflowLoopConfig struct { + MaxIterations int64 + Behavior string + Variables []workflowLoopVariableConfig + UntilLogic string + UntilRules []any + Outputs []workflowLoopOutput +} + +// parseWorkflowLoopConfig compiles the wire config the publish gate accepted. Every field the engine +// needs is required here as well, because a frozen snapshot may predate the gate. +func parseWorkflowLoopConfig(data Object) (workflowLoopConfig, error) { + raw, present := data["loopConfig"] + if !present || raw == nil { + return workflowLoopConfig{}, fmt.Errorf("loop node has no loopConfig") + } + config, ok := asWorkflowObject(raw) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loopConfig must be an object") + } + maxIterations, numeric := workflowNumberOf(config["maxIterations"]) + if !numeric || maxIterations < workflowLoopMinIterations || maxIterations > workflowLoopMaxIterations { + return workflowLoopConfig{}, fmt.Errorf("maxIterations must be between %d and %d", workflowLoopMinIterations, workflowLoopMaxIterations) + } + parsed := workflowLoopConfig{MaxIterations: int64(maxIterations), Behavior: "fail"} + switch behavior := config.S("maxIterationsBehavior"); behavior { + case "": + case "fail", "succeed": + parsed.Behavior = behavior + default: + return workflowLoopConfig{}, fmt.Errorf("unknown maxIterationsBehavior %q", behavior) + } + variables, ok := config["variables"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop variables must be a list") + } + for _, entry := range variables { + variable, err := parseWorkflowLoopVariable(entry) + if err != nil { + return workflowLoopConfig{}, err + } + parsed.Variables = append(parsed.Variables, variable) + } + until, ok := asWorkflowObject(config["until"]) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop until must be an object") + } + switch until.S("logic") { + case "and", "or": + parsed.UntilLogic = until.S("logic") + default: + return workflowLoopConfig{}, fmt.Errorf("loop until logic must be and or or") + } + rules, ok := until["conditions"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop until conditions must be a list") + } + parsed.UntilRules = rules + outputs, ok := config["outputs"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop outputs must be a list") + } + for _, entry := range outputs { + output, ok := asWorkflowObject(entry) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop output must be an object") + } + selector, ok := parseWorkflowSelector(output["variableSelector"]) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop output %q has an invalid selector", output.S("name")) + } + parsed.Outputs = append(parsed.Outputs, workflowLoopOutput{Name: output.S("name"), Selector: selector}) + } + return parsed, nil +} + +// parseWorkflowLoopVariable compiles one carried variable. +func parseWorkflowLoopVariable(raw any) (workflowLoopVariableConfig, error) { + entry, ok := asWorkflowObject(raw) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable must be an object") + } + variable := workflowLoopVariableConfig{Name: entry.S("name"), ValueType: entry.S("valueType")} + if variable.Name == "" || variable.ValueType == "" { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable needs a name and a value type") + } + initial, ok := asWorkflowObject(entry["initial"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has no initial value", variable.Name) + } + switch variable.InitialKind = initial.S("kind"); variable.InitialKind { + case "constant": + value, present := initial["value"] + if !present { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has no initial constant", variable.Name) + } + normalized, ok := normalizeWorkflowValue(value, variable.ValueType) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q initial value does not match %s", variable.Name, variable.ValueType) + } + variable.InitialValue = normalized + case "variable": + selector, ok := parseWorkflowSelector(initial["selector"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an invalid initial selector", variable.Name) + } + variable.InitialSelector = selector + default: + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an unknown initial kind", variable.Name) + } + feedback, ok := parseWorkflowSelector(entry["feedback"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an invalid feedback selector", variable.Name) + } + variable.Feedback = feedback + return variable, nil +} + +// workflowOuterGraph is the graph the run's wave executes: every loop body is driven by its own +// container engine, so members are not nodes of the outer ready set. +func workflowOuterGraph(graph workflowGraphView) workflowGraphView { + out := workflowGraphView{ByNode: map[string]workflowNodeView{}, Start: graph.Start} + for _, node := range graph.Nodes { + if node.Data.S("containerId") != "" { + continue + } + out.Nodes = append(out.Nodes, node) + out.ByNode[node.ID] = node + } + for _, edge := range graph.Edges { + if _, ok := out.ByNode[edge.S("source")]; !ok { + continue + } + if _, ok := out.ByNode[edge.S("target")]; !ok { + continue + } + out.Edges = append(out.Edges, edge) + } + return out +} + +// workflowBodyGraph is one loop's body as a standalone graph: members, internal edges and the body +// start. An empty Start means the document has no defined place to begin and the loop cannot run. +func workflowBodyGraph(graph workflowGraphView, loopID string) workflowGraphView { + out := workflowGraphView{ByNode: map[string]workflowNodeView{}} + for _, node := range graph.Nodes { + if node.Data.S("containerId") != loopID { + continue + } + out.Nodes = append(out.Nodes, node) + out.ByNode[node.ID] = node + if node.Kind == "start" && out.Start == "" { + out.Start = node.ID + } + } + for _, edge := range graph.Edges { + if _, ok := out.ByNode[edge.S("source")]; !ok { + continue + } + if _, ok := out.ByNode[edge.S("target")]; !ok { + continue + } + out.Edges = append(out.Edges, edge) + } + return out +} + +// runningWorkflowLoop finds a loop whose state says it is mid-flight. A committed running loop is not +// "ready" in the projection, so the wave has to drive it explicitly before looking for new work. +func runningWorkflowLoop(graph workflowGraphView, states map[string]workflowNodeState) (workflowNodeView, bool) { + for _, node := range graph.Nodes { + if node.Kind == "loop" && states[node.ID].Status == "running" && states[node.ID].Loop != nil { + return node, true + } + } + return workflowNodeView{}, false +} + +// advanceWorkflowLoop drives one loop as far as committed state allows. It returns true when the pass +// may continue the outer wave (progress was made or the loop is terminal), and false when the pass +// must return — a body agent session was started, or the loop is waiting for one. +func advanceWorkflowLoop( + t *transaction, + run Object, + node workflowNodeView, + version int64, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, + graph workflowGraphView, +) bool { + config, err := parseWorkflowLoopConfig(node.Data) + if err != nil { + failWorkflowLoop(states, node, "loop_invalid_config", err.Error()) + return true + } + state, committed := states[node.ID] + if !committed { + carried, err := initializeWorkflowLoopCarried(config, graph, node.ID, states, ambient) + if err != nil { + failWorkflowLoop(states, node, "loop_initial_invalid", err.Error()) + return true + } + state = workflowNodeState{ + Status: "running", + DisplayName: workflowNodeDisplayName(node), + StartedAt: nowRFC3339(), + Loop: &workflowLoopRunState{ + Round: 1, + MaxIterations: config.MaxIterations, + Carried: carried, + Completed: []any{}, + }, + } + } else if state.Loop == nil || state.Status != "running" { + return true + } + states[node.ID] = state + body := workflowBodyGraph(graph, node.ID) + if body.Start == "" { + failWorkflowLoop(states, node, "loop_body_start_count", "loop body has no start node") + return true + } + projection := projectWorkflowNodes(body, states) + if projection.hasFailure() { + reason, detail := workflowBodyFailure(body, states) + failWorkflowLoop(states, node, reason, detail) + return true + } + if ready, ok := projection.readyNode(); ok { + if ready.Kind == "agent" { + startWorkflowAgentSession(t, run, ready, version, states) + return false + } + readStates := workflowLoopReadStates(graph, node.ID, states, ambient) + states[ready.ID] = executeWorkflowNode(ready, run.O("input"), readStates, ambient) + return true + } + if projection.hasPending() { + return false + } + if workflowBodyInFlight(body, states) { + // A committed running or retrying member owns the serial slot; the pass settles it through the + // session or retry path before the round can be judged complete. + return false + } + // The body is drained. A succeeded loopExit short-circuits the round: no feedback is computed and + // the published outputs come from the round the exit produced. + roundStates := workflowLoopReadStates(graph, node.ID, states, ambient) + if workflowLoopExitSucceeded(body, states) { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_exit_output_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "loop_exit", outputs) + return true + } + feedback, err := resolveWorkflowLoopFeedback(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + satisfied, err := workflowLoopUntilSatisfied(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + if satisfied { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "condition_satisfied", outputs) + return true + } + if state.Loop.Round >= config.MaxIterations { + if config.Behavior == "succeed" { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "max_iterations", outputs) + return true + } + // The round that exhausted the budget is a finished round: archive it before failing so the + // run's evidence contains the values the condition was judged against. + archiveWorkflowLoopRound(state.Loop, body, states, true) + failWorkflowLoop(states, node, "loop_max_iterations_reached", + fmt.Sprintf("loop did not terminate within %d rounds", config.MaxIterations)) + return true + } + archiveWorkflowLoopRound(state.Loop, body, states, false) + state.Loop.Round++ + state.Loop.Carried = feedback + states[node.ID] = state + return true +} + +// initializeWorkflowLoopCarried evaluates every carried variable once, before the first round: a +// constant is normalized against its declared type and a selector is read from the loop's outer +// scope. A missing or mistyped value fails the loop instead of starting it with a hole. +func initializeWorkflowLoopCarried( + config workflowLoopConfig, + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) (map[string]any, error) { + outer := workflowLoopOuterStates(graph, loopID, states, ambient) + carried := map[string]any{} + for _, variable := range config.Variables { + value := variable.InitialValue + if variable.InitialKind == "variable" { + resolved, present, err := resolveWorkflowSelector(variable.InitialSelector, outer) + if err != nil || !present { + return nil, fmt.Errorf("initial value of %s is not available", variable.Name) + } + value, _ = normalizeWorkflowValue(resolved, variable.ValueType) + } + carried[variable.Name] = value + } + return carried, nil +} + +// resolveWorkflowLoopFeedback reads every feedback selector from the completed round in one snapshot +// and normalizes it against the declared type. All variables are read before any is assigned, so two +// variables that swap values receive each other's value. +func resolveWorkflowLoopFeedback(config workflowLoopConfig, roundStates map[string]workflowNodeState) (map[string]any, error) { + carried := map[string]any{} + for _, variable := range config.Variables { + value, present, err := resolveWorkflowSelector(variable.Feedback, roundStates) + if err != nil || !present { + return nil, fmt.Errorf("feedback selector %s has no value", variable.Feedback.qualified()) + } + normalized, ok := normalizeWorkflowValue(value, variable.ValueType) + if !ok { + return nil, fmt.Errorf("feedback selector %s does not match %s", variable.Feedback.qualified(), variable.ValueType) + } + carried[variable.Name] = normalized + } + return carried, nil +} + +// workflowLoopUntilSatisfied evaluates the round-end condition over the completed round. An until with +// no rules never matches, which is desktop's reading: only a case with conditions can select. +func workflowLoopUntilSatisfied(config workflowLoopConfig, roundStates map[string]workflowNodeState) (bool, error) { + until := workflowNodeView{ + ID: "until", + Kind: "condition", + Data: Object{"cases": []any{Object{"id": "until", "logic": config.UntilLogic, "conditions": config.UntilRules}}}, + } + branch, err := evaluateWorkflowCondition(until, roundStates) + if err != nil { + return false, err + } + return branch != workflowBranchElse, nil +} + +// resolveWorkflowLoopOutputs reads every named output from the round that produced it. +func resolveWorkflowLoopOutputs(config workflowLoopConfig, roundStates map[string]workflowNodeState) (Object, error) { + outputs := Object{} + for _, output := range config.Outputs { + value, present, err := resolveWorkflowSelector(output.Selector, roundStates) + if err != nil || !present { + return nil, fmt.Errorf("loop output %s has no value", output.Name) + } + outputs[output.Name] = value + } + return outputs, nil +} + +// workflowLoopReadStates is the round's variable pool as committed states: ambient variables, the +// loop's outer ancestors, the active round's member states, and the carried values under the loop's +// own node id. It is what feedback, until, outputs and body conditions read. +func workflowLoopReadStates( + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) map[string]workflowNodeState { + round := workflowLoopOuterStates(graph, loopID, states, ambient) + for _, node := range graph.Nodes { + if node.Data.S("containerId") != loopID { + continue + } + if state, ok := states[node.ID]; ok { + round[node.ID] = state + } + } + loop := states[loopID].Loop + if loop != nil { + carried := Object{} + for name, value := range loop.Carried { + carried[name] = value + } + round[loopID] = workflowNodeState{Status: "succeeded", Output: carried} + } + return round +} + +// workflowLoopOuterStates is the pool visible before any round: ambient variables overlaid with the +// loop's transitive predecessors. Body members are never ancestors of their own loop. +func workflowLoopOuterStates( + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) map[string]workflowNodeState { + outer := map[string]workflowNodeState{} + for id, state := range ambient { + outer[id] = state + } + for id := range workflowAncestorIDs(graph, loopID) { + if state, ok := states[id]; ok { + outer[id] = state + } + } + return outer +} + +// workflowAncestorIDs walks the reverse edges of one node and returns its transitive predecessors. +func workflowAncestorIDs(graph workflowGraphView, nodeID string) map[string]bool { + ancestors := map[string]bool{} + queue := []string{nodeID} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, edge := range graph.Edges { + if edge.S("target") != current { + continue + } + source := edge.S("source") + if source == nodeID || ancestors[source] { + continue + } + ancestors[source] = true + queue = append(queue, source) + } + } + return ancestors +} + +// workflowBodyFailure names the first failed body member in document order with its committed reason. +func workflowBodyFailure(body workflowGraphView, states map[string]workflowNodeState) (string, string) { + for _, member := range body.Nodes { + state := states[member.ID] + if state.Status != "failed" { + continue + } + reason := state.Output.S("reason") + if reason == "" { + reason = "node failed" + } + return reason, state.Error + } + return "node failed", "" +} + +// workflowBodyInFlight reports whether the active round still owns a member the pass must wait for: a +// running body agent session or a retrying one. The round cannot be judged complete until it settles. +func workflowBodyInFlight(body workflowGraphView, states map[string]workflowNodeState) bool { + for _, member := range body.Nodes { + switch states[member.ID].Status { + case "running", "retrying": + return true + } + } + return false +} + +// workflowLoopExitSucceeded reports whether the round selected and completed a loopExit member. +func workflowLoopExitSucceeded(body workflowGraphView, states map[string]workflowNodeState) bool { + for _, member := range body.Nodes { + if member.Kind == "loopExit" && states[member.ID].Status == "succeeded" { + return true + } + } + return false +} + +// cancelWorkflowLoops marks every in-flight loop cancelled when the run itself is being cancelled. +// A cancelled run is terminal, so a loop left `running` would misreport a container that will never +// advance; the loop state keeps its rounds as the audit trail of what did run. +func cancelWorkflowLoops(states map[string]workflowNodeState) { + for id, state := range states { + if state.Status != "running" || state.Loop == nil { + continue + } + state.Status = "cancelled" + state.FinishedAt = nowRFC3339() + states[id] = state + } +} + +// failWorkflowLoop commits one loop failure: the node's stable reason, a readable detail, and the +// round archive as it stood. The run settles failed from this committed state, never from the pass. +func failWorkflowLoop(states map[string]workflowNodeState, node workflowNodeView, reason, detail string) { + state := states[node.ID] + if state.DisplayName == "" { + state.DisplayName = workflowNodeDisplayName(node) + } + if state.StartedAt == "" { + state.StartedAt = nowRFC3339() + } + state.Status = "failed" + state.FinishedAt = nowRFC3339() + state.Error = detail + state.Output = Object{"reason": reason} + states[node.ID] = state +} + +// finishWorkflowLoop archives the final round, keeps its member states visible, and publishes the +// loop's outputs. The completion reason is recorded on the loop state so the viewer can distinguish +// "the condition held" from "the budget ran out". +func finishWorkflowLoop( + states map[string]workflowNodeState, + node workflowNodeView, + state workflowNodeState, + body workflowGraphView, + reason string, + outputs Object, +) { + if state.Loop != nil { + archiveWorkflowLoopRound(state.Loop, body, states, true) + state.Loop.Reason = reason + } + state.Status = "succeeded" + state.FinishedAt = nowRFC3339() + state.Error = "" + state.Output = outputs + states[node.ID] = state +} + +// archiveWorkflowLoopRound records the active round's member states as one durable entry. When the +// loop continues, the members are removed from the flat map so the next round starts from nothing; +// when the loop finishes they stay, because they are the round the viewer should see. +func archiveWorkflowLoopRound(loop *workflowLoopRunState, body workflowGraphView, states map[string]workflowNodeState, keep bool) { + archived := Object{} + for _, member := range body.Nodes { + state, ok := states[member.ID] + if !ok { + continue + } + archived[member.ID] = state.object() + if !keep { + delete(states, member.ID) + } + } + carried := Object{} + for name, value := range loop.Carried { + carried[name] = value + } + loop.Completed = append(loop.Completed, Object{ + "index": loop.Round, + "carried": carried, + "states": archived, + }) +} diff --git a/internal/core/workflow_loop_scheduler_db_test.go b/internal/core/workflow_loop_scheduler_db_test.go new file mode 100644 index 00000000..114fce16 --- /dev/null +++ b/internal/core/workflow_loop_scheduler_db_test.go @@ -0,0 +1,241 @@ +package core + +import ( + "context" + "testing" +) + +// These are the loop round engine's end-to-end scheduler tests over PostgreSQL. They use no agent +// node, so every round is decided inline and the assertions are about the round machine itself: +// simultaneous feedback, until, exhaustion and loopExit. + +// loopSceneGraph builds start → loop → output with a body of the given members and edges. The loop's +// config is supplied by the caller so each test states the contract it exercises. +func loopSceneGraph(loopConfig Object, bodyNodes []Object, bodyEdges [][3]string) Object { + loop := wfNode("l", "loop", "循环 1") + loop["data"].(Object)["loopConfig"] = loopConfig + nodes := []Object{wfNode("s", "start", "开始 1"), loop, wfNode("o", "output", "输出 1")} + nodes = append(nodes, bodyNodes...) + edges := [][3]string{{"s", "l", ""}, {"l", "o", ""}} + edges = append(edges, bodyEdges...) + return wfGraph(nodes, edges) +} + +// loopBodyStart is the one start node a loop body requires. +func loopBodyStart(id string) Object { + node := wfNode(id, "start", "入口") + node["data"].(Object)["containerId"] = "l" + return node +} + +// loopVar builds one carried variable with a constant initial and a feedback selector. +func loopVar(name, valueType string, initial any, feedback ...string) Object { + selector := make([]any, 0, len(feedback)) + for _, part := range feedback { + selector = append(selector, part) + } + return Object{ + "name": name, + "valueType": valueType, + "initial": Object{"kind": "constant", "value": initial}, + "feedback": selector, + } +} + +// loopUntilEquals builds the round-end condition `selector equals value`. +func loopUntilEquals(selector []any, value any) Object { + return Object{ + "logic": "and", + "conditions": []any{ + Object{"variableSelector": selector, "operator": "equals", "value": value}, + }, + } +} + +// readLoopState projects the committed loop progress for assertions. +func readLoopState(t *testing.T, states Object) (round int64, reason string, completed int) { + t.Helper() + node, ok := asWorkflowObject(states["l"]) + if !ok { + t.Fatalf("loop node has no committed state") + } + loop, ok := asWorkflowObject(node["loop"]) + if !ok { + t.Fatalf("loop node has no committed loop state: %v", node) + } + finished, _ := loop["completed"].([]any) + return loop.N("round"), loop.S("reason"), len(finished) +} + +// TestWorkflowAdvanceLoopFinishesWhenUntilHolds drives two rounds with a carried pair that swaps +// values each round: the first round does not satisfy until, the second does. It pins that feedback +// is read from one completed snapshot (a swap must exchange values, not collapse both onto one) and +// that the loop publishes the final carried value. +func TestWorkflowAdvanceLoopFinishesWhenUntilHolds(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 3, + "variables": []any{ + loopVar("a", "string", "a", "l", "b"), + loopVar("b", "string", "b", "l", "a"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "b"), + "outputs": []any{Object{"name": "result", "variableSelector": []any{"l", "a"}}}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + round, reason, completed := readLoopState(t, states) + if round != 2 || reason != "condition_satisfied" || completed != 2 { + t.Fatalf("loop round=%d reason=%q completed=%d; want 2 condition_satisfied 2", round, reason, completed) + } + node, _ := asWorkflowObject(states["l"]) + output, _ := asWorkflowObject(node["output"]) + if got := output["result"]; got != "b" { + t.Fatalf("loop output result = %v; want the swapped carried value b", got) + } + if got := wfNodeStatus(t, states, "o"); got != "succeeded" { + t.Fatalf("output node status = %q; want succeeded", got) + } +} + +// TestWorkflowAdvanceLoopFailsOnExhaustion pins the default exhaustion policy: a loop that never +// satisfies until fails the run with a stable reason instead of publishing its last round. +func TestWorkflowAdvanceLoopFailsOnExhaustion(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 2, + "variables": []any{ + loopVar("a", "string", "a", "l", "b"), + loopVar("b", "string", "b", "l", "a"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "never"), + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "loop_max_iterations_reached" { + t.Fatalf("run settled %q error %q; want failed loop_max_iterations_reached", status, runErr) + } + round, _, completed := readLoopState(t, states) + if round != 2 || completed != 2 { + t.Fatalf("loop round=%d completed=%d; want 2 2", round, completed) + } +} + +// TestWorkflowAdvanceLoopFailsWhenFeedbackIsUnset pins the atomic feedback rule: a selector that names +// an assigned-but-absent root fails the loop instead of starting a round with a hole. +func TestWorkflowAdvanceLoopFailsWhenFeedbackIsUnset(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 2, + "variables": []any{ + loopVar("a", "string", "a", "s", "missing"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "never"), + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, _, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "loop_value_unset" { + t.Fatalf("run settled %q error %q; want failed loop_value_unset", status, runErr) + } +} + +// TestWorkflowAdvanceCancelsARunningLoop pins the cancellation reaction: a run cancelled while its +// loop is mid-flight settles `cancelled`, and the loop node is cancelled with it instead of staying +// `running` in a terminal run. +func TestWorkflowAdvanceCancelsARunningLoop(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 3, + "variables": []any{}, + "until": Object{"logic": "and", "conditions": []any{}}, + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + commitWorkflowRunMidpoint(t, store, runID, Object{ + "s": Object{"status": "succeeded", "output": Object{"input": ""}}, + "l": Object{"status": "running", "startedAt": nowRFC3339(), "loop": Object{ + "round": 1, + "maxIterations": 3, + "carried": Object{}, + "completed": []any{}, + }}, + }) + if _, err := store.Pool.Exec(`UPDATE workflow_runs SET cancel_requested_at=now() WHERE id=$1`, runID); err != nil { + t.Fatalf("request cancellation: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "cancelled" || runErr != "" { + t.Fatalf("run settled %q error %q; want cancelled", status, runErr) + } + if got := wfNodeStatus(t, states, "l"); got != "cancelled" { + t.Fatalf("loop node status = %q; want cancelled", got) + } + if _, ran := states["o"]; ran { + t.Fatalf("a node downstream of the cancelled loop committed: %v", states) + } +} + +// TestWorkflowAdvanceLoopExitsEarly pins loopExit: the marker ends the round before feedback, and the +// loop publishes the outputs resolved from the round that produced it. +func TestWorkflowAdvanceLoopExitsEarly(t *testing.T) { + store := dispatcherDB(t) + exit := wfNode("exit", "loopExit", "早退") + exit["data"].(Object)["containerId"] = "l" + config := Object{ + "maxIterations": 5, + "variables": []any{ + loopVar("draft", "string", "x", "l", "draft"), + }, + "until": loopUntilEquals([]any{"l", "draft"}, "never"), + "outputs": []any{Object{"name": "result", "variableSelector": []any{"l", "draft"}}}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry"), exit}, + [][3]string{{"entry", "exit", ""}}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + round, reason, completed := readLoopState(t, states) + if round != 1 || reason != "loop_exit" || completed != 1 { + t.Fatalf("loop round=%d reason=%q completed=%d; want 1 loop_exit 1", round, reason, completed) + } + node, _ := asWorkflowObject(states["l"]) + output, _ := asWorkflowObject(node["output"]) + if got := output["result"]; got != "x" { + t.Fatalf("loop output result = %v; want the carried value x", got) + } +} diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 830ffb2b..348b2890 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -300,6 +300,9 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no // Declaring delete on a busy Project returns busy — a later pass re-declares, so a terminal run // is never left with a live Workspace forever. func finishWorkflowRun(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) { + if status == "cancelled" { + cancelWorkflowLoops(states) + } if settleWorkflowRunStates(t, runID, version, status, runErr, states) { finishWorkflowRunCleanup(t, runID) } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 714ec48a..9b9880e1 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -157,7 +157,9 @@ func readWorkflowGraph(graph Object) workflowGraphView { view := workflowNodeView{ID: id, Kind: o.O("data").S("kind"), Data: o.O("data")} out.Nodes = append(out.Nodes, view) out.ByNode[id] = view - if view.Kind == "start" && out.Start == "" { + // A loop body carries its own start. The run's entry point is the outer start only; picking a + // body start here would make the projection root inside the container and never run the loop. + if view.Kind == "start" && view.Data.S("containerId") == "" && out.Start == "" { out.Start = id } } @@ -256,6 +258,10 @@ type workflowNodeState struct { // replaces a failure, it does not erase it. Error string Output Object + // Loop is the round progress of a loop node and nil for every other kind. The active round's + // member states stay in the run's flat node_states map (so the session, retry and viewer paths see + // one shape); completed rounds are archived here for audit and recovery. + Loop *workflowLoopRunState } // attemptNumber is the state's 1-based attempt, reading an absent attempt as the first one. Only @@ -285,6 +291,9 @@ func (s *workflowNodeState) object() Object { if s.Output != nil { out["output"] = s.Output } + if s.Loop != nil { + out["loop"] = s.Loop.object() + } return out } @@ -311,6 +320,9 @@ func workflowStatesFromJSON(raw Object) map[string]workflowNodeState { if output, ok := asWorkflowObject(node["output"]); ok { state.Output = output } + if loop, ok := asWorkflowObject(node["loop"]); ok { + state.Loop = workflowLoopRunStateFromJSON(loop) + } out[id] = state } return out @@ -370,6 +382,18 @@ func (p *workflowProjection) readyNode() (workflowNodeView, bool) { return workflowNodeView{}, false } +// hasPending reports whether the projection left a node unresolved because an upstream edge is still +// pending. A scope whose nodes are all resolved and none ready has drained: for a loop body that is +// the moment the round is judged complete. +func (p *workflowProjection) hasPending() bool { + for _, node := range p.graph.Nodes { + if _, ok := p.projected[node.ID]; !ok { + return true + } + } + return false +} + // hasFailure reports whether any committed node failed. func (p *workflowProjection) hasFailure() bool { for _, state := range p.committed { @@ -591,8 +615,9 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura // would make the pass's final settle a silent no-op and leave the run in `running`. version = workflowRunVersion(t, runID, version) } + outer := workflowOuterGraph(graph) for { - projection := projectWorkflowNodes(graph, states) + projection := projectWorkflowNodes(outer, states) // A failed node decides the run. Dispatching another ready node in the same wave would do // real work for a run that is already terminal — for an agent that means provisioning a // Workspace and waiting on a session — only to settle failed after the session ends. Stopping @@ -601,6 +626,14 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura if projection.hasFailure() { break } + if loopNode, running := runningWorkflowLoop(outer, states); running { + // A running loop is not "ready" in the projection (its state is committed), so the wave + // drives it explicitly. A started body session ends the pass; other progress continues. + if !advanceWorkflowLoop(t, run, loopNode, version, states, ambient, graph) { + return + } + continue + } node, ready := projection.readyNode() if !ready { break @@ -613,6 +646,15 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura startWorkflowAgentSession(t, run, node, version, states) return } + if node.Kind == "loop" { + // Drive the container one step. A loop starts its body agent through the same session + // path, so a started session ends the pass; otherwise the loop's state is updated in + // this pass and the outer wave continues. + if !advanceWorkflowLoop(t, run, node, version, states, ambient, graph) { + return + } + continue + } states[node.ID] = executeWorkflowNode(node, run.O("input"), states, ambient) } // The wave stopped with no executable node left. Persist the projection's inactive nodes — @@ -620,9 +662,9 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura // tells the viewer the full story (a missing state renders as idle, which would understate a // branch the run already resolved). Then settle: failed when any committed node failed, // succeeded otherwise; the failure check reads committed state, never the pass's own reasoning. - states = workflowInactiveStates(graph, states) - if projectWorkflowNodes(graph, states).hasFailure() { - finishWorkflowRun(t, runID, version, "failed", workflowRunFailureReason(graph, states), states) + states = workflowInactiveStates(outer, states) + if projectWorkflowNodes(outer, states).hasFailure() { + finishWorkflowRun(t, runID, version, "failed", workflowRunFailureReason(outer, states), states) return } if workflowAnyCancelled(states) { @@ -732,6 +774,27 @@ func executeWorkflowNode(node workflowNodeView, input Object, states, ambient ma state.Output = Object{"selectedBranch": branch} case "output": state.Output = Object{} + case "aggregator": + value, assigned, err := evaluateWorkflowAggregator(node, workflowConditionStates(states, ambient)) + if err != nil { + state.Status = "failed" + state.Error = err.Error() + state.Output = Object{"reason": "aggregator_invalid_config"} + return state + } + if !assigned { + // Every candidate was declared but none was produced: the stable desktop failure, not an + // empty output. Publishing nothing would make a branch merge silently disappear. + state.Status = "failed" + state.Error = "variable aggregator found no assigned candidate" + state.Output = Object{"reason": "aggregator_no_match"} + return state + } + state.Output = Object{"output": value} + case "loopExit": + // The early-exit marker carries no configuration and produces nothing; the container engine + // reads its success to skip feedback and publish the round's outputs. + state.Output = Object{} default: // Unreachable: the ready pass only yields executable kinds, and the agent case returned // earlier. A future kind that reaches here must not be recorded as success. diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 0cddb1f6..fc024d7b 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -494,7 +494,7 @@ func TestWorkflowAdvanceIgnoresARepublishedVersion(t *testing.T) { func TestWorkflowAdvanceFailsAnUnsupportedNodeKind(t *testing.T) { store := dispatcherDB(t) runID := seedWorkflowRunScene(t, store, wfGraph( - []Object{wfNode("s", "start", ""), wfNode("l", "loop", "iterate"), wfNode("o", "output", "")}, + []Object{wfNode("s", "start", ""), wfNode("l", "iteration", "iterate"), wfNode("o", "output", "")}, [][3]string{{"s", "l", ""}, {"l", "o", ""}}, ))