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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * dsh-knj-workflow 图编排器脚本(workflow 工具执行体)
3
+ * ---------------------------------------------------------------
4
+ * 图遍历:从 start 开始逐节点执行——
5
+ * - task 节点:派子 agent 执行 body.prompt,产出结构化结果写入 ctx
6
+ * - gateway-xor:按 when 条件路由(default 兜底)
7
+ * - gateway-and:split(多出边)并行执行分支,join(多入边)等全部完成;
8
+ * 某分支失败 → 整体失败(严格)
9
+ * - human:暂停,返回 null 等待人工决策(后续小步)
10
+ *
11
+ * 输入 args:
12
+ * config - 工作流定义 { id, name, inputs, nodes: [...], edges: [...] }
13
+ * task - 任务元数据 { id, title, taskDir }
14
+ *
15
+ * 断点持久化:每个 task 节点完成后,子 agent 把结构化结果写入
16
+ * <taskDir>/stages/<nodeId>.json(子 agent 通过 write 工具落盘)。
17
+ *
18
+ * 注意:本脚本运行在 workflow 引擎沙箱,无 fs/network/require,
19
+ * 因此路由/数据流逻辑内联(与 lib/graph.js 语义一致,勿偏离)。
20
+ */
21
+
22
+ const cfg = args.config;
23
+ if (!cfg || !Array.isArray(cfg.nodes) || !Array.isArray(cfg.edges)) {
24
+ throw new Error('args.config 必须是 { nodes: [...], edges: [...] }');
25
+ }
26
+ const task = args.task || { id: 'task', taskDir: '.tasks/knj' };
27
+ const stageDir = `${String(task.taskDir || '').replace(/\/+$/, '')}/stages`;
28
+
29
+ const byId = new Map(cfg.nodes.map((n) => [n.id, n]));
30
+ const results = { ...(args.initialResults || {}) }; // ctx: nodeId -> output(断点恢复)
31
+ const skipSet = new Set(Object.keys(args.initialResults || {})); // 断点恢复时跳过的节点
32
+ const stageLog = [];
33
+ const runCount = new Map(); // nodeId -> 本轮已执行次数(节点级 maxRuns 保护,防死循环)
34
+ let humanFeedback = null; // 人工意见:注入去向目标节点(仅一次)
35
+
36
+ /** 节点最大执行次数:显式配置 maxRuns 优先,默认 3(首次 + 最多 2 次重跑)。 */
37
+ function maxRunsOf(node) {
38
+ const m = Number(node && node.maxRuns);
39
+ return Number.isInteger(m) && m >= 1 ? m : 3;
40
+ }
41
+
42
+ /** 执行前检查并登记一次执行:超过 maxRuns 直接抛错(不浪费 subagent)。 */
43
+ function countRun(node) {
44
+ const n = (runCount.get(node.id) || 0) + 1;
45
+ if (n > maxRunsOf(node)) {
46
+ throw new Error(`节点「${node.title || node.id}」本轮执行 ${n} 次,超过上限 ${maxRunsOf(node)}(疑似死循环,请检查连线)`);
47
+ }
48
+ runCount.set(node.id, n);
49
+ }
50
+
51
+ /** 人工节点去向:优先 routes(多去向:label + to),兼容旧 approveTo/rejectTo(推导为 通过/驳回 两条)。 */
52
+ function humanRoutes(node) {
53
+ if (Array.isArray(node.routes) && node.routes.length > 0) return node.routes;
54
+ const r = [];
55
+ if (node.approveTo) r.push({ label: '通过', to: node.approveTo, tone: 'success' });
56
+ if (node.rejectTo) r.push({ label: '驳回', to: node.rejectTo, tone: 'danger' });
57
+ return r;
58
+ }
59
+
60
+ // ---- 内联自 lib/graph.js(沙箱无法 require)----
61
+ function matchCondition(upstream, when) {
62
+ if (!when || !when.field) return false;
63
+ const v = upstream[when.field];
64
+ if (when.op === 'eq') return v === when.value;
65
+ if (when.op === 'neq') return v !== when.value;
66
+ if (when.op === 'in') return Array.isArray(when.value) && when.value.includes(v);
67
+ return false;
68
+ }
69
+
70
+ /** 路由:单出口节点返回唯一出边;多出边(XOR 网关)按 when 条件路由。死循环由节点级 maxRuns 保护(见 countRun)。 */
71
+ function nextNode(nodeId) {
72
+ const node = byId.get(nodeId);
73
+ if (!node) throw new Error(`节点不存在: ${nodeId}`);
74
+ const out = cfg.edges.filter((e) => e.from === nodeId);
75
+ if (node.type === 'human') return null;
76
+
77
+ // when.field 作用域:XOR 网关相对唯一上游;task 多出边相对自身输出
78
+ let upstream;
79
+ if (node.type === 'gateway-xor') {
80
+ const inEdge = cfg.edges.find((e) => e.to === nodeId);
81
+ upstream = inEdge ? (results[inEdge.from] || {}) : {};
82
+ } else {
83
+ upstream = results[nodeId] || {};
84
+ }
85
+
86
+ let chosen = null;
87
+ if (out.length > 1) {
88
+ for (const e of out) {
89
+ // default 边:配了条件也参与匹配(兜底优先级的条件边);无条件则纯兜底,跳过匹配
90
+ if (e.default && !e.when) continue;
91
+ if (e.when) {
92
+ // 条件值支持引用任务变量(${ctx.inputs.x} / ${inputDescription} 等),如按输入分流
93
+ const wVal = typeof e.when.value === 'string' ? resolveRefs(e.when.value) : e.when.value;
94
+ if (matchCondition(upstream, { ...e.when, value: wVal })) { chosen = e; break; }
95
+ }
96
+ }
97
+ // 所有条件都没命中 → 优先走无条件 default(纯兜底);没有则走第一条 default(带条件也兜底)
98
+ if (!chosen) chosen = out.find((e) => e.default && !e.when) || out.find((e) => e.default) || null;
99
+ if (!chosen) throw new Error(`节点 ${nodeId} 无匹配条件且无 default 边`);
100
+ } else if (out.length === 1) {
101
+ chosen = out[0];
102
+ } else {
103
+ return null; // end 无出边
104
+ }
105
+
106
+ return chosen.to;
107
+ }
108
+
109
+ /** 从 split 网关出边 BFS,找第一个多入边的 gateway-and(即对应 join)。 */
110
+ function findJoin(splitId) {
111
+ const seen = new Set([splitId]);
112
+ const queue = cfg.edges.filter((e) => e.from === splitId).map((e) => e.to);
113
+ while (queue.length) {
114
+ const id = queue.shift();
115
+ if (seen.has(id)) continue;
116
+ seen.add(id);
117
+ const node = byId.get(id);
118
+ if (node.type === 'gateway-and') {
119
+ const inN = cfg.edges.filter((e) => e.to === id).length;
120
+ if (inN > 1) return id;
121
+ }
122
+ for (const e of cfg.edges.filter((e) => e.from === id)) queue.push(e.to);
123
+ }
124
+ return null;
125
+ }
126
+ // ----------------------------------------------------
127
+
128
+ // ---- hook:内置动作库 + 动作执行(内联,勿偏离 lib/actions.js 语义)----
129
+ const BUILTIN_ACTIONS = {
130
+ 'git-pull': { prompt: '用 bash 工具执行 git pull,返回 { ok, branch, commit }', schema: null },
131
+ 'git-checkout': { prompt: '用 bash 工具执行 git checkout {branch},返回 { ok, commit }', schema: null },
132
+ 'mkdir': { prompt: '用 bash 工具创建目录 {path},返回 { ok, path }', schema: null },
133
+ 'write-product': { prompt: '用 write 工具把产物内容写入文件 {path}(UTF-8)', schema: null },
134
+ };
135
+
136
+ function getCtx() {
137
+ // 任务级变量:inputDescription(新建任务需求描述)、inputTitle(标题),
138
+ // 与 inputs(参数映射)并列,工作流任意 prompt/hook 参数可引用。
139
+ return {
140
+ inputs: args.inputs || {},
141
+ inputDescription: task.description || '',
142
+ inputTitle: task.title || '',
143
+ ...results
144
+ };
145
+ }
146
+
147
+ /** 解析引用:${inputDescription}(任务级)、${节点id.字段}(上游输出)、${ctx.路径}(兼容旧写法)。 */
148
+ function resolveRefs(text) {
149
+ let s = String(text);
150
+ // 统一正则:${路径} 或 ${ctx.路径}——路径按 getCtx 的 key 贪心匹配(inputDescription / inputs.x / 节点id.字段)
151
+ s = s.replace(/\$\{(?:ctx\.)?([^}]+)\}/g, (m, path) => {
152
+ const ctx = getCtx();
153
+ const parts = path.split('.');
154
+ for (let i = parts.length; i >= 1; i--) {
155
+ const key = parts.slice(0, i).join('.');
156
+ if (key in ctx) {
157
+ let val = ctx[key];
158
+ for (let j = i; j < parts.length; j++) val = val == null ? val : val[parts[j]];
159
+ if (val != null) return typeof val === 'string' ? val : JSON.stringify(val);
160
+ }
161
+ }
162
+ // 找不到(XOR 分支被跳过的节点、拼写错误)→ 解析为空,避免把 ${...} 原文留在 prompt
163
+ return '';
164
+ });
165
+ return s;
166
+ }
167
+
168
+ function fillTemplate(tpl, params) {
169
+ return String(tpl).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m));
170
+ }
171
+
172
+ /** 执行一个 hook 动作(builtin 或 exec),输出按 as 命名写进 ctx。 */
173
+ async function runAction(node, action) {
174
+ let prompt, schema, label;
175
+ if (action.type === 'builtin') {
176
+ const def = BUILTIN_ACTIONS[action.name];
177
+ if (!def) throw new Error(`未知内置动作: ${action.name}`);
178
+ prompt = resolveRefs(fillTemplate(def.prompt, action.params || {}));
179
+ schema = def.schema;
180
+ label = `${node.title}:${action.name}`;
181
+ } else {
182
+ prompt = resolveRefs(action.prompt || '');
183
+ schema = action.output && action.output.type ? action.output : null;
184
+ label = `${node.title}:exec`;
185
+ }
186
+ const opts = { label, phase: node.id };
187
+ if (schema) opts.schema = schema;
188
+ const out = await agent(prompt, opts);
189
+ if (action.as) results[`${node.id}.${action.as}`] = out;
190
+ return out;
191
+ }
192
+ // ----------------------------------------------------
193
+
194
+ /** 执行从 nodeId 到 joinId(或 end)的路径,返回 { ok, current }。 */
195
+ async function executeUntil(nodeId, joinId) {
196
+ let current = nodeId;
197
+ let ok = true;
198
+ let safety = 0;
199
+ while (current && current !== 'end' && current !== joinId && safety < 1000) {
200
+ safety++;
201
+ const node = byId.get(current);
202
+ if (!node) throw new Error(`节点不存在: ${current}`);
203
+ // phase 传 node.id(而非 title):stageStates 按 id 匹配,重名节点不会错标
204
+ phase(node.id);
205
+
206
+ if (node.type === 'task') {
207
+ // 断点恢复:initialResults 里的节点跳过一次(后续重跑可重新执行)
208
+ if (skipSet.has(node.id)) {
209
+ skipSet.delete(node.id);
210
+ current = nextNode(current);
211
+ continue;
212
+ }
213
+ // 节点最大执行次数保护(默认 3):超过即失败,防死循环。并行分片按一轮计 1 次。
214
+ countRun(node);
215
+ // 1. prehook 动作序列
216
+ for (const action of node.prehook || []) {
217
+ await runAction(node, action);
218
+ }
219
+ // 2. 主体
220
+ // 干净 prompt:不再自动注入 [任务需求]/[工作目录]/[上游节点输出]/[skill]。
221
+ // 需要引用时在 prompt 里显式写 ${inputDescription} / ${ctx.上游节点id.字段} / ${ctx.inputs.x};
222
+ // 需要 skill 时直接在 prompt 里写「先调用 skill 工具加载 skill「xxx」」。
223
+ // 仅保留:人工意见(去向目标节点需要知道)。
224
+ const parts = [];
225
+ if (humanFeedback) {
226
+ parts.push(`[人工意见]\n${humanFeedback}`);
227
+ humanFeedback = null;
228
+ }
229
+ // 主体 prompt 解析 ${ctx.xxx} 引用(任务输入 / 上游输出 / 动作输出)。
230
+ parts.push(resolveRefs((node.body && node.body.prompt) || ''));
231
+ let prompt = parts.join('\n\n');
232
+ const writeInstr = `\n\n完成后,用 write 工具将你的结构化输出写入文件 ${stageDir}/${node.id}.json(UTF-8 JSON)。`;
233
+
234
+ const opts = { label: node.title, phase: node.id };
235
+ const outSchema = node.body && node.body.output;
236
+ if (outSchema && outSchema.type) opts.schema = outSchema;
237
+
238
+ const isParallel = node.body && node.body.mode === 'parallel';
239
+ if (isParallel) {
240
+ // 节点级并行:派 N 个 subagent 执行同一 prompt,结果合并成数组(各自写不同 stage 文件)
241
+ const n = Math.max(1, node.body.parallelItems || 3);
242
+ const thunks = [];
243
+ for (let i = 0; i < n; i++) {
244
+ const instr = writeInstr.replace(`${node.id}.json`, `${node.id}-${i + 1}.json`);
245
+ thunks.push(() => agent(prompt + instr, { ...opts, label: `${node.title} #${i + 1}`, phase: node.id }));
246
+ }
247
+ const outputs = await parallel(thunks);
248
+ const valid = (outputs || []).filter((o) => o !== null && o !== undefined);
249
+ if (valid.length === 0) throw new Error(`节点「${node.title}」并行执行失败(无有效输出)`);
250
+ results[node.id] = valid;
251
+ stageLog.push({ id: node.id, title: node.title, ok: true });
252
+ } else {
253
+ const output = await agent(prompt + writeInstr, opts);
254
+ results[node.id] = output;
255
+ stageLog.push({ id: node.id, title: node.title, ok: output !== null });
256
+ if (output === null) throw new Error(`节点「${node.title}」执行失败(subagent 无有效输出)`);
257
+ }
258
+ // 断点:节点成功后主动上报结果,Host 落盘 stages/<nodeId>.json,
259
+ // 取消/中断后 resume 用它们跳过已完成节点(subagent 常不遵守写文件指令,不能依赖它)。
260
+ try { log('[knj-checkpoint]' + JSON.stringify({ node: node.id, output: results[node.id] })); } catch {}
261
+ log(`节点 ${node.title} 结束`);
262
+ // 3. posthook 动作序列
263
+ for (const action of node.posthook || []) {
264
+ await runAction(node, action);
265
+ }
266
+ current = nextNode(current);
267
+ } else if (node.type === 'gateway-and' && cfg.edges.filter((e) => e.from === current).length > 1) {
268
+ // split:并行执行所有分支到 join
269
+ const nestedJoin = findJoin(current);
270
+ const branches = cfg.edges
271
+ .filter((e) => e.from === current)
272
+ .map((e) => () => executeUntil(e.to, nestedJoin));
273
+ const branchResults = await parallel(branches);
274
+ if (branchResults.some((r) => !r || r.ok === false)) throw new Error(`并行分支执行失败(节点「${current}」)`);
275
+ // 分支内 human 暂停必须向上传播,否则审批被静默吞掉、工作流"成功"完成
276
+ const pausedBranch = branchResults.find((r) => r && r.paused);
277
+ if (pausedBranch) return { ok: true, current: null, paused: true, pausedAt: pausedBranch.pausedAt };
278
+ current = nestedJoin ? nextNode(nestedJoin) : null;
279
+ } else if (node.type === 'human') {
280
+ // 人工节点:有决策走对应去向(routes 多去向,兼容 approve/reject),否则暂停(返回 paused)
281
+ if (args.decision && args.decision.humanId === node.id) {
282
+ const decision = args.decision;
283
+ args.decision = null; // 消费决策:去向指回本节点的回边图若重复应用会无限循环直到 AGENT_CAP
284
+ const routes = humanRoutes(node);
285
+ // 兼容旧决策值:approve/reject 映射到 通过/驳回
286
+ let value = decision.value;
287
+ if (value === 'approve') value = '通过';
288
+ else if (value === 'reject') value = '驳回';
289
+ const route = routes.find((r) => r.label === value);
290
+ if (!route) {
291
+ throw new Error(`人工节点「${node.title || node.id}」决策「${decision.value}」不在去向列表(${routes.map((r) => r.label).join(' / ') || '未配置'})`);
292
+ }
293
+ if (!route.to || !byId.has(route.to)) throw new Error(`人工节点「${node.title || node.id}」的「${route.label}」去向无效: ${route.to}`);
294
+ if (decision.feedback) humanFeedback = decision.feedback;
295
+ skipSet.delete(route.to); // 去向目标应重新执行(接受人工意见),避免被断点跳过
296
+ current = route.to;
297
+ } else {
298
+ return { ok: true, current: null, paused: true, pausedAt: node.id };
299
+ }
300
+ } else {
301
+ current = nextNode(current);
302
+ }
303
+ }
304
+ return { ok, current };
305
+ }
306
+
307
+ // 所有执行失败(节点 subagent 无输出 / 并行分支失败 / 循环超限)都在 executeUntil 内抛错,
308
+ // 这里不吞错、直接向外抛:脚本正常 return 会被 workflow engine 判 stopReason=completed,
309
+ // 导致失败任务被误标 success。
310
+ const r = await executeUntil('start', null);
311
+
312
+ return {
313
+ workflow: cfg.id || cfg.name || 'workflow',
314
+ taskId: task.id,
315
+ nodeCount: cfg.nodes.length,
316
+ stageLog,
317
+ results,
318
+ ok: r.ok,
319
+ current: r.current, // 'end' 正常结束;null 表示暂停(human)
320
+ ...(r.paused ? { paused: r.paused, pausedAt: r.pausedAt } : {}),
321
+ ...(r.error ? { error: r.error } : {}),
322
+ };