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/lib/index.js ADDED
@@ -0,0 +1,1001 @@
1
+ /**
2
+ * dsh-knj-workflow Host 端
3
+ * ---------------------------------------------------------------
4
+ * 功能:
5
+ * 1. 工作流 CRUD(workflows.json,全局 ~/.dsh/dev-orchestrator/)
6
+ * 2. 开发任务 CRUD(tasks/<id>/task.json),任务绑定工作流
7
+ * 3. 任务启动/取消/暂停/重跑阶段/继续 —— 桥接 ctx.workflowEngine
8
+ * 4. HTTP API(默认前缀 /devtask)供 Client UI 调用
9
+ * 5. /dev-task 命令注册(命令平面,结果不进模型上下文)
10
+ * 6. 监听 workflow/phase、workflow/agent-end、workflow/end 事件更新任务进度
11
+ */
12
+ import { homedir } from 'node:os';
13
+ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
14
+ import { mkdir, readFile, writeFile, readdir, rm, stat, realpath } from 'node:fs/promises';
15
+ import { randomUUID } from 'node:crypto';
16
+ import z from '@deepseek-ai/schemastery';
17
+ import { readFileSync } from 'node:fs';
18
+ import { validateWorkflow, prepareWorkflowForSave } from './graph.js';
19
+
20
+ export const name = 'dsh-knj-workflow';
21
+
22
+ export const Config = z.object({
23
+ dataRoot: z.string().default(''),
24
+ httpPrefix: z.string().default('/devtask'),
25
+ orchestratorScript: z.string().default(''),
26
+ });
27
+
28
+ // `workflowEngine` is intentionally NOT injected: since rc.8 the engine lives
29
+ // inside the agent-preset realm (the standard preset's delegation group), not
30
+ // in the host realm this plugin mounts in. It is resolved lazily from the
31
+ // owning agent's scope when a run starts (see WorkflowBridge.startTask).
32
+ export const inject = ['webServer', 'agents'];
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // 工具函数
36
+ // ---------------------------------------------------------------------------
37
+ async function exists(p) {
38
+ try { await readFile(p); return true; } catch { return false; }
39
+ }
40
+ async function readJson(p, fallback) {
41
+ try { return JSON.parse(await readFile(p, 'utf8')); }
42
+ catch { return fallback; }
43
+ }
44
+ async function writeJson(p, value) {
45
+ await mkdir(dirname(p), { recursive: true });
46
+ const data = JSON.stringify(value, null, 2);
47
+ // Windows 上 rename 到已存在目标可能 EPERM(目标被占用/并发)。直接写文件
48
+ // 并做小规模重试;写入内容完整(同一进程内单线程事件循环串行化写入)。
49
+ let lastErr;
50
+ for (let attempt = 0; attempt < 3; attempt++) {
51
+ try {
52
+ await writeFile(p, data, 'utf8');
53
+ return;
54
+ } catch (error) {
55
+ lastErr = error;
56
+ await new Promise((r) => setTimeout(r, 25 * (attempt + 1)));
57
+ }
58
+ }
59
+ throw lastErr;
60
+ }
61
+ function nowIso() { return new Date().toISOString(); }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // 数据层 DevTaskStore
65
+ // ---------------------------------------------------------------------------
66
+ export class DevTaskStore {
67
+ constructor(root) {
68
+ this.root = root;
69
+ this.workflowsFile = join(root, 'workflows.json');
70
+ this.tasksDir = join(root, 'tasks');
71
+ this._queues = new Map(); // taskId -> Promise 链(串行化同一任务的写)
72
+ }
73
+
74
+ async init() {
75
+ await mkdir(this.root, { recursive: true });
76
+ await mkdir(this.tasksDir, { recursive: true });
77
+ const wf = await readJson(this.workflowsFile, { workflows: [] });
78
+ if (!Array.isArray(wf.workflows)) wf.workflows = [];
79
+ // 旧线性格式(含 stages、无 nodes/edges)直接废弃,不迁移
80
+ const before = wf.workflows.length;
81
+ wf.workflows = wf.workflows.filter((w) => !(Array.isArray(w.stages) && !Array.isArray(w.nodes)));
82
+ if (wf.workflows.length === 0) {
83
+ wf.workflows = [defaultWorkflow()];
84
+ }
85
+ if (wf.workflows.length !== before) {
86
+ await writeJson(this.workflowsFile, wf);
87
+ }
88
+ }
89
+
90
+ async listWorkflows() {
91
+ const wf = await readJson(this.workflowsFile, { workflows: [] });
92
+ return wf.workflows || [];
93
+ }
94
+ async getWorkflow(id) {
95
+ const list = await this.listWorkflows();
96
+ return list.find((w) => w.id === id);
97
+ }
98
+ async saveWorkflow(workflow) {
99
+ const existing = await this.getWorkflow(workflow?.id);
100
+ const prep = prepareWorkflowForSave(workflow, existing);
101
+ if (!prep.ok) throw new Error(`workflow 校验失败: ${prep.errors.join('; ')}`);
102
+ const prepared = prep.workflow;
103
+ const wf = await readJson(this.workflowsFile, { workflows: [] });
104
+ const i = wf.workflows.findIndex((w) => w.id === prepared.id);
105
+ if (i === -1) wf.workflows.push(prepared);
106
+ else wf.workflows[i] = prepared;
107
+ await writeJson(this.workflowsFile, wf);
108
+ return prepared;
109
+ }
110
+ async deleteWorkflow(id) {
111
+ const wf = await readJson(this.workflowsFile, { workflows: [] });
112
+ wf.workflows = wf.workflows.filter((w) => w.id !== id);
113
+ await writeJson(this.workflowsFile, wf);
114
+ }
115
+
116
+ /** 校验任务/阶段 id,防止路径穿越(%2e%2e%2f 解码后进 join 可任意读写/删目录) */
117
+ static assertSafeId(id) {
118
+ if (typeof id !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(id) || id.includes('..')) {
119
+ throw new Error(`invalid id: ${id}`);
120
+ }
121
+ }
122
+ taskFile(id) { DevTaskStore.assertSafeId(id); return join(this.tasksDir, id, 'task.json'); }
123
+ stageFile(taskId, stageId) { DevTaskStore.assertSafeId(taskId); DevTaskStore.assertSafeId(stageId); return join(this.tasksDir, taskId, 'stages', `${stageId}.json`); }
124
+ resultsFile(taskId) { DevTaskStore.assertSafeId(taskId); return join(this.tasksDir, taskId, 'results.json'); }
125
+
126
+ async listTasks() {
127
+ const out = [];
128
+ let dirs = [];
129
+ try { dirs = await readdir(this.tasksDir); } catch { return out; }
130
+ for (const d of dirs) {
131
+ const t = await readJson(this.taskFile(d), null);
132
+ if (t) {
133
+ // 列表不需要完整工作流配置(每个可能几十 KB,且每 3 秒轮询),剥掉以减轻传输与解析。
134
+ // 详情页用 getTask 拿完整快照。
135
+ const { workflowSnapshot, ...rest } = t;
136
+ out.push(rest);
137
+ }
138
+ }
139
+ return out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
140
+ }
141
+ async getTask(id) {
142
+ return await readJson(this.taskFile(id), null);
143
+ }
144
+ async saveTask(task) {
145
+ await mkdir(join(this.tasksDir, task.id), { recursive: true });
146
+ const file = this.taskFile(task.id);
147
+ // 串行化同一任务的写入:事件回调(phase/agent-end/end)可能并发触发 saveTask,
148
+ // Windows 上并发写同一文件会互相踩踏。
149
+ const prev = this._queues.get(task.id) || Promise.resolve();
150
+ const next = prev.then(async () => {
151
+ await writeJson(file, task);
152
+ });
153
+ this._queues.set(task.id, next.catch(() => {})); // 队列本身吞掉错误,避免整链断掉
154
+ await next;
155
+ return task;
156
+ }
157
+ /**
158
+ * 事务化 read-modify-write:在队列内串行「读最新快照 → mutator 原地修改 → 写盘」。
159
+ * 并发事件回调(phase/agent-start/agent-end)都用它,避免各自 getTask 拿到旧快照、
160
+ * 后写覆盖先写(此前导致 stageStates 的 status 一直 pending、进度不更新)。
161
+ */
162
+ async mutateTask(id, mutator) {
163
+ await mkdir(join(this.tasksDir, id), { recursive: true });
164
+ const file = this.taskFile(id);
165
+ const prev = this._queues.get(id) || Promise.resolve();
166
+ const next = prev.then(async () => {
167
+ const t = await readJson(file, null);
168
+ if (!t) return null;
169
+ await mutator(t);
170
+ await writeJson(file, t);
171
+ return t;
172
+ });
173
+ this._queues.set(id, next.catch(() => {}));
174
+ return await next;
175
+ }
176
+ async deleteTask(id) {
177
+ DevTaskStore.assertSafeId(id);
178
+ await rm(join(this.tasksDir, id), { recursive: true, force: true });
179
+ }
180
+ async readStage(taskId, stageId) {
181
+ return await readJson(this.stageFile(taskId, stageId), null);
182
+ }
183
+ async writeStage(taskId, stageId, data) {
184
+ await writeJson(this.stageFile(taskId, stageId), data);
185
+ }
186
+ async saveResults(taskId, results) {
187
+ await writeJson(this.resultsFile(taskId), results || {});
188
+ }
189
+ async readResults(taskId) {
190
+ return await readJson(this.resultsFile(taskId), null);
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 默认工作流(首次启动种子)
196
+ // ---------------------------------------------------------------------------
197
+ function defaultWorkflow() {
198
+ const task = (id, title, prompt, output, inputs = [], mode = 'single', parallelItems = undefined) => ({
199
+ id,
200
+ type: 'task',
201
+ title,
202
+ inputs,
203
+ body: {
204
+ prompt,
205
+ skill: null,
206
+ mode,
207
+ ...(parallelItems ? { parallelItems } : {}),
208
+ output,
209
+ },
210
+ prehook: [],
211
+ posthook: [],
212
+ });
213
+ return {
214
+ id: 'wf-dev-pipeline',
215
+ name: '开发任务流水线',
216
+ description: '需求 → 初始化目录 → 分析设计 → 编码 → 评审',
217
+ schemaVersion: 2,
218
+ revision: 1,
219
+ inputs: [],
220
+ nodes: [
221
+ { id: 'start', type: 'start' },
222
+ task('fetch-requirement', '获取需求详情',
223
+ '读取工作区 openspec/changes/ 目录下最近的 change 文件(或用户指定的需求来源),提取结构化需求:标题、背景、功能描述、验收标准、优先级。',
224
+ {
225
+ type: 'object',
226
+ properties: {
227
+ title: { type: 'string' },
228
+ description: { type: 'string' },
229
+ acceptanceCriteria: { type: 'array', items: { type: 'string' } },
230
+ source: { type: 'string' },
231
+ },
232
+ required: ['title', 'description', 'acceptanceCriteria'],
233
+ additionalProperties: false,
234
+ }),
235
+ task('init-task-dir', '初始化任务目录',
236
+ '用 pwsh 在工作区创建任务目录 .tasks/<需求id或短标题>/,内含 docs/ 与 src/ 子目录,输出实际创建的目录路径。',
237
+ {
238
+ type: 'object',
239
+ properties: { taskDir: { type: 'string' }, created: { type: 'boolean' } },
240
+ required: ['taskDir', 'created'],
241
+ additionalProperties: false,
242
+ },
243
+ [{ from: 'fetch-requirement', field: '*' }]),
244
+ task('design', '功能分析设计',
245
+ '基于上游需求与代码现状编写任务目录下 docs/design.md:涉及模块、改动清单、接口设计、风险点、实施顺序。若需可视化,可自行用文本描述架构图,不要依赖额外 skill。',
246
+ {
247
+ type: 'object',
248
+ properties: {
249
+ designDoc: { type: 'string' },
250
+ affectedFiles: { type: 'array', items: { type: 'string' } },
251
+ plan: { type: 'array', items: { type: 'string' } },
252
+ },
253
+ required: ['designDoc', 'affectedFiles', 'plan'],
254
+ additionalProperties: false,
255
+ },
256
+ [{ from: 'fetch-requirement', field: '*' }, { from: 'init-task-dir', field: '*' }]),
257
+ task('implement', '实施编码',
258
+ '读取设计文档与相关代码,用 edit/write 实现功能,记录改动文件清单。',
259
+ {
260
+ type: 'object',
261
+ properties: {
262
+ changedFiles: { type: 'array', items: { type: 'string' } },
263
+ summary: { type: 'string' },
264
+ },
265
+ required: ['changedFiles', 'summary'],
266
+ additionalProperties: false,
267
+ },
268
+ [{ from: 'design', field: '*' }]),
269
+ task('review-code', '评审代码',
270
+ '评审代码改动,输出问题清单(文件+行号+严重级别)。你的视角:正确性与逻辑 / 安全与健壮性 / 代码质量与风格。',
271
+ {
272
+ type: 'object',
273
+ properties: {
274
+ viewpoint: { type: 'string' },
275
+ verdict: { type: 'string' },
276
+ issues: {
277
+ type: 'array',
278
+ items: {
279
+ type: 'object',
280
+ properties: {
281
+ file: { type: 'string' }, line: { type: 'string' },
282
+ severity: { type: 'string' }, detail: { type: 'string' },
283
+ },
284
+ required: ['file', 'line', 'severity', 'detail'],
285
+ additionalProperties: false,
286
+ },
287
+ },
288
+ },
289
+ required: ['viewpoint', 'verdict', 'issues'],
290
+ additionalProperties: false,
291
+ },
292
+ [{ from: 'implement', field: '*' }], 'parallel', 3),
293
+ { id: 'end', type: 'end' },
294
+ ],
295
+ edges: [
296
+ { from: 'start', to: 'fetch-requirement' },
297
+ { from: 'fetch-requirement', to: 'init-task-dir' },
298
+ { from: 'init-task-dir', to: 'design' },
299
+ { from: 'design', to: 'implement' },
300
+ { from: 'implement', to: 'review-code' },
301
+ { from: 'review-code', to: 'end' },
302
+ ],
303
+ };
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // workflow 桥接:启动、事件监听
308
+ // ---------------------------------------------------------------------------
309
+ class WorkflowBridge {
310
+ constructor(ctx, store, orchestratorScript) {
311
+ this.ctx = ctx;
312
+ this.store = store;
313
+ this.orchestratorScript = orchestratorScript;
314
+ this.listeners = new Map(); // taskId -> runId
315
+ this.runs = new Map(); // taskId -> run 引用(用于取消)
316
+ this.parentHandles = new Map(); // runId -> 动态创建的专用 parent handle(按 run 代次管理,防旧 run 误杀新 run 的 parent)
317
+ }
318
+
319
+ /** 启动一个任务的工作流(resumeFrom / rerunStage 可选) */
320
+ async startTask(task, opts = {}) {
321
+ // 任务快照优先:运行只用创建时的快照,与工作流后续修改解耦
322
+ const workflow = task.workflowSnapshot || await this.store.getWorkflow(task.workflowId);
323
+ if (!workflow) throw new Error(`workflow not found: ${task.workflowId}`);
324
+
325
+ // 为每个任务动态创建一个专用顶层会话作为 parent(隔离用户现有会话,
326
+ // 不让 workflow 的 subagent 挂在「随机」的当前会话下);失败则回退现有会话。
327
+ const agents = this.ctx.get('agents');
328
+ let parent = null;
329
+ let parentHandle = null;
330
+ if (typeof agents?.create === 'function') {
331
+ const seed = agents.currentInitiator?.() ?? agents.roots?.()?.[0];
332
+ if (seed) {
333
+ try {
334
+ // 关键:专用 parent 必须带 cwd(工作目录),否则它的 subagent 继承不到 cwd,
335
+ // 会以「no working directory for the child」启动即失败(task 被误标 success)。
336
+ // 优先用任务 cwd,其次继承 seed 会话的 cwd。
337
+ const seedCwd = seed.session?.header?.cwd;
338
+ const parentCwd = task.cwd || seedCwd;
339
+ parentHandle = await agents.create({
340
+ sessionId: `knj-task-${task.id}-${randomUUID().slice(0, 8)}`,
341
+ agentOptions: {
342
+ ...(seed.options?.provider != null ? { provider: seed.options.provider } : {}),
343
+ ...(seed.options?.model != null ? { model: seed.options.model } : {}),
344
+ ...(seed.options?.maxTokens != null ? { maxTokens: seed.options.maxTokens } : {}),
345
+ },
346
+ ...(parentCwd ? { meta: { cwd: parentCwd } } : {}),
347
+ });
348
+ parent = parentHandle?.agent;
349
+ } catch (error) {
350
+ this.ctx.logger?.warn?.(`dsh-knj-workflow: 创建专用 parent 会话失败,回退现有会话: ${error?.message || error}`);
351
+ }
352
+ }
353
+ }
354
+ if (!parent) parent = agents?.currentInitiator?.() ?? agents?.roots?.()?.[0];
355
+ if (!parent) throw new Error('no active agent to own the workflow run');
356
+
357
+ // 记录 parent 会话 id(agent.id === session.id,见 agent-loop enter 校验),
358
+ // 前端「执行记录」用它 refreshSubagents 并把每个节点 subagent 跳转回原生会话。
359
+ task.parentSessionId = parent.id;
360
+ await this.store.saveTask(task);
361
+
362
+ // workflowEngine 是 agent-scope 服务(dsh-scope),必须在 agent 的 scope 上下文内解析。
363
+ // Agent 暴露 scope(Scope 对象,.ctx 是带标签的上下文);loopCtx 是 agent loop 的上下文。
364
+ // engine 解析或 engine.start 抛错时,必须释放已创建的专用 parent,否则成为泄漏的常驻 agent。
365
+ let run;
366
+ try {
367
+ const scopeCtx = parent.scope?.ctx ?? parent.loopCtx;
368
+ const engine = scopeCtx?.get?.('workflowEngine');
369
+ if (!engine) {
370
+ throw new Error('workflowEngine unavailable in the owning agent scope (is an agent preset with workflow support mounted?)');
371
+ }
372
+ const taskDir = join(this.store.tasksDir, task.id);
373
+ run = engine.start({
374
+ script: this.orchestratorScript || readFileSync(new URL('./orchestrator.js', import.meta.url), 'utf8'),
375
+ meta: { name: workflow.id, description: `${task.title} @ ${workflow.name}` },
376
+ args: {
377
+ config: workflow,
378
+ task: { id: task.id, title: task.title, taskDir, ...(task.description ? { description: task.description } : {}), ...(task.cwd ? { cwd: task.cwd } : {}) },
379
+ ...(task.inputs && typeof task.inputs === 'object' ? { inputs: task.inputs } : {}),
380
+ ...(opts.decision ? { decision: opts.decision } : {}),
381
+ ...(opts.initialResults ? { initialResults: opts.initialResults } : {}),
382
+ ...(opts.resumeFrom ? { resumeFrom: opts.resumeFrom } : {}),
383
+ ...(opts.rerunStage ? { rerunStage: opts.rerunStage } : {}),
384
+ },
385
+ parent,
386
+ });
387
+ } catch (error) {
388
+ if (parentHandle) { try { await parentHandle.dispose?.(); } catch {} }
389
+ throw error;
390
+ }
391
+
392
+ this.listeners.set(task.id, run.id);
393
+ this.runs.set(task.id, run);
394
+ if (parentHandle) this.parentHandles.set(run.id, parentHandle);
395
+ this.monitorRun(task, run);
396
+ return { runId: run.id };
397
+ }
398
+
399
+ /** 监听 run 事件并回写任务状态(事件签名:(info, payload)) */
400
+ async monitorRun(task, run) {
401
+ const ctx = this.ctx;
402
+ const matches = (info) => info?.id === run.id;
403
+
404
+ const onPhase = (info, title) => {
405
+ if (!matches(info)) return;
406
+ this.store.mutateTask(task.id, (t) => {
407
+ const now = nowIso();
408
+ // phase 现在是 node id(编排器 phase(node.id)),按 s.id 匹配,重名节点不串标
409
+ const matched = (t.stageStates || []).find((x) => x.id === title);
410
+ if (matched) t.currentStage = matched.title;
411
+ t.stageStates = (t.stageStates || []).map((s) => {
412
+ // skipped/failed 也要重新标 running:resume 重跑时旧状态会挡住进度推进
413
+ if (s.id === title && (s.status === 'pending' || s.status === 'skipped')) return { ...s, status: 'running', startedAt: s.startedAt || now };
414
+ return s;
415
+ });
416
+ }).catch(() => {});
417
+ };
418
+ const onAgentStart = (info, agent) => {
419
+ if (!matches(info)) return;
420
+ this.store.mutateTask(task.id, (t) => {
421
+ const title = agent?.phase || agent?.label;
422
+ const childId = agent?.childId;
423
+ // 关联节点 subagent 会话 id;只记节点主体/并行分片(label 不含 ':'),
424
+ // prehook 动作代理(label=node:xxx)不污染 sessionIds。
425
+ const isNodeAgent = agent?.label && !String(agent.label).includes(':');
426
+ if (title && childId && isNodeAgent) {
427
+ const s = (t.stageStates || []).find((x) => x.id === title);
428
+ if (s) s.sessionIds = [...(s.sessionIds || []), childId];
429
+ }
430
+ }).catch(() => {});
431
+ };
432
+ const onAgentEnd = (info, agent) => {
433
+ if (!matches(info)) return;
434
+ this.store.mutateTask(task.id, (t) => {
435
+ const title = agent?.phase || agent?.label;
436
+ // 只对「主体 subagent」(label 不含 ':' 且不含 '#')标 done:
437
+ // prehook(label=node:xxx)完成不代表节点完成;并行分片(label=node #N)逐个结束
438
+ // 也不能提前标 done(等全部结束由 finalizeTask 用 stageLog 标)。
439
+ const isMain = agent?.label && !String(agent.label).includes(':') && !String(agent.label).includes('#');
440
+ if (title && isMain) {
441
+ const s = (t.stageStates || []).find((x) => x.id === title);
442
+ if (s && s.status === 'running') { s.status = 'done'; s.finishedAt = nowIso(); }
443
+ }
444
+ }).catch(() => {});
445
+ };
446
+ // 编排器每完成一个节点会 log 一条 `[knj-checkpoint]`(JSON: {node, output}),
447
+ // Host 落盘到 stages/<nodeId>.json —— 这是取消/中断后断点续跑的数据源
448
+ // (不依赖 subagent 写文件,实测 subagent 常不遵守写文件指令)。
449
+ const onLog = (info, message) => {
450
+ if (!matches(info) || typeof message !== 'string') return;
451
+ if (!message.startsWith('[knj-checkpoint]')) return;
452
+ try {
453
+ const data = JSON.parse(message.slice('[knj-checkpoint]'.length));
454
+ if (data && data.node && data.output !== undefined) {
455
+ this.store.writeStage(task.id, String(data.node), data.output).catch(() => {});
456
+ }
457
+ } catch {}
458
+ };
459
+ ctx.on('workflow/phase', onPhase);
460
+ ctx.on('workflow/agent-start', onAgentStart);
461
+ ctx.on('workflow/agent-end', onAgentEnd);
462
+ ctx.on('workflow/log', onLog);
463
+
464
+ // 唯一终态来源:run.result(含完整 value.results)。
465
+ // 注意 workflow/end 事件 payload 只有 stopReason/error/agentsStarted、不含 value,
466
+ // 不能拿它 finalizeTask,否则会用空 results 覆盖正确结果(已踩坑)。
467
+ // run.result 契约上永不 reject,直接 await 设置终态 + 清理监听器。
468
+ run.result.then(async (result) => {
469
+ try {
470
+ // 只允许「当前活跃 run」写终态:cancel→resume 时旧 run 的 result 晚到,
471
+ // 若不校验会覆盖新 run 状态;parent 按 runId 管理,旧 run 只 dispose 自己的 parent,
472
+ // 不会误杀新 run 的子代理(dsh-subagent dispose 会级联 cancel({kind:'parent'}))。
473
+ if (this.runs.get(task.id) === run) {
474
+ await this.finalizeTask(task.id, result);
475
+ }
476
+ } catch {}
477
+ // 清理放 finally 语义(即使 finalizeTask 抛错也必须 off 监听器 + dispose parent)
478
+ ctx.off('workflow/phase', onPhase);
479
+ ctx.off('workflow/agent-start', onAgentStart);
480
+ ctx.off('workflow/agent-end', onAgentEnd);
481
+ ctx.off('workflow/log', onLog);
482
+ if (this.runs.get(task.id) === run) {
483
+ this.listeners.delete(task.id);
484
+ this.runs.delete(task.id);
485
+ }
486
+ // 终态后释放专用 parent 会话(按 runId,避免误删新 run 的 parent)
487
+ const handle = this.parentHandles.get(run.id);
488
+ if (handle) {
489
+ this.parentHandles.delete(run.id);
490
+ try { await handle.dispose?.(); } catch (e) { /* 忽略 dispose 失败,任务已终态 */ }
491
+ }
492
+ }).catch(() => {});
493
+ }
494
+
495
+ /** 取消后台 run(真正停止,否则 finalizeTask 会把 cancelled 覆盖成 success/failed) */
496
+ cancelTask(taskId) {
497
+ const run = this.runs.get(taskId);
498
+ if (run && typeof run.cancel === 'function') run.cancel();
499
+ }
500
+
501
+ /** 根据 run 结果设置任务终态(幂等) */
502
+ async finalizeTask(taskId, result) {
503
+ const value = result?.value;
504
+ // 人工节点暂停:标记 waiting-human + 落盘断点(result.value 是编排器 return 值)
505
+ if (value?.paused) {
506
+ await this.store.mutateTask(taskId, (t) => {
507
+ t.status = 'waiting-human';
508
+ t.currentStage = value.pausedAt;
509
+ t.humanState = { humanId: value.pausedAt, results: value.results || {} };
510
+ });
511
+ await this.store.saveResults(taskId, value.results || {});
512
+ return;
513
+ }
514
+ const stopReason = result?.stopReason;
515
+ const failMsg = stopReason === 'completed' ? null : (result?.error ?? stopReason ?? 'unknown');
516
+ // 终态修正:用编排器 stageLog 标记实际执行过的节点,没执行的 pending 标为 skipped
517
+ // (XOR 分叉没走的分支、并行未触及的节点),避免进度条永远到不了 100%。
518
+ const executedIds = new Set((value?.stageLog || []).map((e) => e.id));
519
+ await this.store.mutateTask(taskId, (t) => {
520
+ if (stopReason === 'completed') {
521
+ t.status = 'success';
522
+ } else if (stopReason === 'cancelled') {
523
+ t.status = 'cancelled';
524
+ } else {
525
+ t.status = 'failed';
526
+ t.error = result?.error ?? stopReason ?? 'unknown';
527
+ }
528
+ t.finishedAt = nowIso();
529
+ t.stageStates = (t.stageStates || []).map((s) => {
530
+ if (s.status === 'running') {
531
+ // cancelled 时 running 标 done(用户主动取消,节点不是失败,避免误导)
532
+ const failed = stopReason !== 'completed' && stopReason !== 'cancelled';
533
+ return { ...s, status: failed ? 'failed' : 'done', finishedAt: nowIso(), ...(failed && failMsg ? { error: failMsg } : {}) };
534
+ }
535
+ if (s.status === 'pending' && !executedIds.has(s.id)) return { ...s, status: 'skipped' };
536
+ return s;
537
+ });
538
+ });
539
+ await this.store.saveResults(taskId, result?.value?.results || {});
540
+ }
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // HTTP API 路由
545
+ // ---------------------------------------------------------------------------
546
+ function sendJson(res, status, body) {
547
+ const data = JSON.stringify(body);
548
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
549
+ res.end(data);
550
+ }
551
+ async function readBody(req) {
552
+ return await new Promise((resolve, reject) => {
553
+ let raw = '';
554
+ req.on('data', (c) => { raw += c; if (raw.length > 1e7) { reject(new Error('body too large')); req.destroy(); } });
555
+ req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
556
+ req.on('error', reject);
557
+ });
558
+ }
559
+
560
+ function registerRoutes(ctx, store, bridge, prefix) {
561
+ const routes = [
562
+ // 健康检查
563
+ ['GET', '/health', async (c) => sendJson(c.res, 200, { ok: true, plugin: 'dsh-knj-workflow', dataRoot: store.root })],
564
+ // 诊断:运行时可见性(排查 workflowEngine scope 问题)
565
+ ['GET', '/diag', async (c) => {
566
+ const agents = ctx.get('agents');
567
+ const roots = agents?.roots ? agents.roots() : [];
568
+ const diag = {
569
+ ctxKeys: Object.keys(ctx).filter((k) => /workflow|agent|subagent|scope/i.test(k)),
570
+ hasWorkflowEngineGlobal: !!ctx.get('workflowEngine'),
571
+ hasAgentsService: !!agents,
572
+ rootAgentCount: roots.length,
573
+ roots: roots.map((a) => {
574
+ const scopeCtx = a.scope?.ctx ?? a.loopCtx;
575
+ return {
576
+ id: a.id,
577
+ keys: Object.keys(a).filter((k) => /ctx|scope|session|loop/i.test(k)),
578
+ hasScope: !!a.scope,
579
+ scopeKeys: a.scope ? Object.keys(a.scope) : [],
580
+ engineViaScopeCtx: !!scopeCtx?.get?.('workflowEngine'),
581
+ engineViaLoopCtx: !!a.loopCtx?.get?.('workflowEngine'),
582
+ engineViaCtx: !!a.ctx?.get?.('workflowEngine'),
583
+ loopCtxKeys: a.loopCtx ? Object.keys(a.loopCtx).filter((k) => /workflow|agent|subagent|scope/i.test(k)) : [],
584
+ };
585
+ }),
586
+ };
587
+ sendJson(c.res, 200, diag);
588
+ }],
589
+ // 工作流 CRUD
590
+ ['GET', '/workflows', async (c) => sendJson(c.res, 200, { workflows: await store.listWorkflows() })],
591
+ ['POST', '/workflows', async (c) => {
592
+ const body = await readBody(c.req);
593
+ const wf = await store.saveWorkflow(body);
594
+ sendJson(c.res, 200, { workflow: wf });
595
+ }],
596
+ ['PUT', '/workflows', async (c) => {
597
+ const body = await readBody(c.req);
598
+ const wf = await store.saveWorkflow(body);
599
+ sendJson(c.res, 200, { workflow: wf });
600
+ }],
601
+ ['DELETE', '/workflows/:id', async (c) => {
602
+ await store.deleteWorkflow(c.params.id);
603
+ sendJson(c.res, 200, { ok: true });
604
+ }],
605
+ // 任务 CRUD
606
+ ['GET', '/tasks', async (c) => sendJson(c.res, 200, { tasks: await store.listTasks() })],
607
+ ['GET', '/tasks/:id', async (c) => {
608
+ const t = await store.getTask(c.params.id);
609
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
610
+ sendJson(c.res, 200, { task: t });
611
+ }],
612
+ ['POST', '/tasks', async (c) => {
613
+ const body = await readBody(c.req);
614
+ if (!body.title) return sendJson(c.res, 400, { error: 'title required' });
615
+ const workflow = body.workflowId ? await store.getWorkflow(body.workflowId) : (await store.listWorkflows())[0];
616
+ if (!workflow) return sendJson(c.res, 400, { error: 'no workflow available' });
617
+ const task = {
618
+ id: body.id || `task-${Date.now().toString(36)}`,
619
+ title: body.title,
620
+ workflowId: workflow.id,
621
+ workflowRevision: workflow.revision || 1,
622
+ workflowSnapshot: workflow,
623
+ status: 'pending',
624
+ currentStage: null,
625
+ stageStates: workflow.nodes.filter((n) => n.type === 'task').map((n) => ({ id: n.id, title: n.title, status: 'pending' })),
626
+ createdAt: nowIso(),
627
+ ...(body.notes ? { notes: body.notes } : {}),
628
+ // 需求描述必须保存:编排器各节点 prompt 要靠它注入「用户输入的需求内容」
629
+ ...(typeof body.description === 'string' && body.description.trim() ? { description: body.description.trim() } : {}),
630
+ ...(body.inputs && typeof body.inputs === 'object' ? { inputs: body.inputs } : {}),
631
+ ...(body.cwd && typeof body.cwd === 'string' ? { cwd: body.cwd.trim() } : {}),
632
+ };
633
+ await store.saveTask(task);
634
+ if (body.autoStart !== false) {
635
+ await bridge.startTask(task);
636
+ task.status = 'running';
637
+ task.startedAt = nowIso();
638
+ await store.saveTask(task);
639
+ }
640
+ sendJson(c.res, 200, { task });
641
+ }],
642
+ ['POST', '/tasks/:id/start', async (c) => {
643
+ const t = await store.getTask(c.params.id);
644
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
645
+ t.status = 'running';
646
+ t.startedAt = nowIso();
647
+ await store.saveTask(t);
648
+ try {
649
+ const { runId } = await bridge.startTask(t);
650
+ sendJson(c.res, 200, { task: t, runId });
651
+ } catch (e) {
652
+ // 启动失败必须标记 failed,否则任务永久卡 running(取消也无效)
653
+ await store.mutateTask(c.params.id, (tt) => {
654
+ tt.status = 'failed';
655
+ tt.error = `启动失败:${e instanceof Error ? e.message : String(e)}`;
656
+ tt.finishedAt = nowIso();
657
+ }).catch(() => {});
658
+ sendJson(c.res, 500, { error: e instanceof Error ? e.message : String(e) });
659
+ }
660
+ }],
661
+ ['POST', '/tasks/:id/cancel', async (c) => {
662
+ const t = await store.getTask(c.params.id);
663
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
664
+ t.status = 'cancelled';
665
+ t.finishedAt = nowIso();
666
+ await store.saveTask(t);
667
+ bridge.cancelTask(c.params.id); // 真正停止后台 run
668
+ sendJson(c.res, 200, { task: t });
669
+ }],
670
+ ['POST', '/tasks/:id/rerun-stage', async (c) => {
671
+ const body = await readBody(c.req);
672
+ const t = await store.getTask(c.params.id);
673
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
674
+ if (!body.stageId) return sendJson(c.res, 400, { error: 'stageId required' });
675
+ // 断点恢复:重跑阶段之前的已完成节点结果作为 initialResults(跳过),重跑阶段及之后重新执行
676
+ const steps = t.stageStates || [];
677
+ const idx = steps.findIndex((s) => s.id === body.stageId);
678
+ const beforeIds = idx > 0 ? steps.slice(0, idx).map((s) => s.id) : [];
679
+ const allResults = await store.readResults(c.params.id) || {};
680
+ const initialResults = {};
681
+ // 之前节点的断点优先用 stage 文件(编排器 checkpoint,取消后仍可靠);
682
+ // results.json 在取消时是空的,不能作为唯一来源。
683
+ for (const id of beforeIds) {
684
+ const stageData = await store.readStage(c.params.id, id).catch(() => null);
685
+ if (stageData !== null) initialResults[id] = stageData;
686
+ else if (allResults[id] !== undefined) initialResults[id] = allResults[id];
687
+ }
688
+ // 清空重跑节点及之后节点的旧 stage 文件(否则中断后再「续跑」会误用旧 checkpoint,
689
+ // 把本次还没重跑到的节点误判为已完成直接跳过)。
690
+ try {
691
+ const afterIds = new Set(idx >= 0 ? steps.slice(idx).map((s) => s.id) : []);
692
+ const stagesDir = join(store.tasksDir, c.params.id, 'stages');
693
+ if (afterIds.size > 0) {
694
+ const files = await readdir(stagesDir).catch(() => []);
695
+ for (const f of files) {
696
+ const base = f.replace(/\.json$/, '');
697
+ const m = /^(.*)-(\d+)$/.exec(base);
698
+ if (afterIds.has(m ? m[1] : base)) await rm(join(stagesDir, f), { force: true }).catch(() => {});
699
+ }
700
+ }
701
+ } catch {}
702
+ t.status = 'running';
703
+ t.startedAt = nowIso();
704
+ t.currentStage = body.stageId;
705
+ t.stageStates = steps.map((s, i) => {
706
+ if (i < idx) return s; // 之前的保持
707
+ if (i === idx) return { ...s, status: 'running' };
708
+ return { ...s, status: 'pending' }; // 之后的重新执行
709
+ });
710
+ await store.saveTask(t);
711
+ try {
712
+ const { runId } = await bridge.startTask(t, { initialResults });
713
+ sendJson(c.res, 200, { task: t, runId });
714
+ } catch (e) {
715
+ await store.mutateTask(c.params.id, (tt) => {
716
+ tt.status = 'failed';
717
+ tt.error = `重跑阶段失败:${e instanceof Error ? e.message : String(e)}`;
718
+ tt.finishedAt = nowIso();
719
+ }).catch(() => {});
720
+ sendJson(c.res, 500, { error: e instanceof Error ? e.message : String(e) });
721
+ }
722
+ }],
723
+ ['POST', '/tasks/:id/resume', async (c) => {
724
+ const body = await readBody(c.req);
725
+ const mode = body.mode === 'rerun' ? 'rerun' : 'resume';
726
+ const t = await store.getTask(c.params.id);
727
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
728
+ // 断点来源:续跑(mode=resume)读 stage 文件(每节点完成时编排器 checkpoint 落盘),
729
+ // orchestrator 跳过已完成节点从断点继续;重跑(mode=rerun)清空 initialResults 从头执行。
730
+ // 注意:results.json 在取消时是空的(编排器被中断,中间结果不落盘),不能作为断点来源。
731
+ const initialResults = {};
732
+ if (mode === 'rerun') {
733
+ // 清空旧 stage 文件:否则重跑中途取消后再「续跑」会误用本次重跑前的旧 checkpoint
734
+ try {
735
+ await rm(join(store.tasksDir, c.params.id, 'stages'), { recursive: true, force: true });
736
+ } catch {}
737
+ }
738
+ if (mode === 'resume') {
739
+ try {
740
+ // 识别并行节点(body.mode === 'parallel')及其分片数,避免 -N 后缀误分类/部分分片被当完整结果
741
+ const parallelPlan = new Map();
742
+ for (const n of (t.workflowSnapshot?.nodes || [])) {
743
+ if (n.type === 'task' && n.body && n.body.mode === 'parallel') {
744
+ parallelPlan.set(n.id, Math.max(1, n.body.parallelItems || 3));
745
+ }
746
+ }
747
+ const stagesDir = join(store.tasksDir, c.params.id, 'stages');
748
+ const files = await readdir(stagesDir);
749
+ const singles = {};
750
+ const parallelParts = {};
751
+ for (const f of files) {
752
+ if (!f.endsWith('.json')) continue;
753
+ const base = f.replace(/\.json$/, '');
754
+ const data = await store.readStage(c.params.id, base);
755
+ if (data === null) continue;
756
+ const m = /^(.*)-(\d+)$/.exec(base); // 并行分片写 node-1.json / node-2.json
757
+ if (m && parallelPlan.has(m[1])) {
758
+ // 只对已知并行节点识别分片,避免「以 -数字 结尾的单节点名」被误分类
759
+ (parallelParts[m[1]] = parallelParts[m[1]] || []).push({ idx: parseInt(m[2], 10), data });
760
+ } else {
761
+ singles[base] = data;
762
+ }
763
+ }
764
+ for (const [k, parts] of Object.entries(parallelParts)) {
765
+ const n = parallelPlan.get(k) || 0;
766
+ parts.sort((a, b) => a.idx - b.idx);
767
+ const complete = n > 0 && parts.length === n && parts.every((p, i) => p.idx === i + 1);
768
+ // 只把「分片完整且序号连续」的并行节点当断点;部分完成让并行节点重跑
769
+ if (complete && !(k in singles)) singles[k] = parts.map((p) => p.data);
770
+ }
771
+ Object.assign(initialResults, singles);
772
+ } catch {}
773
+ }
774
+ const hasInitial = Object.keys(initialResults).length > 0;
775
+ // 重置 stageStates:续跑且有断点(跳过已完成)时保留 done、仅 skipped 重置 pending;
776
+ // 重跑或无断点则全部重置 pending。旧 done/skipped 会挡住 onPhase 重新标记 running,
777
+ // 导致重跑节点进度不更新、任务看似卡住。
778
+ t.stageStates = (t.stageStates || []).map((s) => {
779
+ // skipped/failed 都要重置:failed 节点重跑成功后若不被重置,onPhase 只认 pending/skipped,
780
+ // 节点会永久显示 failed(README 主流程"失败任务可继续"被破坏)。
781
+ if (hasInitial) return (s.status === 'skipped' || s.status === 'failed') ? { ...s, status: 'pending', sessionIds: [], startedAt: undefined, finishedAt: undefined } : s;
782
+ return { ...s, status: 'pending', sessionIds: [], startedAt: undefined, finishedAt: undefined };
783
+ });
784
+ t.status = 'running';
785
+ t.startedAt = nowIso();
786
+ t.error = undefined;
787
+ await store.saveTask(t);
788
+ try {
789
+ const { runId } = await bridge.startTask(t, { initialResults });
790
+ sendJson(c.res, 200, { task: t, runId, mode });
791
+ } catch (e) {
792
+ await store.mutateTask(c.params.id, (tt) => {
793
+ tt.status = 'failed';
794
+ tt.error = `继续失败:${e instanceof Error ? e.message : String(e)}`;
795
+ tt.finishedAt = nowIso();
796
+ }).catch(() => {});
797
+ sendJson(c.res, 500, { error: e instanceof Error ? e.message : String(e) });
798
+ }
799
+ }],
800
+ // 人工节点决策:按节点去向(routes 多去向 / 兼容 approve→通过、reject→驳回)
801
+ ['POST', '/tasks/:id/decide', async (c) => {
802
+ const body = await readBody(c.req);
803
+ const t = await store.getTask(c.params.id);
804
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
805
+ if (t.status !== 'waiting-human' || !t.humanState) return sendJson(c.res, 400, { error: 'task not waiting for human' });
806
+ const { humanId, results } = t.humanState;
807
+ const humanNode = (t.workflowSnapshot?.nodes || []).find((n) => n.id === humanId);
808
+ // 去向:routes 优先;兼容旧 approveTo/rejectTo(通过/驳回)
809
+ const routes = (humanNode && Array.isArray(humanNode.routes) && humanNode.routes.length)
810
+ ? humanNode.routes
811
+ : [].concat(humanNode?.approveTo ? [{ label: '通过', to: humanNode.approveTo }] : [], humanNode?.rejectTo ? [{ label: '驳回', to: humanNode.rejectTo }] : []);
812
+ let decision = body.decision;
813
+ if (decision === 'approve') decision = '通过';
814
+ else if (decision === 'reject') decision = '驳回';
815
+ if (!routes.some((r) => r.label === decision)) {
816
+ return sendJson(c.res, 400, { error: `decision 必须是 ${routes.map((r) => r.label).join(' / ') || '未配置去向'}` });
817
+ }
818
+ const feedback = typeof body.feedback === 'string' ? body.feedback.trim() : '';
819
+ // 记录决策历史,便于事后追溯(第几轮审批、结论、意见)
820
+ t.decisions = [...(t.decisions || []), { humanId, decision, feedback, at: nowIso() }];
821
+ t.status = 'running';
822
+ t.humanState = null;
823
+ t.error = undefined;
824
+ await store.saveTask(t);
825
+ try {
826
+ const { runId } = await bridge.startTask(t, {
827
+ decision: { humanId, value: decision, ...(feedback ? { feedback } : {}) },
828
+ initialResults: results,
829
+ });
830
+ sendJson(c.res, 200, { task: t, runId });
831
+ } catch (e) {
832
+ // humanState 已清、决策已记录——无法回滚,但至少要标记 failed,避免任务卡 running
833
+ await store.mutateTask(c.params.id, (tt) => {
834
+ tt.status = 'failed';
835
+ tt.error = `决策后继续失败:${e instanceof Error ? e.message : String(e)}`;
836
+ tt.finishedAt = nowIso();
837
+ }).catch(() => {});
838
+ sendJson(c.res, 500, { error: e instanceof Error ? e.message : String(e) });
839
+ }
840
+ }],
841
+ ['DELETE', '/tasks/:id', async (c) => {
842
+ // 运行中的任务先取消,避免 finalizeTask 在目录删除后写回失败 / parent 滞留
843
+ bridge.cancelTask(c.params.id);
844
+ await store.deleteTask(c.params.id);
845
+ sendJson(c.res, 200, { ok: true });
846
+ }],
847
+ // 阶段产物(断点数据,供 UI 展示)
848
+ ['GET', '/tasks/:id/stages/:stageId', async (c) => {
849
+ const data = await store.readStage(c.params.id, c.params.stageId);
850
+ sendJson(c.res, 200, data === null ? { data: null } : { data });
851
+ }],
852
+ // 任务产出物(编排器 results 落盘结果,供详情页「产物」区展示)
853
+ ['GET', '/tasks/:id/results', async (c) => {
854
+ const results = await store.readResults(c.params.id);
855
+ sendJson(c.res, 200, results === null ? { results: null } : { results });
856
+ }],
857
+ // 读取任务工作目录下的文件(供详情页查看节点产物里的文件内容;限制在 cwd 内 + 大小上限)
858
+ ['GET', '/tasks/:id/file', async (c) => {
859
+ const t = await store.getTask(c.params.id);
860
+ if (!t) return sendJson(c.res, 404, { error: 'task not found' });
861
+ const cwd = t.cwd;
862
+ if (!cwd) return sendJson(c.res, 400, { error: 'task has no cwd' });
863
+ const rel = c.url.searchParams.get('path') || '';
864
+ if (!rel.trim()) return sendJson(c.res, 400, { error: 'path required' });
865
+ const abs = resolve(cwd, rel.trim());
866
+ // 严格路径校验:用 path.relative 判断 abs 是否真的在 cwd 内。
867
+ // (旧的 startsWith 检查在 cwd 为盘符根目录如 C:\ 时会失效,可越权读任意文件。)
868
+ const relCheck = relative(cwd, abs);
869
+ if (relCheck.startsWith('..') || isAbsolute(relCheck)) {
870
+ return sendJson(c.res, 400, { error: 'path outside task cwd' });
871
+ }
872
+ try {
873
+ // symlink/junction 逃逸防护:realpath 解析后再次校验真实路径在 realpath(cwd) 内
874
+ const [realCwd, realAbs] = await Promise.all([realpath(cwd), realpath(abs)]);
875
+ const relReal = relative(realCwd, realAbs);
876
+ if (relReal.startsWith('..') || isAbsolute(relReal)) {
877
+ return sendJson(c.res, 400, { error: 'path escapes task cwd via symlink' });
878
+ }
879
+ const st = await stat(abs);
880
+ if (!st.isFile()) return sendJson(c.res, 400, { error: 'not a file' });
881
+ if (st.size > 200 * 1024) return sendJson(c.res, 413, { error: 'file too large (>200KB)' });
882
+ const content = await readFile(abs, 'utf8');
883
+ sendJson(c.res, 200, { path: abs, content });
884
+ } catch (e) {
885
+ sendJson(c.res, 404, { error: `read failed: ${e instanceof Error ? e.message : String(e)}` });
886
+ }
887
+ }],
888
+ ];
889
+
890
+ const handler = async (req, res) => {
891
+ const url = new URL(req.url ?? '/', 'http://localhost');
892
+ const path = url.pathname.slice(prefix.length) || '/';
893
+ const method = req.method ?? 'GET';
894
+ try {
895
+ for (const [m, pattern, fn] of routes) {
896
+ if (m !== method) continue;
897
+ const match = matchRoute(pattern, path);
898
+ if (!match) continue;
899
+ await fn({ req, res, url, params: match });
900
+ return;
901
+ }
902
+ sendJson(res, 404, { error: `no route for ${method} ${path}` });
903
+ } catch (error) {
904
+ if (!res.headersSent) sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
905
+ else res.end();
906
+ }
907
+ };
908
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix, handler }), 'dsh-knj-workflow: http routes');
909
+ }
910
+
911
+ function matchRoute(pattern, path) {
912
+ const parts = pattern.split('/').filter(Boolean);
913
+ const pathParts = path.split('/').filter(Boolean);
914
+ if (parts.length !== pathParts.length) return null;
915
+ const params = {};
916
+ for (let i = 0; i < parts.length; i++) {
917
+ if (parts[i].startsWith(':')) params[parts[i].slice(1)] = decodeURIComponent(pathParts[i]);
918
+ else if (parts[i] !== pathParts[i]) return null;
919
+ }
920
+ return params;
921
+ }
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // 命令注册:/dev-task
925
+ // ---------------------------------------------------------------------------
926
+ function registerCommands(ctx, store, bridge) {
927
+ const commands = ctx.get('commands');
928
+ if (!commands) return;
929
+
930
+ commands.register({
931
+ name: 'dev-task',
932
+ description: '开发任务编排:/dev-task new <标题> 新建任务;/dev-task list 查看任务;/dev-task status <id> 查看进度;/dev-task wf 列出工作流',
933
+ async execute(line) {
934
+ const raw = (line || '').trim();
935
+ const [cmd, ...rest] = raw.split(/\s+/);
936
+ const arg = rest.join(' ');
937
+ try {
938
+ if (cmd === 'new') {
939
+ if (!arg) return { success: false, text: '用法:/dev-task new <任务标题>' };
940
+ const workflow = (await store.listWorkflows())[0];
941
+ if (!workflow) return { success: false, text: '没有可用的工作流,请先在「开发任务 → 工作流」中创建' };
942
+ const task = {
943
+ id: `task-${Date.now().toString(36)}`,
944
+ title: arg,
945
+ workflowId: workflow.id,
946
+ workflowRevision: workflow.revision || 1,
947
+ workflowSnapshot: workflow,
948
+ status: 'pending',
949
+ stageStates: workflow.nodes.filter((n) => n.type === 'task').map((n) => ({ id: n.id, title: n.title, status: 'pending' })),
950
+ createdAt: nowIso(),
951
+ };
952
+ await store.saveTask(task);
953
+ await bridge.startTask(task);
954
+ task.status = 'running';
955
+ task.startedAt = nowIso();
956
+ await store.saveTask(task);
957
+ return { success: true, text: `已创建并启动任务 ${task.id}(${task.title}),工作流:${workflow.name}。查看进度:/dev-task status ${task.id}` };
958
+ }
959
+ if (cmd === 'list') {
960
+ const tasks = await store.listTasks();
961
+ if (tasks.length === 0) return { success: true, text: '暂无任务。创建:/dev-task new <标题>' };
962
+ const lines = tasks.map((t) => `• ${t.id} [${t.status}] ${t.title}${t.currentStage ? ` → 阶段:${t.currentStage}` : ''}`);
963
+ return { success: true, text: `任务列表(${tasks.length}):\n${lines.join('\n')}` };
964
+ }
965
+ if (cmd === 'status') {
966
+ if (!arg) return { success: false, text: '用法:/dev-task status <任务id>' };
967
+ const t = await store.getTask(arg);
968
+ if (!t) return { success: false, text: `任务 ${arg} 不存在` };
969
+ const steps = (t.stageStates || []).map((s) => `${s.status === 'done' ? '✅' : s.status === 'running' ? '🔵' : '⬜'} ${s.title}`).join(' ');
970
+ return { success: true, text: `${t.id} [${t.status}] ${t.title}\n工作流:${t.workflowId}\n进度:${steps}` };
971
+ }
972
+ if (cmd === 'wf') {
973
+ const list = await store.listWorkflows();
974
+ const lines = list.map((w) => `• ${w.id}:${w.name}(${(w.nodes || []).filter((n) => n.type === 'task').length} 个阶段)`);
975
+ return { success: true, text: `工作流(${list.length}):\n${lines.join('\n')}` };
976
+ }
977
+ return { success: false, text: '未知子命令。用法:/dev-task new|list|status|wf' };
978
+ } catch (e) {
979
+ return { success: false, text: `命令执行失败:${e instanceof Error ? e.message : String(e)}` };
980
+ }
981
+ },
982
+ });
983
+ }
984
+
985
+ // ---------------------------------------------------------------------------
986
+ // 插件入口
987
+ // ---------------------------------------------------------------------------
988
+ export function apply(ctx, config) {
989
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
990
+ const dataRoot = config.dataRoot || join(home, 'dev-orchestrator');
991
+ const store = new DevTaskStore(dataRoot);
992
+
993
+ store.init().then(() => {
994
+ const bridge = new WorkflowBridge(ctx, store, config.orchestratorScript);
995
+ registerRoutes(ctx, store, bridge, config.httpPrefix);
996
+ registerCommands(ctx, store, bridge);
997
+ ctx.logger?.info?.(`dsh-knj-workflow ready @ ${dataRoot}`);
998
+ }).catch((error) => {
999
+ ctx.logger?.warn?.(`dsh-knj-workflow init failed: ${error instanceof Error ? error.message : String(error)}`);
1000
+ });
1001
+ }