dsh-knj-workflow 2026.9.73 → 2026.9.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/lib/assist-edits.js +284 -0
- package/lib/assist-edits.test.js +283 -0
- package/lib/assist-prompt.js +130 -0
- package/lib/assist-prompt.test.js +109 -0
- package/lib/assist-route.test.js +568 -0
- package/lib/assist-rules.js +166 -0
- package/lib/assist-rules.test.js +197 -0
- package/lib/assist-script.js +52 -0
- package/lib/assist-script.test.js +71 -0
- package/lib/client.assist.test.js +244 -0
- package/lib/client.js +442 -7
- package/lib/index.js +509 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- **可视化进度**:右侧栏「开发任务」tab 横向步骤条展示每个阶段状态(待执行/运行中/完成/失败),实时刷新
|
|
13
13
|
- **阶段重跑 / 继续**:失败的阶段可点击「重跑」,暂停/失败的任务可点击「继续」——基于断点持久化(每阶段结果落盘)
|
|
14
14
|
- **斜杠命令**:`/dev-task new <标题>`、`/dev-task list`、`/dev-task status <id>`、`/dev-task wf`
|
|
15
|
+
- **AI 助手对话配置(新)**:在图编辑器里用自然语言改流程图——对话坞位于编辑器下半区(左=对话过程 / 右=指令输入),AI 返回「回复 + 编辑动作序列」,逐条校验后实时应用到画布草稿;整轮改动一次撤销,可查看推理/工具/正文流与 token 用量
|
|
15
16
|
- **定时调度集成(可选)**:为 `dsh-scheduler` 提供进程内 `knjWorkflowScheduler` 服务;定时触发时按最新保存的流程定义创建并启动任务,历史可直达该任务详情
|
|
16
17
|
- **UI 融入原生**:左侧栏底部「新建任务」入口 + 右侧栏两个 tab(better-sidebar 扩展点),全部使用 DSH 原生设计令牌(`--dsw-*`)
|
|
17
18
|
|
|
@@ -68,6 +69,20 @@ dsh plugin --profile web add dsh-knj-workflow
|
|
|
68
69
|
| POST | `/devtask/tasks/:id/start` `/cancel` `/resume` | 启动 / 取消 / 继续 |
|
|
69
70
|
| POST | `/devtask/tasks/:id/rerun-stage` | 重跑指定阶段 |
|
|
70
71
|
| GET | `/devtask/tasks/:id/stages/:stageId` | 阶段断点产物 |
|
|
72
|
+
| POST | `/devtask/assist` | AI 助手对话:`{ workflow, message, history? }` → `202 { requestId }`(异步启动) |
|
|
73
|
+
| GET | `/devtask/assist/progress?id=&since=` | 对话过程:`{ events[], stream{text,reasoning,tools,usage}, done, result? }` |
|
|
74
|
+
|
|
75
|
+
## AI 助手对话配置
|
|
76
|
+
|
|
77
|
+
在「流程设计」打开任一工作流 → 编辑器下半区出现对话坞,用自然语言描述改动即可(如
|
|
78
|
+
「在 coding 后加一个代码验证节点,输出 passed(布尔),通过走人工评审、失败回到 coding」)。
|
|
79
|
+
|
|
80
|
+
- **改动可控**:AI 返回结构化编辑动作(add_node / set_node / remove_edge / add_edge / set_routes …),
|
|
81
|
+
Host 逐条做语义预检 + 全图校验,非法动作被拒并说明原因(不会写出坏图);整轮改动 = 一次撤销步
|
|
82
|
+
- **遵守流程配置规范**:内置本插件的流程配置规范(图形态、节点选型、输出字段类型、网关条件、
|
|
83
|
+
人工审批去向、并行限制、引用语法、运行语义与反模式),规范与 `validateWorkflow` 由一致性测试绑定
|
|
84
|
+
- **过程可见**:对话坞实时显示助手推理、正文流、工具调用与 token 用量;可跳转查看助手完整会话
|
|
85
|
+
- **独立通道**:经 workflow 引擎派专用 subagent,不占用主对话上下文;「+ 新对话」可随时清空对话重来(画布草稿保留)
|
|
71
86
|
|
|
72
87
|
## 编排器参数
|
|
73
88
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-knj-workflow AI 助手编辑动作引擎(纯函数,零依赖)
|
|
3
|
+
* ---------------------------------------------------------------
|
|
4
|
+
* applyEdits(workflow, edits) —— 把 AI 助手返回的编辑动作序列可靠地应用
|
|
5
|
+
* 到工作流草稿上,作为「对话配置工作流」的安全地基:
|
|
6
|
+
*
|
|
7
|
+
* - 语义预检(动作级):删 start/end、引用不存在的节点/边、重复边、
|
|
8
|
+
* set_node 写 x/y/id、set_meta 改 workflow.id、human routes 目标无效、
|
|
9
|
+
* 未知动作类型 —— 单条 rejected(含原因),跳过该动作,其余合法动作继续;
|
|
10
|
+
* - 图结构终检(批级):全部动作应用后跑一次 validateWorkflow。
|
|
11
|
+
* ok → 返回 { workflow: 应用后, applied, rejected }
|
|
12
|
+
* !ok → 整批回滚:workflow = 原始(深拷贝), applied = [], rejected 追记图错误
|
|
13
|
+
*
|
|
14
|
+
* 数据模型约定(与 lib/graph.js / orchestrator.js 一致,勿偏离):
|
|
15
|
+
* workflow = { id, name, schemaVersion:2, inputs, nodes:[{id,type,title,...}], edges:[{from,to,when?,default?}] }
|
|
16
|
+
* 节点 type:start | end | task | gateway-xor | gateway-and | human
|
|
17
|
+
* human 节点去向:routes(label + to)优先,兼容 approveTo/rejectTo
|
|
18
|
+
*
|
|
19
|
+
* AI 不允许:写 x/y 坐标(布局归 Client)、整体替换 workflow、修改 workflow.id。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { validateWorkflow } from './graph.js';
|
|
23
|
+
|
|
24
|
+
const NODE_TYPES = new Set(['start', 'end', 'task', 'gateway-xor', 'gateway-and', 'human']);
|
|
25
|
+
const KNOWN_ACTIONS = new Set([
|
|
26
|
+
'add_node', 'set_node', 'remove_node',
|
|
27
|
+
'add_edge', 'set_edge', 'remove_edge',
|
|
28
|
+
'set_routes', 'set_meta',
|
|
29
|
+
]);
|
|
30
|
+
// set_node 允许写的节点字段白名单(AI 不可写布局坐标/内部类型/输入映射)
|
|
31
|
+
const NODE_PATCH_KEYS = new Set(['title', 'body', 'routes', 'approveTo', 'rejectTo', 'maxRuns', 'skill', 'description', 'displayFrom']);
|
|
32
|
+
// set_meta 允许写的顶层字段(id 是身份,绝不允许 AI 改)
|
|
33
|
+
const META_PATCH_KEYS = new Set(['name', 'description', 'inputs']);
|
|
34
|
+
|
|
35
|
+
/** 深拷贝辅助(JSON 往返即可,数据均为可序列化结构)。 */
|
|
36
|
+
function clone(x) { return JSON.parse(JSON.stringify(x)); }
|
|
37
|
+
|
|
38
|
+
/** 无 id 的 add_node:英文标题 → task-<slug>;否则 task-<随机 6 位>。 */
|
|
39
|
+
function autoNodeId(nodes, title, type) {
|
|
40
|
+
const slug = String(title || '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '');
|
|
44
|
+
if (slug) {
|
|
45
|
+
const base = `task-${slug}`;
|
|
46
|
+
if (!nodes.some((n) => n.id === base)) return base;
|
|
47
|
+
return `${base}-${Math.random().toString(36).slice(2, 8)}`;
|
|
48
|
+
}
|
|
49
|
+
const t = type === 'task' ? 'task' : type;
|
|
50
|
+
for (let i = 0; i < 20; i++) {
|
|
51
|
+
const id = `${t}-${Math.random().toString(36).slice(2, 8)}`;
|
|
52
|
+
if (!nodes.some((n) => n.id === id)) return id;
|
|
53
|
+
}
|
|
54
|
+
return `${t}-${Date.now().toString(36)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 默认 task 节点形状:与 orchestrator/graph 约定对齐(inputs 依赖显式声明)。 */
|
|
58
|
+
function blankNode(id, type, title, extra) {
|
|
59
|
+
const node = { id, type, title: title || '' };
|
|
60
|
+
if (type === 'task') {
|
|
61
|
+
node.inputs = [];
|
|
62
|
+
node.body = { prompt: '', mode: 'single', output: {} };
|
|
63
|
+
}
|
|
64
|
+
if (extra && typeof extra === 'object') {
|
|
65
|
+
for (const k of Object.keys(extra)) {
|
|
66
|
+
if (k === 'id' || k === 'type' || k === 'x' || k === 'y') continue; // AI 不许直接写
|
|
67
|
+
node[k] = clone(extra[k]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 动作语义预检(不含整图拓扑)。返回错误字符串;null 表示通过。 */
|
|
74
|
+
function precheck(workflow, action) {
|
|
75
|
+
if (!action || typeof action !== 'object') return '动作必须是对象';
|
|
76
|
+
const a = action.action;
|
|
77
|
+
if (!KNOWN_ACTIONS.has(a)) return `未知动作类型: ${a}`;
|
|
78
|
+
|
|
79
|
+
const { nodes, edges } = workflow;
|
|
80
|
+
const hasNode = (id) => nodes.some((n) => n.id === id);
|
|
81
|
+
const nodeType = (id) => (nodes.find((n) => n.id === id) || {}).type;
|
|
82
|
+
const edgeKey = (from, to) => `${from}\u0000${to}`;
|
|
83
|
+
const edgeSet = new Set(edges.map((e) => edgeKey(e.from, e.to)));
|
|
84
|
+
|
|
85
|
+
switch (a) {
|
|
86
|
+
case 'add_node': {
|
|
87
|
+
const type = action.type;
|
|
88
|
+
if (!NODE_TYPES.has(type)) return `add_node type 非法: ${type}`;
|
|
89
|
+
if (action.id !== undefined && action.id !== null && action.id !== '') {
|
|
90
|
+
if (typeof action.id !== 'string') return 'add_node id 必须是字符串';
|
|
91
|
+
if (hasNode(action.id)) return `节点 id 已存在: ${action.id}`;
|
|
92
|
+
}
|
|
93
|
+
// routes 目标有效性(human)
|
|
94
|
+
const routes = Array.isArray(action.routes) ? action.routes : [];
|
|
95
|
+
for (const r of routes) {
|
|
96
|
+
if (!r || !r.label) return `human 去向缺少标签`;
|
|
97
|
+
if (r.to && !hasNode(r.to) && r.to !== action.id) return `human 去向「${r.label}」目标不存在: ${r.to}`;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
case 'remove_node': {
|
|
102
|
+
const id = action.id;
|
|
103
|
+
if (!hasNode(id)) return `节点不存在: ${id}`;
|
|
104
|
+
const t = nodeType(id);
|
|
105
|
+
if (t === 'start' || t === 'end') return `节点 ${id} 是流程必需起止点(${t}),不能删除`;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
case 'set_node': {
|
|
109
|
+
const id = action.id;
|
|
110
|
+
if (!hasNode(id)) return `节点不存在: ${id}`;
|
|
111
|
+
const t = nodeType(id);
|
|
112
|
+
const bad = Object.keys(action.patch || {}).filter((k) => !NODE_PATCH_KEYS.has(k));
|
|
113
|
+
if (bad.length) return `set_node 不允许写字段: ${bad.join(', ')}(坐标/类型/id 由系统管理)`;
|
|
114
|
+
if (t !== 'human' && (action.patch?.routes || action.patch?.approveTo || action.patch?.rejectTo)) {
|
|
115
|
+
return `仅 human 节点可配置去向(routes/approveTo/rejectTo)`;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
case 'add_edge': {
|
|
120
|
+
const from = action.from, to = action.to;
|
|
121
|
+
if (!hasNode(from)) return `add_edge from 节点不存在: ${from}`;
|
|
122
|
+
if (!hasNode(to)) return `add_edge to 节点不存在: ${to}`;
|
|
123
|
+
if (edgeSet.has(edgeKey(from, to))) return `重复边(已存在连线): ${from} → ${to}`;
|
|
124
|
+
if (from === to) return `不允许自环边: ${from} → ${from}`;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
case 'remove_edge': {
|
|
128
|
+
const from = action.from, to = action.to;
|
|
129
|
+
if (!edgeSet.has(edgeKey(from, to))) return `边不存在: ${from} → ${to}`;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
case 'set_edge': {
|
|
133
|
+
const from = action.from, to = action.to;
|
|
134
|
+
if (!edgeSet.has(edgeKey(from, to))) return `边不存在: ${from} → ${to}`;
|
|
135
|
+
const bad = Object.keys(action.patch || {}).filter((k) => !['when', 'default'].includes(k));
|
|
136
|
+
if (bad.length) return `set_edge 不允许写字段: ${bad.join(', ')}(仅 when/default)`;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
case 'set_routes': {
|
|
140
|
+
const id = action.id;
|
|
141
|
+
if (!hasNode(id)) return `节点不存在: ${id}`;
|
|
142
|
+
if (nodeType(id) !== 'human') return `仅 human 节点可配置去向(当前 ${nodeType(id)})`;
|
|
143
|
+
const routes = Array.isArray(action.routes) ? action.routes : [];
|
|
144
|
+
if (routes.length === 0) return `human 节点 ${id} 至少需要一个去向`;
|
|
145
|
+
const labels = new Set();
|
|
146
|
+
for (const r of routes) {
|
|
147
|
+
if (!r || !r.label) return `human 去向缺少标签`;
|
|
148
|
+
if (labels.has(r.label)) return `human 去向标签重复: ${r.label}`;
|
|
149
|
+
labels.add(r.label);
|
|
150
|
+
if (!r.to || !hasNode(r.to)) return `human 去向「${r.label}」目标无效: ${r.to}`;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
case 'set_meta': {
|
|
155
|
+
const bad = Object.keys(action).filter((k) => k !== 'action' && !META_PATCH_KEYS.has(k));
|
|
156
|
+
if (bad.length) return `set_meta 不允许写字段: ${bad.join(', ')}(id 不可改)`;
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 应用单个动作到 wf(在 precheck 通过后调用;返回新 wf,不就地修改)。 */
|
|
165
|
+
function applyOne(wf, action) {
|
|
166
|
+
const out = clone(wf);
|
|
167
|
+
switch (action.action) {
|
|
168
|
+
case 'add_node': {
|
|
169
|
+
const id = action.id || autoNodeId(out.nodes, action.title, action.type);
|
|
170
|
+
const node = blankNode(id, action.type, action.title, action);
|
|
171
|
+
out.nodes.push(node);
|
|
172
|
+
return { wf: out, nodeId: id };
|
|
173
|
+
}
|
|
174
|
+
case 'remove_node': {
|
|
175
|
+
const id = action.id;
|
|
176
|
+
out.nodes = out.nodes.filter((n) => n.id !== id);
|
|
177
|
+
out.edges = out.edges.filter((e) => e.from !== id && e.to !== id);
|
|
178
|
+
return { wf: out };
|
|
179
|
+
}
|
|
180
|
+
case 'set_node': {
|
|
181
|
+
out.nodes = out.nodes.map((n) => {
|
|
182
|
+
if (n.id !== action.id) return n;
|
|
183
|
+
const patch = action.patch || {};
|
|
184
|
+
const next = { ...n };
|
|
185
|
+
for (const k of Object.keys(patch)) {
|
|
186
|
+
if (k === 'body' && n.body) next.body = { ...n.body, ...clone(patch.body) };
|
|
187
|
+
else next[k] = clone(patch[k]);
|
|
188
|
+
}
|
|
189
|
+
return next;
|
|
190
|
+
});
|
|
191
|
+
return { wf: out };
|
|
192
|
+
}
|
|
193
|
+
case 'add_edge': {
|
|
194
|
+
const e = { from: action.from, to: action.to };
|
|
195
|
+
if (action.when !== undefined) e.when = clone(action.when);
|
|
196
|
+
if (action.default !== undefined) e.default = !!action.default;
|
|
197
|
+
out.edges.push(e);
|
|
198
|
+
return { wf: out };
|
|
199
|
+
}
|
|
200
|
+
case 'remove_edge': {
|
|
201
|
+
out.edges = out.edges.filter((e) => !(e.from === action.from && e.to === action.to));
|
|
202
|
+
return { wf: out };
|
|
203
|
+
}
|
|
204
|
+
case 'set_edge': {
|
|
205
|
+
out.edges = out.edges.map((e) => {
|
|
206
|
+
if (!(e.from === action.from && e.to === action.to)) return e;
|
|
207
|
+
const patch = action.patch || {};
|
|
208
|
+
const next = { ...e };
|
|
209
|
+
for (const k of ['when', 'default']) {
|
|
210
|
+
if (k in patch) next[k] = k === 'default' ? !!patch[k] : clone(patch[k]);
|
|
211
|
+
}
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
return { wf: out };
|
|
215
|
+
}
|
|
216
|
+
case 'set_routes': {
|
|
217
|
+
out.nodes = out.nodes.map((n) => {
|
|
218
|
+
if (n.id !== action.id) return n;
|
|
219
|
+
const next = { ...n };
|
|
220
|
+
next.routes = clone(action.routes);
|
|
221
|
+
delete next.approveTo;
|
|
222
|
+
delete next.rejectTo;
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
return { wf: out };
|
|
226
|
+
}
|
|
227
|
+
case 'set_meta': {
|
|
228
|
+
for (const k of Object.keys(action)) {
|
|
229
|
+
if (k === 'action') continue;
|
|
230
|
+
out[k] = clone(action[k]);
|
|
231
|
+
}
|
|
232
|
+
return { wf: out };
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
return { wf: out };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 把编辑动作序列应用到工作流草稿。
|
|
241
|
+
* @param {object} workflow 现有工作流(不可变:无论成败都不修改入参)
|
|
242
|
+
* @param {Array} edits 编辑动作数组 [{ action, ... }]
|
|
243
|
+
* @returns {{ workflow, applied: Array, rejected: Array }}
|
|
244
|
+
*/
|
|
245
|
+
export function applyEdits(workflow, edits) {
|
|
246
|
+
if (!workflow || typeof workflow !== 'object') {
|
|
247
|
+
return { workflow, applied: [], rejected: [{ action: null, reason: 'workflow 必须是对象' }] };
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(edits)) {
|
|
250
|
+
return { workflow: clone(workflow), applied: [], rejected: [{ action: null, reason: 'edits 必须是数组' }] };
|
|
251
|
+
}
|
|
252
|
+
if (!Array.isArray(workflow.nodes) || !Array.isArray(workflow.edges)) {
|
|
253
|
+
return { workflow: clone(workflow), applied: [], rejected: [{ action: null, reason: 'workflow 必须是 { nodes, edges } 结构' }] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let current = clone(workflow);
|
|
257
|
+
const applied = [];
|
|
258
|
+
const rejected = [];
|
|
259
|
+
|
|
260
|
+
for (const action of edits) {
|
|
261
|
+
const err = precheck(current, action);
|
|
262
|
+
if (err) {
|
|
263
|
+
rejected.push({ action: clone(action || null), reason: err });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const { wf, nodeId } = applyOne(current, action);
|
|
268
|
+
current = wf;
|
|
269
|
+
applied.push({ action: action.action, nodeId, from: action.from, to: action.to, id: action.id });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// 单动作意外错误:按 rejected 处理,不污染图
|
|
272
|
+
rejected.push({ action: clone(action), reason: `应用动作失败: ${error instanceof Error ? error.message : String(error)}` });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 图结构终检:整批合法性。不通过 → 整批回滚,绝不放行坏图。
|
|
277
|
+
const v = validateWorkflow(current);
|
|
278
|
+
if (!v.ok) {
|
|
279
|
+
const reason = `图校验未通过: ${(v.errors || []).join(';')}`;
|
|
280
|
+
return { workflow: clone(workflow), applied: [], rejected: [...rejected, { action: null, reason }] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { workflow: current, applied, rejected };
|
|
284
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-knj-workflow AI 助手编辑动作引擎单元测试(node:test,零依赖)
|
|
3
|
+
* ---------------------------------------------------------------
|
|
4
|
+
* 运行:node --test lib/assist-edits.test.js
|
|
5
|
+
* 覆盖:applyEdits 逐条应用/语义预检/图校验回滚、动作集白名单、非法场景、
|
|
6
|
+
* 原 workflow 深度不可变。
|
|
7
|
+
*
|
|
8
|
+
* 引擎语义(与 design/assist-impl-phase1.spec.md 一致):
|
|
9
|
+
* - 动作级语义预检失败 → rejected(含原因),跳过该动作,其余合法动作继续;
|
|
10
|
+
* - 全部动作应用后跑一次 validateWorkflow:
|
|
11
|
+
* ok → 返回 { workflow: 应用后, applied, rejected }
|
|
12
|
+
* !ok → 整批回滚:workflow = 原始深拷贝, applied = [], rejected 追加图校验错误
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { applyEdits } from './assist-edits.js';
|
|
17
|
+
|
|
18
|
+
/** 串行基座:start → a(task) → end(合法图) */
|
|
19
|
+
function baseWorkflow(over = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: 'wf-base', name: '基座', schemaVersion: 2, revision: 1,
|
|
22
|
+
inputs: [],
|
|
23
|
+
nodes: [
|
|
24
|
+
{ id: 'start', type: 'start' },
|
|
25
|
+
{ id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '做 A', mode: 'single', output: {} } },
|
|
26
|
+
{ id: 'end', type: 'end' },
|
|
27
|
+
],
|
|
28
|
+
edges: [
|
|
29
|
+
{ from: 'start', to: 'a' },
|
|
30
|
+
{ from: 'a', to: 'end' },
|
|
31
|
+
],
|
|
32
|
+
...over,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// 动作集白名单与基本语义
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
test('applyEdits: add_node 合法(无 id 时按标题生成 task-<slug>,可被同批引用)', () => {
|
|
40
|
+
const wf = baseWorkflow();
|
|
41
|
+
const r = applyEdits(wf, [
|
|
42
|
+
{ action: 'remove_edge', from: 'a', to: 'end' }, // 先拆原边,a 才有空间接新分支
|
|
43
|
+
{ action: 'add_node', type: 'task', title: 'Verify', body: { prompt: '校验', mode: 'single', output: { passed: { type: 'boolean' } } } },
|
|
44
|
+
{ action: 'add_edge', from: 'a', to: 'task-verify' },
|
|
45
|
+
{ action: 'add_edge', from: 'task-verify', to: 'end' },
|
|
46
|
+
]);
|
|
47
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
48
|
+
assert.equal(r.applied.length, 4);
|
|
49
|
+
const node = r.workflow.nodes.find((n) => n.id === 'task-verify');
|
|
50
|
+
assert.ok(node && node.title === 'Verify' && node.type === 'task');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('applyEdits: add_node 中文标题 → 兜底随机 task-xxxx 前缀', () => {
|
|
54
|
+
const wf = baseWorkflow();
|
|
55
|
+
const r = applyEdits(wf, [
|
|
56
|
+
{ action: 'add_node', type: 'task', id: 'task-zh', title: '验证' },
|
|
57
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
58
|
+
{ action: 'add_edge', from: 'a', to: 'task-zh' },
|
|
59
|
+
{ action: 'add_edge', from: 'task-zh', to: 'end' },
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
62
|
+
// 兜底生成逻辑单独存在即可,这里验证显式 id 仍可用
|
|
63
|
+
const node = r.workflow.nodes.find((n) => n.id === 'task-zh');
|
|
64
|
+
assert.ok(node && node.title === '验证');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('applyEdits: 显式 id 生效且不重复生成', () => {
|
|
68
|
+
const wf = baseWorkflow();
|
|
69
|
+
const r = applyEdits(wf, [
|
|
70
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
71
|
+
{ action: 'add_node', type: 'task', id: 'verify', title: '验证' },
|
|
72
|
+
{ action: 'add_edge', from: 'a', to: 'verify' },
|
|
73
|
+
{ action: 'add_edge', from: 'verify', to: 'end' },
|
|
74
|
+
]);
|
|
75
|
+
assert.equal(r.rejected.length, 0);
|
|
76
|
+
assert.ok(r.workflow.nodes.some((n) => n.id === 'verify'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('applyEdits: 未知动作类型被拒且不影响其它合法动作', () => {
|
|
80
|
+
const wf = baseWorkflow();
|
|
81
|
+
const r = applyEdits(wf, [
|
|
82
|
+
{ action: 'explode_everything' },
|
|
83
|
+
{ action: 'set_meta', name: '改名' },
|
|
84
|
+
]);
|
|
85
|
+
assert.equal(r.rejected.length, 1);
|
|
86
|
+
assert.match(r.rejected[0].reason, /未知动作|explode_everything/);
|
|
87
|
+
assert.ok(r.applied.some((x) => x.action === 'set_meta'));
|
|
88
|
+
assert.equal(r.workflow.name, '改名');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('applyEdits: 空 edits / 非数组 edits 不崩溃', () => {
|
|
92
|
+
assert.equal(applyEdits(baseWorkflow(), []).applied.length, 0);
|
|
93
|
+
const r = applyEdits(baseWorkflow(), null);
|
|
94
|
+
assert.ok(Array.isArray(r.rejected) && r.rejected.length >= 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// 动作级非法场景(逐条拒绝,不污染,不影响后续)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
test('applyEdits: 删除 start / end 被拒', () => {
|
|
101
|
+
const wf = baseWorkflow();
|
|
102
|
+
const r = applyEdits(wf, [
|
|
103
|
+
{ action: 'remove_node', id: 'start' },
|
|
104
|
+
{ action: 'remove_node', id: 'end' },
|
|
105
|
+
]);
|
|
106
|
+
assert.equal(r.rejected.length, 2);
|
|
107
|
+
assert.ok(r.workflow.nodes.some((n) => n.id === 'start'));
|
|
108
|
+
assert.ok(r.workflow.nodes.some((n) => n.id === 'end'));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('applyEdits: remove_node / remove_edge 引用不存在的节点/边被拒', () => {
|
|
112
|
+
const wf = baseWorkflow();
|
|
113
|
+
const r = applyEdits(wf, [
|
|
114
|
+
{ action: 'remove_node', id: 'ghost' },
|
|
115
|
+
{ action: 'remove_edge', from: 'a', to: 'ghost' },
|
|
116
|
+
]);
|
|
117
|
+
assert.equal(r.rejected.length, 2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('applyEdits: 重复边被拒(同一 from→to 已有)', () => {
|
|
121
|
+
const wf = baseWorkflow();
|
|
122
|
+
const r = applyEdits(wf, [
|
|
123
|
+
{ action: 'add_edge', from: 'start', to: 'a' },
|
|
124
|
+
]);
|
|
125
|
+
assert.equal(r.rejected.length, 1);
|
|
126
|
+
assert.match(r.rejected[0].reason, /重复|已存在/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('applyEdits: add_edge 指向不存在的节点被拒', () => {
|
|
130
|
+
const wf = baseWorkflow();
|
|
131
|
+
const r = applyEdits(wf, [{ action: 'add_edge', from: 'a', to: 'nope' }]);
|
|
132
|
+
assert.equal(r.rejected.length, 1);
|
|
133
|
+
assert.match(r.rejected[0].reason, /不存在/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('applyEdits: set_node 不允许写 x/y 坐标与 id', () => {
|
|
137
|
+
const wf = baseWorkflow();
|
|
138
|
+
const r = applyEdits(wf, [
|
|
139
|
+
{ action: 'set_node', id: 'a', patch: { x: 999, y: 999, title: '改标题' } },
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(r.rejected.length, 1);
|
|
142
|
+
assert.match(r.rejected[0].reason, /x|y|id/);
|
|
143
|
+
const a = r.workflow.nodes.find((n) => n.id === 'a');
|
|
144
|
+
assert.equal(a.x, undefined);
|
|
145
|
+
assert.equal(a.title, 'A');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('applyEdits: set_meta 不允许改 workflow id', () => {
|
|
149
|
+
const wf = baseWorkflow();
|
|
150
|
+
const r = applyEdits(wf, [{ action: 'set_meta', id: 'evil-id', name: 'x' }]);
|
|
151
|
+
assert.equal(r.rejected.length, 1);
|
|
152
|
+
assert.equal(r.workflow.id, 'wf-base');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('applyEdits: human 节点 routes 指向不存在节点被拒', () => {
|
|
156
|
+
const wf = baseWorkflow();
|
|
157
|
+
const r = applyEdits(wf, [
|
|
158
|
+
{ action: 'add_node', type: 'human', id: 'h1', title: '审批', routes: [{ label: '通过', to: 'nope' }] },
|
|
159
|
+
]);
|
|
160
|
+
assert.equal(r.rejected.length, 1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('applyEdits: set_node 引用不存在节点被拒', () => {
|
|
164
|
+
const wf = baseWorkflow();
|
|
165
|
+
const r = applyEdits(wf, [{ action: 'set_node', id: 'ghost', patch: { title: 'x' } }]);
|
|
166
|
+
assert.equal(r.rejected.length, 1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// 终检回滚场景(图校验失败 → 整批回滚,原图不被污染)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
test('applyEdits: 孤立节点导致终检失败 → 整批回滚', () => {
|
|
173
|
+
const wf = baseWorkflow();
|
|
174
|
+
const r = applyEdits(wf, [{ action: 'add_node', type: 'task', id: 'orphan', title: '孤儿' }]);
|
|
175
|
+
assert.equal(r.applied.length, 0);
|
|
176
|
+
assert.ok(r.rejected.length >= 1);
|
|
177
|
+
assert.match(r.rejected.map((x) => x.reason).join(' '), /孤立|入边|校验/);
|
|
178
|
+
assert.deepEqual(r.workflow, wf, '整批回滚后原 workflow 不被污染');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('applyEdits: XOR 出边不足/缺 default 导致终检失败 → 回滚', () => {
|
|
182
|
+
const wf = baseWorkflow();
|
|
183
|
+
// 把 a 后改接 XOR,但 XOR 只给 1 条出边 → 非法
|
|
184
|
+
const r = applyEdits(wf, [
|
|
185
|
+
{ action: 'add_node', type: 'gateway-xor', id: 'gw', title: '分支' },
|
|
186
|
+
{ action: 'add_node', type: 'task', id: 'd1', title: 'D1' },
|
|
187
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
188
|
+
{ action: 'add_edge', from: 'a', to: 'gw' },
|
|
189
|
+
{ action: 'add_edge', from: 'gw', to: 'd1' }, // 仅 1 条出边且非 default → XOR 出边<2
|
|
190
|
+
{ action: 'add_edge', from: 'd1', to: 'end' },
|
|
191
|
+
]);
|
|
192
|
+
assert.equal(r.applied.length, 0, JSON.stringify(r.rejected));
|
|
193
|
+
assert.deepEqual(r.workflow, wf);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('applyEdits: 完整 XOR 分流合法批量 → 全部 applied 无 rejected', () => {
|
|
197
|
+
const wf = baseWorkflow();
|
|
198
|
+
const r = applyEdits(wf, [
|
|
199
|
+
{ action: 'add_node', type: 'gateway-xor', id: 'gw', title: '复杂度' },
|
|
200
|
+
{ action: 'add_node', type: 'task', id: 'd1', title: '设计', body: { prompt: '设计', mode: 'single', output: {} } },
|
|
201
|
+
{ action: 'add_node', type: 'task', id: 'd2', title: '直接编码', body: { prompt: '编码', mode: 'single', output: {} } },
|
|
202
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
203
|
+
{ action: 'add_edge', from: 'a', to: 'gw' },
|
|
204
|
+
{ action: 'add_edge', from: 'gw', to: 'd1', when: { field: 'level', op: 'eq', value: 'high' } },
|
|
205
|
+
{ action: 'add_edge', from: 'gw', to: 'd2', default: true },
|
|
206
|
+
{ action: 'add_edge', from: 'd1', to: 'end' },
|
|
207
|
+
{ action: 'add_edge', from: 'd2', to: 'end' },
|
|
208
|
+
]);
|
|
209
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
210
|
+
assert.equal(r.applied.length, 9);
|
|
211
|
+
assert.ok(r.workflow.nodes.some((n) => n.id === 'gw'));
|
|
212
|
+
// 终检通过 → workflow 是应用后的(非回滚)
|
|
213
|
+
assert.notDeepEqual(r.workflow, wf);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('applyEdits: AND 并行 split+join 合法(gateway-and 双向)', () => {
|
|
217
|
+
const wf = baseWorkflow();
|
|
218
|
+
const r = applyEdits(wf, [
|
|
219
|
+
{ action: 'add_node', type: 'gateway-and', id: 'split', title: '并行' },
|
|
220
|
+
{ action: 'add_node', type: 'gateway-and', id: 'join', title: '汇合' },
|
|
221
|
+
{ action: 'add_node', type: 'task', id: 'b1', title: 'B1', body: { prompt: '', mode: 'single', output: {} } },
|
|
222
|
+
{ action: 'add_node', type: 'task', id: 'b2', title: 'B2', body: { prompt: '', mode: 'single', output: {} } },
|
|
223
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
224
|
+
{ action: 'add_edge', from: 'a', to: 'split' },
|
|
225
|
+
{ action: 'add_edge', from: 'split', to: 'b1' },
|
|
226
|
+
{ action: 'add_edge', from: 'split', to: 'b2' },
|
|
227
|
+
{ action: 'add_edge', from: 'b1', to: 'join' },
|
|
228
|
+
{ action: 'add_edge', from: 'b2', to: 'join' },
|
|
229
|
+
{ action: 'add_edge', from: 'join', to: 'end' },
|
|
230
|
+
]);
|
|
231
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('applyEdits: human 节点 + routes 通过/驳回 合法', () => {
|
|
235
|
+
const wf = baseWorkflow();
|
|
236
|
+
const r = applyEdits(wf, [
|
|
237
|
+
{ action: 'add_node', type: 'human', id: 'h1', title: '评审', routes: [{ label: '通过', to: 'end' }] },
|
|
238
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
239
|
+
{ action: 'add_edge', from: 'a', to: 'h1' },
|
|
240
|
+
]);
|
|
241
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('applyEdits: 环线(驳回重做)合法:a → h1 → a', () => {
|
|
245
|
+
const wf = baseWorkflow();
|
|
246
|
+
const r = applyEdits(wf, [
|
|
247
|
+
{ action: 'add_node', type: 'human', id: 'h1', title: '审批', routes: [{ label: '通过', to: 'end' }, { label: '驳回', to: 'a' }] },
|
|
248
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
249
|
+
{ action: 'add_edge', from: 'a', to: 'h1' },
|
|
250
|
+
]);
|
|
251
|
+
assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected));
|
|
252
|
+
// human 去向经 routes 表达:通过 → end(虚拟边)
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('applyEdits: set_node 修改 prompt/输出字段合法且保留其余字段', () => {
|
|
256
|
+
const wf = baseWorkflow();
|
|
257
|
+
const r = applyEdits(wf, [
|
|
258
|
+
{ action: 'set_node', id: 'a', patch: { title: 'A 加强', body: { prompt: '更严格地做 A', mode: 'single', output: { passed: { type: 'boolean' } } } } },
|
|
259
|
+
]);
|
|
260
|
+
assert.equal(r.rejected.length, 0);
|
|
261
|
+
const a = r.workflow.nodes.find((n) => n.id === 'a');
|
|
262
|
+
assert.equal(a.title, 'A 加强');
|
|
263
|
+
assert.equal(a.body.prompt, '更严格地做 A');
|
|
264
|
+
assert.deepEqual(a.body.output, { passed: { type: 'boolean' } });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// 深度不可变:任何路径都不修改传入对象
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
test('applyEdits: 失败与成功路径均不改传入 workflow(深度不可变)', () => {
|
|
271
|
+
const wf = baseWorkflow();
|
|
272
|
+
const before = JSON.stringify(wf);
|
|
273
|
+
// 失败路径(终检回滚)
|
|
274
|
+
applyEdits(wf, [{ action: 'add_node', type: 'task', id: 'orphan2', title: '孤' }]);
|
|
275
|
+
// 成功路径
|
|
276
|
+
applyEdits(wf, [
|
|
277
|
+
{ action: 'remove_edge', from: 'a', to: 'end' },
|
|
278
|
+
{ action: 'add_node', type: 'task', id: 'v2', title: '验' },
|
|
279
|
+
{ action: 'add_edge', from: 'a', to: 'v2' },
|
|
280
|
+
{ action: 'add_edge', from: 'v2', to: 'end' },
|
|
281
|
+
]);
|
|
282
|
+
assert.equal(JSON.stringify(wf), before);
|
|
283
|
+
});
|