dsh-knj-workflow 0.1.94
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 +77 -0
- package/cordis.patch.yml +18 -0
- package/lib/client.js +2058 -0
- package/lib/graph.js +197 -0
- package/lib/graph.test.js +274 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +1001 -0
- package/lib/orchestrator.js +322 -0
- package/lib/orchestrator.test.js +682 -0
- package/package.json +60 -0
package/lib/graph.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-knj-workflow 图工作流纯函数(零依赖,Host 端与编排器脚本共用语义)
|
|
3
|
+
* ---------------------------------------------------------------
|
|
4
|
+
* validateWorkflow - 图结构校验(start/end、孤立节点、边、网关拓扑)
|
|
5
|
+
* nextNode - 路由:给定当前节点 + 上下文 → 下一步节点 id
|
|
6
|
+
* resolveInputs - 数据流:解析节点显式参数映射(inputs)
|
|
7
|
+
*
|
|
8
|
+
* 数据模型约定(见 WORKFLOW-DESIGN.md 第 6 节):
|
|
9
|
+
* - 节点 type:start | end | task | gateway-xor | gateway-and | human
|
|
10
|
+
* - XOR 网关单入边多出边,when.field 相对唯一上游;至少一条 default 出边
|
|
11
|
+
* - AND 网关 split(多出单入)或 join(多入单出),不混合
|
|
12
|
+
* - human 节点路由由 routes(label + to 的多去向)决定,兼容旧 approveTo / rejectTo(不在 edges 上写 when)
|
|
13
|
+
* - ctx 形状:{ <nodeId>: { <field>: value } }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const NODE_TYPES = new Set(['start', 'end', 'task', 'gateway-xor', 'gateway-and', 'human']);
|
|
17
|
+
|
|
18
|
+
/** 人工节点去向:routes 优先(多去向:label + to),兼容旧 approveTo/rejectTo(推导为 通过/驳回 两条)。 */
|
|
19
|
+
function humanRoutes(node) {
|
|
20
|
+
if (Array.isArray(node.routes) && node.routes.length > 0) return node.routes;
|
|
21
|
+
const r = [];
|
|
22
|
+
if (node.approveTo) r.push({ label: '通过', to: node.approveTo });
|
|
23
|
+
if (node.rejectTo) r.push({ label: '驳回', to: node.rejectTo });
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 图结构校验。返回 { ok, errors[] }。
|
|
29
|
+
*/
|
|
30
|
+
export function validateWorkflow(workflow) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!workflow || typeof workflow !== 'object') {
|
|
33
|
+
return { ok: false, errors: ['workflow 必须是对象'] };
|
|
34
|
+
}
|
|
35
|
+
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
|
36
|
+
const edges = Array.isArray(workflow.edges) ? workflow.edges : [];
|
|
37
|
+
|
|
38
|
+
if (nodes.length === 0) errors.push('nodes 不能为空');
|
|
39
|
+
|
|
40
|
+
const ids = new Set();
|
|
41
|
+
const byId = new Map();
|
|
42
|
+
for (const n of nodes) {
|
|
43
|
+
if (!n || !n.id) { errors.push('存在缺少 id 的节点'); continue; }
|
|
44
|
+
if (ids.has(n.id)) errors.push(`节点 id 重复: ${n.id}`);
|
|
45
|
+
ids.add(n.id);
|
|
46
|
+
byId.set(n.id, n);
|
|
47
|
+
if (!NODE_TYPES.has(n.type)) errors.push(`节点 ${n.id} 的 type 非法: ${n.type}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// start / end 唯一
|
|
51
|
+
const starts = nodes.filter((n) => n.type === 'start');
|
|
52
|
+
const ends = nodes.filter((n) => n.type === 'end');
|
|
53
|
+
if (starts.length !== 1) errors.push(`必须有且仅有一个 start(当前 ${starts.length} 个)`);
|
|
54
|
+
if (ends.length !== 1) errors.push(`必须有且仅有一个 end(当前 ${ends.length} 个)`);
|
|
55
|
+
|
|
56
|
+
// 度数统计(用于孤立节点 / 网关拓扑)
|
|
57
|
+
const inDegree = new Map();
|
|
58
|
+
const outDegree = new Map();
|
|
59
|
+
for (const id of ids) { inDegree.set(id, 0); outDegree.set(id, 0); }
|
|
60
|
+
|
|
61
|
+
for (const e of edges) {
|
|
62
|
+
if (!e || !e.from || !e.to) { errors.push('存在缺少 from/to 的边'); continue; }
|
|
63
|
+
if (!byId.has(e.from)) errors.push(`边 from 指向不存在的节点: ${e.from}`);
|
|
64
|
+
if (!byId.has(e.to)) errors.push(`边 to 指向不存在的节点: ${e.to}`);
|
|
65
|
+
if (byId.has(e.from)) outDegree.set(e.from, (outDegree.get(e.from) || 0) + 1);
|
|
66
|
+
if (byId.has(e.to)) inDegree.set(e.to, (inDegree.get(e.to) || 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 人工节点的去向目标:其"入边"来自人工决策、不写在 edges 里,豁免孤立检查
|
|
70
|
+
const humanTargets = new Set();
|
|
71
|
+
for (const n of nodes) {
|
|
72
|
+
if (n.type !== 'human') continue;
|
|
73
|
+
const routes = humanRoutes(n);
|
|
74
|
+
for (const r of routes) if (r.to) humanTargets.add(r.to);
|
|
75
|
+
}
|
|
76
|
+
// 孤立节点:除 start 外无入边(人工决策目标豁免)
|
|
77
|
+
for (const n of nodes) {
|
|
78
|
+
if (n.type === 'start') continue; // start 无入边正常
|
|
79
|
+
if ((inDegree.get(n.id) || 0) === 0 && !humanTargets.has(n.id)) errors.push(`节点 ${n.id} 无入边(孤立)`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 网关拓扑 / 人工节点去向
|
|
83
|
+
for (const n of nodes) {
|
|
84
|
+
if (n.type === 'gateway-xor') {
|
|
85
|
+
const out = edges.filter((e) => e.from === n.id);
|
|
86
|
+
if (out.length < 2) { errors.push(`排他网关 ${n.id} 出边数不足(需 ≥2)`); continue; }
|
|
87
|
+
if (!out.some((e) => e.default)) errors.push(`排他网关 ${n.id} 缺少 default 出边`);
|
|
88
|
+
}
|
|
89
|
+
if (n.type === 'gateway-and') {
|
|
90
|
+
const inN = inDegree.get(n.id) || 0;
|
|
91
|
+
const outN = outDegree.get(n.id) || 0;
|
|
92
|
+
const isSplit = outN > 1 && inN <= 1;
|
|
93
|
+
const isJoin = inN > 1 && outN <= 1;
|
|
94
|
+
if (!isSplit && !isJoin) {
|
|
95
|
+
errors.push(`并行网关 ${n.id} 拓扑非法:必须 split(多出单入)或 join(多入单出)`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (n.type === 'human') {
|
|
99
|
+
const routes = humanRoutes(n);
|
|
100
|
+
if (routes.length === 0) errors.push(`人工节点 ${n.id} 未配置任何去向(routes 或 approveTo/rejectTo)`);
|
|
101
|
+
const labels = new Set();
|
|
102
|
+
routes.forEach((r, i) => {
|
|
103
|
+
if (!r.label) errors.push(`人工节点 ${n.id} 的第 ${i + 1} 个去向缺少标签`);
|
|
104
|
+
else if (labels.has(r.label)) errors.push(`人工节点 ${n.id} 去向标签重复: ${r.label}`);
|
|
105
|
+
labels.add(r.label);
|
|
106
|
+
if (!r.to || !byId.has(r.to)) errors.push(`人工节点 ${n.id} 去向「${r.label || (i + 1)}」目标无效: ${r.to}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { ok: errors.length === 0, errors };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 评估结构化条件 when { field, op: eq|neq|in, value },作用于上游输出。 */
|
|
115
|
+
function matchCondition(upstream, when) {
|
|
116
|
+
if (!when || !when.field) return false;
|
|
117
|
+
const value = upstream[when.field];
|
|
118
|
+
switch (when.op) {
|
|
119
|
+
case 'eq': return value === when.value;
|
|
120
|
+
case 'neq': return value !== when.value;
|
|
121
|
+
case 'in': return Array.isArray(when.value) && when.value.includes(value);
|
|
122
|
+
default: return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 路由:给定当前节点 id + 上下文,返回下一个节点 id。
|
|
128
|
+
* - 普通节点(task/start):唯一出边
|
|
129
|
+
* - XOR 网关:按 when 条件命中分支,否则 default;无 default 抛错
|
|
130
|
+
* - human 节点:返回 null(表示暂停,等待人工决策 approveTo/rejectTo)
|
|
131
|
+
*/
|
|
132
|
+
export function nextNode(workflow, nodeId, ctx = {}) {
|
|
133
|
+
const nodes = workflow.nodes || [];
|
|
134
|
+
const edges = workflow.edges || [];
|
|
135
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
136
|
+
const node = byId.get(nodeId);
|
|
137
|
+
if (!node) throw new Error(`节点不存在: ${nodeId}`);
|
|
138
|
+
|
|
139
|
+
const outEdges = edges.filter((e) => e.from === nodeId);
|
|
140
|
+
|
|
141
|
+
if (node.type === 'gateway-xor') {
|
|
142
|
+
const inEdge = edges.find((e) => e.to === nodeId);
|
|
143
|
+
const upstreamId = inEdge?.from;
|
|
144
|
+
const upstream = upstreamId ? (ctx[upstreamId] || {}) : {};
|
|
145
|
+
for (const e of outEdges) {
|
|
146
|
+
if (e.default) continue; // default 最后处理
|
|
147
|
+
if (e.when && matchCondition(upstream, e.when)) return e.to;
|
|
148
|
+
}
|
|
149
|
+
const def = outEdges.find((e) => e.default);
|
|
150
|
+
if (def) return def.to;
|
|
151
|
+
throw new Error(`排他网关 ${nodeId} 无匹配条件且无 default 边`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (node.type === 'human') {
|
|
155
|
+
return null; // 暂停等待人工决策
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (outEdges.length !== 1) {
|
|
159
|
+
throw new Error(`节点 ${nodeId} 出边数应为 1(当前 ${outEdges.length})`);
|
|
160
|
+
}
|
|
161
|
+
return outEdges[0].to;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 数据流:解析节点显式参数映射(inputs: [{from, field|"*"}])。
|
|
166
|
+
* 返回 { <from>: { <field>: value } };字段/上游缺失抛错。
|
|
167
|
+
*/
|
|
168
|
+
export function resolveInputs(ctx, node) {
|
|
169
|
+
const inputs = Array.isArray(node.inputs) ? node.inputs : [];
|
|
170
|
+
if (inputs.length === 0) return {};
|
|
171
|
+
const result = {};
|
|
172
|
+
for (const ref of inputs) {
|
|
173
|
+
const src = ctx[ref.from];
|
|
174
|
+
if (src === undefined) throw new Error(`上游节点 ${ref.from} 无输出`);
|
|
175
|
+
if (ref.field === '*') {
|
|
176
|
+
result[ref.from] = src;
|
|
177
|
+
} else {
|
|
178
|
+
if (!(ref.field in src)) throw new Error(`上游 ${ref.from} 缺少字段 ${ref.field}`);
|
|
179
|
+
result[ref.from] = result[ref.from] || {};
|
|
180
|
+
result[ref.from][ref.field] = src[ref.field];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 保存前准备:校验 + 写 schemaVersion + revision 递增。
|
|
188
|
+
* 返回 { ok, errors? , workflow? }(纯函数,供 DevTaskStore.saveWorkflow 调用)。
|
|
189
|
+
*/
|
|
190
|
+
export function prepareWorkflowForSave(workflow, existing) {
|
|
191
|
+
if (!workflow || !workflow.id) return { ok: false, errors: ['workflow.id required'] };
|
|
192
|
+
const v = validateWorkflow(workflow);
|
|
193
|
+
if (!v.ok) return { ok: false, errors: v.errors };
|
|
194
|
+
workflow.schemaVersion = 2;
|
|
195
|
+
workflow.revision = (existing?.revision || 0) + 1;
|
|
196
|
+
return { ok: true, workflow };
|
|
197
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-knj-workflow 图工作流纯函数单元测试(node:test,零依赖)
|
|
3
|
+
* 覆盖:图校验 validateWorkflow、网关路由 nextNode、数据流 resolveInputs
|
|
4
|
+
* 运行:node --test lib/graph.test.js
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { validateWorkflow, nextNode, resolveInputs, prepareWorkflowForSave } from './graph.js';
|
|
9
|
+
|
|
10
|
+
// 辅助:构造串行图
|
|
11
|
+
function serialWorkflow(over = {}) {
|
|
12
|
+
return {
|
|
13
|
+
id: 'wf-serial', name: '串行', schemaVersion: 2, revision: 1,
|
|
14
|
+
inputs: [],
|
|
15
|
+
nodes: [
|
|
16
|
+
{ id: 'start', type: 'start' },
|
|
17
|
+
{ id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: 'A', mode: 'single', output: {} } },
|
|
18
|
+
{ id: 'end', type: 'end' },
|
|
19
|
+
],
|
|
20
|
+
edges: [
|
|
21
|
+
{ from: 'start', to: 'a' },
|
|
22
|
+
{ from: 'a', to: 'end' },
|
|
23
|
+
],
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 辅助:构造带 XOR 网关的图
|
|
29
|
+
function xorWorkflow() {
|
|
30
|
+
return {
|
|
31
|
+
id: 'wf-xor', name: '分叉', schemaVersion: 2, revision: 1,
|
|
32
|
+
inputs: [],
|
|
33
|
+
nodes: [
|
|
34
|
+
{ id: 'start', type: 'start' },
|
|
35
|
+
{ id: 'analyze', type: 'task', title: '分析', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
36
|
+
{ id: 'gw', type: 'gateway-xor', title: '复杂度' },
|
|
37
|
+
{ id: 'design', type: 'task', title: '设计', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
38
|
+
{ id: 'implement', type: 'task', title: '编码', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
39
|
+
{ id: 'end', type: 'end' },
|
|
40
|
+
],
|
|
41
|
+
edges: [
|
|
42
|
+
{ from: 'start', to: 'analyze' },
|
|
43
|
+
{ from: 'analyze', to: 'gw' },
|
|
44
|
+
{ from: 'gw', to: 'design', when: { field: 'complexity', op: 'eq', value: 'high' } },
|
|
45
|
+
{ from: 'gw', to: 'implement', when: { field: 'complexity', op: 'eq', value: 'low' } },
|
|
46
|
+
{ from: 'gw', to: 'design', default: true },
|
|
47
|
+
{ from: 'design', to: 'end' },
|
|
48
|
+
{ from: 'implement', to: 'end' },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// validateWorkflow
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
test('validateWorkflow: 合法串行图通过', () => {
|
|
57
|
+
const r = validateWorkflow(serialWorkflow());
|
|
58
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
59
|
+
assert.deepEqual(r.errors, []);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('validateWorkflow: 缺 start 报错', () => {
|
|
63
|
+
const wf = serialWorkflow();
|
|
64
|
+
wf.nodes = wf.nodes.filter((n) => n.type !== 'start');
|
|
65
|
+
const r = validateWorkflow(wf);
|
|
66
|
+
assert.equal(r.ok, false);
|
|
67
|
+
assert.ok(r.errors.some((e) => /start/.test(e)), JSON.stringify(r.errors));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('validateWorkflow: 缺 end 报错', () => {
|
|
71
|
+
const wf = serialWorkflow();
|
|
72
|
+
wf.nodes = wf.nodes.filter((n) => n.type !== 'end');
|
|
73
|
+
const r = validateWorkflow(wf);
|
|
74
|
+
assert.equal(r.ok, false);
|
|
75
|
+
assert.ok(r.errors.some((e) => /end/.test(e)));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('validateWorkflow: 两个 start 报错', () => {
|
|
79
|
+
const wf = serialWorkflow();
|
|
80
|
+
wf.nodes.push({ id: 'start2', type: 'start' });
|
|
81
|
+
const r = validateWorkflow(wf);
|
|
82
|
+
assert.equal(r.ok, false);
|
|
83
|
+
assert.ok(r.errors.some((e) => /start/.test(e)));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('validateWorkflow: 节点 id 重复报错', () => {
|
|
87
|
+
const wf = serialWorkflow();
|
|
88
|
+
wf.nodes.push({ id: 'a', type: 'task', title: 'A2', inputs: [], body: { prompt: '', mode: 'single', output: {} } });
|
|
89
|
+
const r = validateWorkflow(wf);
|
|
90
|
+
assert.equal(r.ok, false);
|
|
91
|
+
assert.ok(r.errors.some((e) => /重复|duplicate|unique/.test(e)), JSON.stringify(r.errors));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('validateWorkflow: 边指向不存在节点报错', () => {
|
|
95
|
+
const wf = serialWorkflow();
|
|
96
|
+
wf.edges.push({ from: 'a', to: 'ghost' });
|
|
97
|
+
const r = validateWorkflow(wf);
|
|
98
|
+
assert.equal(r.ok, false);
|
|
99
|
+
assert.ok(r.errors.some((e) => /ghost/.test(e)));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('validateWorkflow: 孤立节点(非 start/end 无入边)报错', () => {
|
|
103
|
+
const wf = serialWorkflow();
|
|
104
|
+
wf.nodes.push({ id: 'orphan', type: 'task', title: '孤儿', inputs: [], body: { prompt: '', mode: 'single', output: {} } });
|
|
105
|
+
const r = validateWorkflow(wf);
|
|
106
|
+
assert.equal(r.ok, false);
|
|
107
|
+
assert.ok(r.errors.some((e) => /orphan/.test(e)));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('validateWorkflow: 非法节点 type 报错', () => {
|
|
111
|
+
const wf = serialWorkflow();
|
|
112
|
+
wf.nodes.push({ id: 'x', type: 'bogus', title: 'X' });
|
|
113
|
+
const r = validateWorkflow(wf);
|
|
114
|
+
assert.equal(r.ok, false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('validateWorkflow: XOR 网关无 default 边报错', () => {
|
|
118
|
+
const wf = xorWorkflow();
|
|
119
|
+
wf.edges = wf.edges.filter((e) => !(e.from === 'gw' && e.default));
|
|
120
|
+
const r = validateWorkflow(wf);
|
|
121
|
+
assert.equal(r.ok, false);
|
|
122
|
+
assert.ok(r.errors.some((e) => /default/.test(e)), JSON.stringify(r.errors));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('validateWorkflow: XOR 网关有 default 边通过', () => {
|
|
126
|
+
const r = validateWorkflow(xorWorkflow());
|
|
127
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// nextNode(路由)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
test('nextNode: 串行普通节点走唯一出边', () => {
|
|
134
|
+
const wf = serialWorkflow();
|
|
135
|
+
assert.equal(nextNode(wf, 'start', {}), 'a');
|
|
136
|
+
assert.equal(nextNode(wf, 'a', {}), 'end');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('nextNode: XOR 网关命中条件走对应分支', () => {
|
|
140
|
+
const wf = xorWorkflow();
|
|
141
|
+
// ctx 存上游节点输出;XOR 网关的 when.field 相对唯一上游(analyze)
|
|
142
|
+
const ctx = { analyze: { complexity: 'high' } };
|
|
143
|
+
assert.equal(nextNode(wf, 'gw', ctx), 'design');
|
|
144
|
+
assert.equal(nextNode(wf, 'gw', { analyze: { complexity: 'low' } }), 'implement');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('nextNode: XOR 网关条件都不命中走 default 边', () => {
|
|
148
|
+
const wf = xorWorkflow();
|
|
149
|
+
assert.equal(nextNode(wf, 'gw', { analyze: { complexity: 'medium' } }), 'design'); // default 指向 design
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('nextNode: XOR 网关无 default 且不命中抛错', () => {
|
|
153
|
+
const wf = xorWorkflow();
|
|
154
|
+
wf.edges = wf.edges.filter((e) => !(e.from === 'gw' && e.default));
|
|
155
|
+
assert.throws(() => nextNode(wf, 'gw', { analyze: { complexity: 'medium' } }), /default|no path/i);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// resolveInputs(数据流:显式参数映射)
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
test('resolveInputs: 引用存在字段返回对应值', () => {
|
|
162
|
+
const node = { id: 'b', type: 'task', inputs: [{ from: 'a', field: 'x' }] };
|
|
163
|
+
const ctx = { a: { x: 1, y: 2 } };
|
|
164
|
+
assert.deepEqual(resolveInputs(ctx, node), { a: { x: 1 } });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('resolveInputs: field="*" 返回上游全部输出', () => {
|
|
168
|
+
const node = { id: 'b', type: 'task', inputs: [{ from: 'a', field: '*' }] };
|
|
169
|
+
const ctx = { a: { x: 1, y: 2 } };
|
|
170
|
+
assert.deepEqual(resolveInputs(ctx, node), { a: { x: 1, y: 2 } });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('resolveInputs: 引用不存在的字段抛错', () => {
|
|
174
|
+
const node = { id: 'b', type: 'task', inputs: [{ from: 'a', field: 'missing' }] };
|
|
175
|
+
const ctx = { a: { x: 1 } };
|
|
176
|
+
assert.throws(() => resolveInputs(ctx, node), /missing/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('resolveInputs: 引用不存在的上游节点抛错', () => {
|
|
180
|
+
const node = { id: 'b', type: 'task', inputs: [{ from: 'ghost', field: 'x' }] };
|
|
181
|
+
const ctx = { a: { x: 1 } };
|
|
182
|
+
assert.throws(() => resolveInputs(ctx, node), /ghost/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('resolveInputs: 无 inputs 返回空对象', () => {
|
|
186
|
+
const node = { id: 'b', type: 'task', inputs: [] };
|
|
187
|
+
assert.deepEqual(resolveInputs({}, node), {});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// prepareWorkflowForSave(保存前准备:校验 + revision 递增)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
test('prepareWorkflowForSave: 首次保存 revision=1,再次递增', () => {
|
|
194
|
+
const r1 = prepareWorkflowForSave(serialWorkflow(), null);
|
|
195
|
+
assert.equal(r1.ok, true);
|
|
196
|
+
assert.equal(r1.workflow.revision, 1);
|
|
197
|
+
const r2 = prepareWorkflowForSave(serialWorkflow(), { revision: 3 });
|
|
198
|
+
assert.equal(r2.workflow.revision, 4);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('prepareWorkflowForSave: 非法图返回 errors', () => {
|
|
202
|
+
const wf = serialWorkflow();
|
|
203
|
+
wf.nodes = wf.nodes.filter((n) => n.type !== 'end');
|
|
204
|
+
const r = prepareWorkflowForSave(wf, null);
|
|
205
|
+
assert.equal(r.ok, false);
|
|
206
|
+
assert.ok(r.errors.length > 0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('prepareWorkflowForSave: 缺 id 返回错误', () => {
|
|
210
|
+
const r = prepareWorkflowForSave({ nodes: [], edges: [] }, null);
|
|
211
|
+
assert.equal(r.ok, false);
|
|
212
|
+
assert.ok(r.errors.some((e) => /id/.test(e)));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('validateWorkflow: human approveTo/rejectTo 目标(无 edges 入边)不报孤立', () => {
|
|
216
|
+
const wf = {
|
|
217
|
+
id: 'wf-human-end', nodes: [
|
|
218
|
+
{ id: 'start', type: 'start' },
|
|
219
|
+
{ id: 'verify', type: 'task', title: '验证', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
220
|
+
{ id: 'review', type: 'human', title: '评审', displayFrom: 'verify', approveTo: 'end', rejectTo: 'fix' },
|
|
221
|
+
{ id: 'fix', type: 'task', title: '修复', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
222
|
+
{ id: 'end', type: 'end' },
|
|
223
|
+
],
|
|
224
|
+
edges: [
|
|
225
|
+
{ from: 'start', to: 'verify' },
|
|
226
|
+
{ from: 'verify', to: 'review' },
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
const r = validateWorkflow(wf);
|
|
230
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('validateWorkflow: human 未配置通过/驳回去向 → 报错', () => {
|
|
234
|
+
const wf = {
|
|
235
|
+
id: 'wf-human-missing', nodes: [
|
|
236
|
+
{ id: 'start', type: 'start' },
|
|
237
|
+
{ id: 'verify', type: 'task', title: '验证', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
238
|
+
{ id: 'review', type: 'human', title: '设计评审', displayFrom: 'verify', approveTo: '', rejectTo: '' },
|
|
239
|
+
{ id: 'end', type: 'end' },
|
|
240
|
+
],
|
|
241
|
+
edges: [
|
|
242
|
+
{ from: 'start', to: 'verify' },
|
|
243
|
+
{ from: 'verify', to: 'review' },
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
const r = validateWorkflow(wf);
|
|
247
|
+
assert.equal(r.ok, false);
|
|
248
|
+
assert.ok(r.errors.some((e) => e.includes('未配置任何去向')), JSON.stringify(r.errors));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('validateWorkflow: human 多去向 routes 校验(目标无效 / 标签重复 / 缺标签)', () => {
|
|
252
|
+
const wf = {
|
|
253
|
+
id: 'wf-human-routes', nodes: [
|
|
254
|
+
{ id: 'start', type: 'start' },
|
|
255
|
+
{ id: 'verify', type: 'task', title: '验证', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
|
|
256
|
+
{ id: 'review', type: 'human', title: '设计评审', displayFrom: 'verify', routes: [
|
|
257
|
+
{ label: '通过', to: 'end' },
|
|
258
|
+
{ label: '驳回', to: 'nope' }, // 目标不存在
|
|
259
|
+
{ label: '驳回', to: 'end' }, // 标签重复
|
|
260
|
+
{ label: '', to: 'end' }, // 缺标签
|
|
261
|
+
] },
|
|
262
|
+
{ id: 'end', type: 'end' },
|
|
263
|
+
],
|
|
264
|
+
edges: [
|
|
265
|
+
{ from: 'start', to: 'verify' },
|
|
266
|
+
{ from: 'verify', to: 'review' },
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
const r = validateWorkflow(wf);
|
|
270
|
+
assert.equal(r.ok, false);
|
|
271
|
+
assert.ok(r.errors.some((e) => e.includes('目标无效: nope')), JSON.stringify(r.errors));
|
|
272
|
+
assert.ok(r.errors.some((e) => e.includes('去向标签重复: 驳回')), JSON.stringify(r.errors));
|
|
273
|
+
assert.ok(r.errors.some((e) => e.includes('缺少标签')), JSON.stringify(r.errors));
|
|
274
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const name: string;
|
|
2
|
+
export declare const inject: string[];
|
|
3
|
+
export declare const Config: import('@deepseek-ai/schemastery').Schema<{
|
|
4
|
+
dataRoot: string;
|
|
5
|
+
httpPrefix: string;
|
|
6
|
+
orchestratorScript: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function apply(ctx: any, config: any): void;
|