Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
201d5b6
feat(core): publish loop containers and validate their scope and conf…
wanglongan587 Oct 25, 2026
11b300a
feat(core): execute loop rounds and loopExit in the scheduler
wanglongan587 Oct 25, 2026
ca12ae7
feat(workflows): add a Loop from the palette as one atomic starter group
wanglongan587 Oct 25, 2026
5aefaa3
feat(workflows): edit Loop rounds and import the loopExit marker
wanglongan587 Oct 25, 2026
aa6ddc1
test(core): pin the palette's loop starter group against the publish …
wanglongan587 Oct 25, 2026
d2370ea
fix(core): cancel a running loop when its run is cancelled
wanglongan587 Oct 25, 2026
2da4919
refactor(workflows): share one variable type menu between globals and…
wanglongan587 Oct 25, 2026
54f5c08
feat(workflows): edit Loop carried variables and named outputs
wanglongan587 Oct 25, 2026
2cc9af5
feat(workflows): edit a Loop's exit condition in the inspector
wanglongan587 Oct 25, 2026
ba54322
feat(core): publish and execute the variable aggregator node
wanglongan587 Oct 25, 2026
5f8325e
feat(workflows): author the variable aggregator node
wanglongan587 Oct 25, 2026
41008da
feat(workflows): integrate loop/loopExit and aggregator nodes
wanglongan587 Oct 25, 2026
950805c
feat(workflows): offer every condition operator the engine evaluates
wanglongan587 Oct 25, 2026
060df28
feat(workflows): reorder aggregator candidates in the inspector
wanglongan587 Oct 25, 2026
99d2b20
refactor(core): normalize container membership to containerId or pare…
wanglongan587 Oct 25, 2026
15559ed
feat(core): validate the iteration publish contract
wanglongan587 Oct 25, 2026
5e8676b
feat(core): execute iteration regions with a per-round ledger
wanglongan587 Oct 25, 2026
c0fcd07
test(core): cancel a running iteration with its run
wanglongan587 Oct 25, 2026
82b201a
fix(core): accept structured run input for json start variables
wanglongan587 Oct 25, 2026
71433a4
test(core): cover structured run input for array variables
wanglongan587 Oct 25, 2026
d16b335
feat(workflows): add a loopExit from the Loop inspector
wanglongan587 Oct 25, 2026
32a5998
feat(workflows): pick aggregator candidates from the variable catalog
wanglongan587 Oct 25, 2026
baa2bcd
feat(workflows): pick Loop output values from the variable catalog
wanglongan587 Oct 25, 2026
26d7da4
feat(workflows): pick Loop feedback and exit-rule variables from the …
wanglongan587 Oct 25, 2026
f85a908
feat(workflows): author distinct condition branch connections
wanglongan587 Oct 25, 2026
b181f3e
feat(workflows): edit and execute named output bindings
wanglongan587 Oct 25, 2026
0f002f0
fix(workflows): retain legacy empty branch port semantics
wanglongan587 Oct 25, 2026
6415865
fix(workflows): persist iteration progress across scheduler passes
wanglongan587 Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/workflows/workflows.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ deliverable declaration `agentConfig.completion` (any non-null value) and any `a
`3` — including `4`, the legacy `1`/`2`, and non-numbers: Cloud executes only version `3`, absent or `null` keeps the
old behavior, and the refusal names the node and field instead of silently dropping the declaration.

## Condition branches and named outputs

Condition cards expose a port for each case and the implicit ELSE branch. Connections persist the
case id in `sourceHandle`. Different branches may join the same node; duplicate endpoint-and-port
pairs are rejected. Deleting a case removes its connections, and undo restores both. Legacy
unlabeled condition connections retain ELSE semantics; case ordering never renumbers existing edges.

The Output inspector adds, names and removes `data.outputs` bindings, with sources selected from
the consumer's visible variable catalog. Execution resolves all bindings against one committed
snapshot and stores the named result on the output node. No bindings retain an empty object.
Explicit null, false, zero and empty strings remain assigned values. An unassigned binding or an
invalid path fails the node without publishing partial results. Publication rejects malformed
bindings, empty or duplicate names, invalid selectors and undeclared roots, with node/field locations.

## Executor slots

Agent nodes do not hard-code an Agent CLI or model ID in the graph; they reference an **executor slot**:
Expand Down
11 changes: 11 additions & 0 deletions docs/workflows/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@
同样在发布时拒绝的还有交互式节点的产出物声明 `agentConfig.completion`(任何非 null 值),以及 `agentConfig.schemaVersion` 不是 `3` 的值
(`4`、旧的 `1`/`2`、非数字):Cloud 只执行版本 `3`,缺省或 `null` 保持旧行为,其余非 `3` 的值都拒绝,拒绝时点名节点与字段,不静默剥离声明。

## 条件分支与命名输出

Condition 卡片为每个 case 和隐式 ELSE 显示独立端口,连线持久化对应 case id 的 `sourceHandle`。
不同分支可以汇入同一节点;同一对端点和端口的重复连线被拒绝。删除分支会删除该分支连线,撤销恢复二者。
旧图未标端口的 condition 连线继续表示 ELSE;新增或调整分支不会把既有连线按分支顺序重新编号。

Output 检查器可添加、命名和删除 `data.outputs` 绑定,从该节点可见的变量目录选择来源。
运行时在同一个已提交快照上解析所有绑定,结果按声明的名称写入 output 节点状态;没有绑定时保持空对象。
显式 null、false、0 和空字符串均为有效产物。任何绑定未赋值或路径无效都会使节点失败,不返回部分成功结果。
发布检查拒绝非法绑定结构、空名称、重名、无效选择器和未声明的变量根,并定位到节点和字段。

## 执行器槽位

Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执行器槽位**:
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/features/workflows/aggregator-translations.ts
Original file line number Diff line number Diff line change
@@ -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',
},
}
19 changes: 19 additions & 0 deletions frontend/src/features/workflows/editor/README.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Workflow editor

Owns the canvas, inspectors and editable draft. Runtime owns policy and variable catalogs;
workflow boundary adapters own HTTP.

| Files | Responsibility |
|---|---|
| `workflow-node-card.tsx`, `workflow-node-source-handles.tsx` | Cards and IF/ELIF/ELSE ports registered by case id |
| `use-workflow-draft.ts` | Sole draft writer, edge identity, removed-case cleanup, saves and history restoration |
| `workflow-inspector.tsx`, `workflow-*-fields.tsx` | Compose per-kind inspectors; Output edits named result bindings |
| `workflow-variable-select.tsx` | Display/select consumer-visible variables without rewriting persisted selectors |
| `workflow-canvas.tsx`, `iteration-*.tsx` | React Flow composition and iteration-region interactions |
| Other `workflow-*` files | Editor page, history, import, publication and launch actions |

Depends on runtime, UI components, i18n and workflow API adapters; workflow pages consume this module.
Case ids and connection ports persist together; different ports may join one target. Output retains
authored bindings and incomplete draft rows, leaving publication validation to reject invalid data.
Tests exercise real React Flow ports, controlled inspectors and draft save/reload behavior; network
tests replace the shared test boundary.
16 changes: 16 additions & 0 deletions frontend/src/features/workflows/editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 工作流编辑器

负责画布、检查器与草稿编辑;运行策略和变量目录由 runtime 提供,HTTP 由工作流边界适配器负责。

| 文件 | 职责 |
|---|---|
| `workflow-node-card.tsx`、`workflow-node-source-handles.tsx` | 卡片和按 case id 注册的 IF/ELIF/ELSE 端口 |
| `use-workflow-draft.ts` | 草稿唯一写入者、连线身份、分支删除清理、保存与历史恢复 |
| `workflow-inspector.tsx`、`workflow-*-fields.tsx` | 按节点类型组合检查器,Output 编辑命名结果绑定 |
| `workflow-variable-select.tsx` | 显示和选择消费者可见的变量,不改变持久化 selector |
| `workflow-canvas.tsx`、`iteration-*.tsx` | React Flow 装配与迭代区域交互 |
| 其余 `workflow-*` 文件 | 编辑页、历史及导入/发布/启动等编辑动作 |

依赖 runtime、UI 组件、i18n 与工作流 API 适配器;工作流页面消费本模块。分支 id 与连线端口共同持久化,
不同端口可以汇入同一节点。Output 保存原始命名绑定,未完成的行保留为草稿,发布校验负责拒绝。
测试通过真实 React Flow 端口、受控检查器及草稿保存重载验证行为,网络测试使用共享测试边界。
12 changes: 12 additions & 0 deletions frontend/src/features/workflows/editor/node-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
ArrowRight,
Boxes,
Braces,
CornerDownRight,
GitBranch,
GitMerge,
Layers,
Merge,
Play,
Repeat,
UserCheck,
Expand Down Expand Up @@ -38,6 +40,11 @@ const NODE_METADATA: Record<WorkflowNodeKind, WorkflowNodeMetadata> = {
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',
Expand All @@ -50,6 +57,11 @@ const NODE_METADATA: Record<WorkflowNodeKind, WorkflowNodeMetadata> = {
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,
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/features/workflows/editor/use-workflow-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,130 @@ function requireNode(nodes: WorkflowCanvasNode[], id: string): WorkflowCanvasNod
}

describe('useWorkflowDraft', () => {
it('persists distinct IF, ELIF and ELSE connections to one target across reloads', () => {
const condition: WorkflowCanvasNode = {
id: 'condition-1',
type: 'workflow',
position: { x: 0, y: 0 },
data: {
kind: 'condition',
title: '条件',
description: '',
cases: [
{ id: 'case-1', conditions: [] },
{ id: 'case-2', conditions: [] },
],
},
}
const { result } = setup(fixtureWithGraph([condition, outputNode()]))
for (const sourceHandle of ['case-1', 'case-2', 'else', 'case-1']) {
act(() =>
result.current.connect({
source: 'condition-1',
target: 'output-1',
sourceHandle,
targetHandle: null,
}),
)
}
const before = result.current.edges
expect(before.map((edge) => edge.sourceHandle)).toEqual(['case-1', 'case-2', 'else'])
expect(new Set(before.map((edge) => edge.id)).size).toBe(3)
const saved = workflowFixture({ graph: result.current.snapshot({ x: 0, y: 0, zoom: 1 }) })
const reloaded = setup(saved)
expect(reloaded.result.current.edges).toEqual(before)
const history = result.current.captureContent()
act(() =>
result.current.updateNodeData('condition-1', {
...condition.data,
cases: [{ id: 'case-1', conditions: [] }],
}),
)
expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['case-1', 'else'])
act(() => result.current.restoreContent(history))
expect(result.current.captureContent()).toEqual(history)
})

it.each([undefined, ''])(
'treats a legacy Condition port %s as ELSE when reconnecting',
(sourceHandle) => {
const condition: WorkflowCanvasNode = {
id: 'condition-1',
type: 'workflow',
position: { x: 0, y: 0 },
data: { kind: 'condition', title: '条件', description: '' },
}
const { result } = setup(
workflowFixture({
graph: {
nodes: [condition, outputNode()],
edges: [{ id: 'old', source: condition.id, target: 'output-1', sourceHandle }],
},
}),
)
act(() =>
result.current.connect({
source: condition.id,
target: 'output-1',
sourceHandle: 'else',
targetHandle: null,
}),
)
expect(result.current.edges).toHaveLength(1)
act(() =>
result.current.connect({
source: condition.id,
target: 'output-1',
sourceHandle: 'case-1',
targetHandle: null,
}),
)
expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['else', 'case-1'])
},
)

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 loopExit into the selected loop body and ignores an unknown loop', () => {
const { result } = setup(fixtureWithGraph([outputNode()]))

act(() => {
result.current.addNode('loop', { x: 0, y: 0 })
})
act(() => {
result.current.addLoopExit('loop-1')
})

const exit = result.current.nodes.find((node) => node.data.kind === 'loopExit')
expect(exit?.data.containerId).toBe('loop-1')
expect(exit?.position).toEqual({ x: 320, y: 0 })

act(() => {
result.current.addLoopExit('missing-loop')
})
expect(result.current.nodes.filter((node) => node.data.kind === 'loopExit')).toHaveLength(1)
})

it('adds a member through the entry seam, widening the frame and wiring the entry edge', () => {
const { result } = setup(draftFixture())

Expand Down
Loading
Loading