dsh-knj-workflow 2026.9.110 → 2026.9.192
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 +22 -5
- package/lib/attachments.js +184 -0
- package/lib/client.js +456 -43
- package/lib/graph.js +70 -0
- package/lib/index.js +361 -27
- package/lib/index.test.js +546 -1
- package/lib/orchestrator.js +6 -1
- package/lib/requirement-import-ui.test.js +593 -0
- package/lib/requirement-routes.test.js +581 -0
- package/lib/requirement.js +86 -0
- package/lib/requirement.test.js +116 -0
- package/lib/ui-regression.test.js +108 -0
- package/lib/uploads.js +157 -0
- package/lib/uploads.test.js +191 -0
- package/lib/workflow-inputs.test.js +104 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
- **工作流管理**:可新增 N 个工作流,每个工作流由多个阶段组成;阶段行为由 `prompt`(提示词)+ 可选 `skill` 定义
|
|
10
10
|
- **模板导入/导出**:工作流列表与图编辑器均支持把模板导出为 `.workflow.json` 文件、从文件导入(跨机器移植);导入后先进入编辑器检查再保存,同 ID 冲突会提示
|
|
11
11
|
- **开发任务**:创建任务并绑定工作流,自动启动执行
|
|
12
|
+
- **需求描述(可选,可长)**:新建任务弹窗里需求描述是**可选项**——不填也能建任务;标题由系统依描述自动生成(首行前 50 字,空则时间戳占位),因此表单没有标题输入框。支持粘贴十万字级长文本;超过 8000 字的完整需求会随任务落盘 `requirement.md`,AI 节点 prompt 只注入头部摘录 + 文件指针(不丢内容、不炸上下文)。任务详情可展开查看/复制完整需求,看板搜索支持搜需求正文
|
|
13
|
+
- **内置通用附件(无需任何声明,推荐)**:新建任务弹窗里永远有一个「附件(可选)」区,可一次多选若干本地文件(docx / md / 表格都行),也能逐个移除。Host 在**启动前**把它们落到 `<工作目录>/.knj-inputs/<任务 id>/`,并把**换行拼接的绝对路径串**写进 `inputs.附件`——节点 prompt 里写 `${inputs.附件}` 拿到清单,**自己写提示词解析文档**。同名文件自动加序号(`2-名字.ext`)不覆盖;暂存失效时**拒绝启动**并报出文件名(不静默少文件)。支持最多 20 个附件,单文件上限 32MB。详见 [design/task-attachments.md](./design/task-attachments.md)
|
|
14
|
+
- **文件类型任务输入参数(声明式,只传路径)**:编辑器里的「输入参数」声明参数(名称 / 显示名 / 必填 / 类型:文本|文件)。**文件**类型的参数在新建任务时选文件:上传后由 Host 在**启动前**落到 `<工作目录>/.knj-inputs/<任务 id>/`,并把**绝对路径**写进 `inputs.<参数名>`——节点用 `${inputs.参数名}` 拿到路径,**文档解析由节点自己写提示词完成**(不注入文件内容,也不塞进需求描述)。该目录自带 `.gitignore`(内容 `*`),默认不进用户版本库;单文件上限 32MB;未提交的上传暂存在 `~/.dsh/dev-orchestrator/uploads/`,24 小时后自动清理
|
|
15
|
+
- **文件入参还支持「按路径引用」(给定时任务用)**:同一参数可以只给一个路径(`pathInputs`):相对路径按任务工作目录解析成**绝对路径**,且**启动前校验文件确实存在**——不存在就拒绝启动并报出参数名与路径(路径引用意味着每次触发读**当时**的文件,不做快照)。必填文件入参由「上传」或「路径」**任一种**满足;`knjWorkflowScheduler.listWorkflows()` 返回各工作流声明的入参,调用方据此渲染字段
|
|
12
16
|
- **可视化进度**:右侧栏「开发任务」tab 横向步骤条展示每个阶段状态(待执行/运行中/完成/失败),实时刷新
|
|
13
17
|
- **阶段重跑 / 继续**:失败的阶段可点击「重跑」,暂停/失败的任务可点击「继续」——基于断点持久化(每阶段结果落盘)
|
|
14
18
|
- **斜杠命令**:`/dev-task new <标题>`、`/dev-task list`、`/dev-task status <id>`、`/dev-task wf`
|
|
@@ -37,9 +41,19 @@
|
|
|
37
41
|
└─────────────────────────────────────────────────────────┘
|
|
38
42
|
|
|
39
43
|
数据目录(默认):~/.dsh/dev-orchestrator/
|
|
40
|
-
workflows.json # N
|
|
41
|
-
|
|
44
|
+
workflows.json # N 个工作流定义(含 inputs 声明:type = text | file)
|
|
45
|
+
uploads/<uploadId>/<文件> # 文件入参的**暂存**:表单打开时任务还没建,先落这里
|
|
46
|
+
# (24h 自动清理;启动前物化进工作区并从暂存删除)
|
|
47
|
+
tasks/<taskId>/task.json # 任务元数据 + 阶段状态(需求描述全文存这里)
|
|
48
|
+
tasks/<taskId>/requirement.md # 仅当需求超 8000 字:完整需求全文,**启动前**统一写出
|
|
49
|
+
# (落盘在 WorkflowBridge.startTask 完成,HTTP 路由 / 调度器 /
|
|
50
|
+
# /dev-task 命令 / 续跑 rerun+resume 全部入口都覆盖)
|
|
42
51
|
tasks/<taskId>/stages/<stageId>.json # 阶段断点产物
|
|
52
|
+
|
|
53
|
+
工作目录内(文件入参/附件的最终位置,按任务隔离):
|
|
54
|
+
<cwd>/.knj-inputs/.gitignore # 内容 `*`:上传文件默认不进版本库
|
|
55
|
+
<cwd>/.knj-inputs/<taskId>/<文件> # 声明式入参:节点用 ${inputs.参数名} 拿路径
|
|
56
|
+
# 内置附件:节点用 ${inputs.附件} 拿换行拼接的路径串
|
|
43
57
|
```
|
|
44
58
|
|
|
45
59
|
## 安装
|
|
@@ -64,8 +78,9 @@ dsh plugin --profile web add dsh-knj-workflow
|
|
|
64
78
|
| GET | `/devtask/health` | 健康检查 |
|
|
65
79
|
| GET/POST/PUT | `/devtask/workflows` | 工作流列表 / 保存 |
|
|
66
80
|
| DELETE | `/devtask/workflows/:id` | 删除工作流 |
|
|
67
|
-
| GET/POST | `/devtask/tasks` | 任务列表 /
|
|
68
|
-
| GET | `/devtask/tasks/:id` |
|
|
81
|
+
| GET/POST | `/devtask/tasks` | 任务列表 / 创建(绑定工作流并启动)· GET 支持 `?archived=1` 与 `?q=<关键词>`(Host 侧按标题/需求正文/工作流 id 全文过滤;列表项不含需求正文,只给 `descriptionChars`)· POST 可带 `fileInputs: { <参数名>: { uploadId, name } }`(必填文件入参缺失 → 400) |
|
|
82
|
+
| GET | `/devtask/tasks/:id` | 任务详情(含阶段状态与需求描述全文) |
|
|
83
|
+
| POST | `/devtask/uploads` | 文件入参上传暂存:`{ name, dataBase64 }` → `{ uploadId, name, size }`;文件名先消毒成 basename(不可用 → `invalid-name`),单文件上限 32MB(`file-too-large`),失败 400 + `{ error, code }` |
|
|
69
84
|
| POST | `/devtask/tasks/:id/start` `/cancel` `/resume` | 启动 / 取消 / 继续 |
|
|
70
85
|
| POST | `/devtask/tasks/:id/rerun-stage` | 重跑指定阶段 |
|
|
71
86
|
| GET | `/devtask/tasks/:id/stages/:stageId` | 阶段断点产物 |
|
|
@@ -87,6 +102,8 @@ dsh plugin --profile web add dsh-knj-workflow
|
|
|
87
102
|
## 编排器参数
|
|
88
103
|
|
|
89
104
|
- `args.config`:工作流定义(`{ name, description, stages: [...] }`)
|
|
90
|
-
- `args.task`:任务元数据(`{ id, title, taskDir }
|
|
105
|
+
- `args.task`:任务元数据(`{ id, title, taskDir, inputDescription }`)——`inputDescription` 由 Host 预计算:
|
|
106
|
+
需求描述未超阈值时是全文,超阈值时是「前 6000 字 + 省略说明 + 完整需求文件路径」。
|
|
107
|
+
脚本沙箱内没有 `require`,**不要在脚本里重新实现这份阈值逻辑**(与 `lib/requirement.js` 会漂移)
|
|
91
108
|
- `args.resumeFrom`:从某阶段继续(之前阶段读缓存)
|
|
92
109
|
- `args.rerunStage`:只重跑某阶段(其余读缓存)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置通用附件(Host 端,零依赖)
|
|
3
|
+
*
|
|
4
|
+
* 为什么不做「声明式」:文件入参(lib/uploads.js + graph.inputs[type=file])要求先在
|
|
5
|
+
* 工作流编辑器里声明参数,实机上用户的 7 个工作流全部零声明 —— 于是「新建任务」里
|
|
6
|
+
* 压根没有选文件的地方,用户第一反应是「文件没有看到在哪里选择」。附件的真实用途是
|
|
7
|
+
* 「把一份/几份参考资料丢给节点,让节点自己解析」(docx / md 从本地直接选),它是通用
|
|
8
|
+
* 能力,不该先回编辑器声明一遍才看得见。所以这里由 Host 兜底:表单永远显示附件区,
|
|
9
|
+
* 不需要任何声明。
|
|
10
|
+
*
|
|
11
|
+
* 契约(与 orchestrator/节点 prompt 的对应关系):
|
|
12
|
+
* - 附件统一灌进 `inputs.附件`,值是**换行拼接的绝对路径串**(`\n` 分隔)。
|
|
13
|
+
* 节点 prompt 写 `${inputs.附件}` 即可拿到全部路径;路径必须绝对,因为节点
|
|
14
|
+
* subagent 的工作目录可能与任务 cwd 不同。
|
|
15
|
+
* - 与声明式文件入参的差别只在「键名固定」,物化机制完全共用
|
|
16
|
+
* (暂存 → 启动前落进 `<cwd>/.knj-inputs/<taskId>/` → 清暂存)。
|
|
17
|
+
*
|
|
18
|
+
* 单点 owner 仍是 `WorkflowBridge.startTask`:路由、调度器服务(不走路由)、
|
|
19
|
+
* 命令、resume/rerun 都经过它,所以物化只在这里发生一次。
|
|
20
|
+
*
|
|
21
|
+
* 安全(本插件栽过一次 task id 路径穿越,同一类错误不重复):
|
|
22
|
+
* - 文件名一律 `sanitizeFileName` 成 basename 再拼路径;
|
|
23
|
+
* - 即使 sanitize 被绕过(如 Windows 上 `..\\x` 之类),拼完还要 `assertInsideDir`
|
|
24
|
+
* 兜一次:目标必须真的落在任务输入目录内,否则拒绝启动。
|
|
25
|
+
*/
|
|
26
|
+
import { copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join, isAbsolute, relative, resolve } from 'node:path';
|
|
29
|
+
import { sanitizeFileName, WORKSPACE_INPUT_DIR } from './uploads.js';
|
|
30
|
+
|
|
31
|
+
/** 附件的固定输入键:节点 prompt 用 `${inputs.附件}` 引用 */
|
|
32
|
+
export const ATTACHMENTS_INPUT_NAME = '附件';
|
|
33
|
+
/** 单次任务附件数量上限(防一次丢进来几百个文件把 prompt 与磁盘压垮) */
|
|
34
|
+
export const MAX_ATTACHMENTS = 20;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 从请求体里规范化附件引用(只取 uploadId + name,绝不含文件内容)。
|
|
38
|
+
*
|
|
39
|
+
* 形状不合法(不是数组)→ 返回空数组而不是抛错:附件是可选的通用能力,请求体里塞了
|
|
40
|
+
* 垃圾字段时不该让整个建任务失败。但**数组里单个条目缺 uploadId** 会原样保留,
|
|
41
|
+
* 由 normalize 阶段统一拒绝(避免"看起来传了 3 个文件,实际只物化 2 个"的静默丢件)。
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} raw
|
|
44
|
+
* @returns {Array<{uploadId: string, name?: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeAttachmentRefs(raw) {
|
|
47
|
+
if (!Array.isArray(raw)) return [];
|
|
48
|
+
return raw
|
|
49
|
+
.filter((x) => x && typeof x === 'object' && !Array.isArray(x))
|
|
50
|
+
.map((x) => ({
|
|
51
|
+
uploadId: typeof x.uploadId === 'string' ? x.uploadId : '',
|
|
52
|
+
...(typeof x.name === 'string' && x.name ? { name: x.name } : {}),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 校验附件引用:数量上限 + 每条必须有 uploadId。
|
|
58
|
+
* @param {Array<{uploadId: string, name?: string}>} refs
|
|
59
|
+
* @returns {string|null} 错误信息(null = 通过)
|
|
60
|
+
*/
|
|
61
|
+
export function describeAttachmentsError(refs) {
|
|
62
|
+
if (refs.length > MAX_ATTACHMENTS) {
|
|
63
|
+
return `附件最多 ${MAX_ATTACHMENTS} 个(收到 ${refs.length} 个)`;
|
|
64
|
+
}
|
|
65
|
+
const bad = refs.findIndex((x) => !x.uploadId);
|
|
66
|
+
if (bad >= 0) return `附件第 ${bad + 1} 个缺少 uploadId(请重新选择文件后再提交)`;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 目标路径必须真在 `dir` 内(防 sanitize 被绕过后的路径穿越)。
|
|
71
|
+
* 导出供测试直接打靶:上游名字都要先过 `sanitizeFileName`,经由 public 入口喂不进
|
|
72
|
+
* 越界名,只能直接验证这一层——否则这道护栏就是没人验证过的死代码。 */
|
|
73
|
+
export function assertInsideDir(dir, dest) {
|
|
74
|
+
const rel = relative(resolve(dir), resolve(dest));
|
|
75
|
+
// rel 为空 → dest 就是 dir 本身;以 .. 开头 / 是绝对路径 / 带盘符 → 都在 dir 之外
|
|
76
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) {
|
|
77
|
+
throw new Error(`附件目标路径越界:${dest}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把已暂存的附件物化进 `<cwd>/.knj-inputs/<taskId>/`,并把换行拼接的绝对路径写进
|
|
83
|
+
* `task.inputs.附件`。
|
|
84
|
+
*
|
|
85
|
+
* 触发条件:`task.pendingAttachments` 非空。幂等:物化成功后清掉 pending,重复调用直接返回
|
|
86
|
+
* (resume/rerun 会再次经过 startTask)。
|
|
87
|
+
*
|
|
88
|
+
* 失败语义(关键):附件是「用户明确要交给节点的东西」,少一个文件就可能让节点基于不完整
|
|
89
|
+
* 材料干活——所以任一条暂存失效都**拒绝启动**并报出文件名,而不是跳过。失败时回滚本次
|
|
90
|
+
* 已写入的文件,避免重试时残留半套材料、同名文件被误判为冲突。
|
|
91
|
+
*
|
|
92
|
+
* @param {import('./uploads.js').UploadStore|undefined} uploads
|
|
93
|
+
* @param {{id: string, cwd?: string, inputs?: Record<string, unknown>, pendingAttachments?: unknown}} task
|
|
94
|
+
*/
|
|
95
|
+
export async function materializeAttachments(uploads, task) {
|
|
96
|
+
const refs = normalizeAttachmentRefs(task?.pendingAttachments);
|
|
97
|
+
if (refs.length === 0) {
|
|
98
|
+
// 没有附件时**绝不**创建 inputs.附件 键:否则节点把空串当路径用(会真的去读 "")
|
|
99
|
+
if (task && 'pendingAttachments' in task) delete task.pendingAttachments;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!uploads) throw new Error('附件需要上传服务,但当前上下文没有 uploads(请重启 dsh web 后重试)');
|
|
103
|
+
if (!task.cwd) throw new Error('附件需要任务工作目录(cwd),当前为空:请在新建任务时选择工作目录');
|
|
104
|
+
|
|
105
|
+
const dir = join(task.cwd, WORKSPACE_INPUT_DIR, task.id);
|
|
106
|
+
await mkdir(dir, { recursive: true });
|
|
107
|
+
// 与声明式文件入参同一个忽略文件:附件是任务输入产物,不该被提交
|
|
108
|
+
const gitignore = join(task.cwd, WORKSPACE_INPUT_DIR, '.gitignore');
|
|
109
|
+
if (!existsSync(gitignore)) {
|
|
110
|
+
await writeFile(gitignore, '*\n', 'utf8').catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const written = [];
|
|
114
|
+
const paths = [];
|
|
115
|
+
const reusable = task.attachmentsAreReusable === true;
|
|
116
|
+
// 上次运行物化过的名字(本任务自己的产物)。用它区分两种"目标已存在":
|
|
117
|
+
// - 上次运行留下的同名文件 → 是同一个附件,本次**覆盖**(否则反复触发会堆出 2-x、3-x…)
|
|
118
|
+
// - 其他来源的同名文件(本次用户另选的一份)→ 加序号,**绝不覆盖**别人的东西
|
|
119
|
+
const marker = join(dir, '.knj-materialized-names.json');
|
|
120
|
+
const previousNames = new Set(readMaterializedNames(marker));
|
|
121
|
+
const seenThisRun = new Set();
|
|
122
|
+
try {
|
|
123
|
+
for (const ref of refs) {
|
|
124
|
+
const staged = await uploads.resolve(ref.uploadId);
|
|
125
|
+
if (!staged) {
|
|
126
|
+
const label = sanitizeFileName(ref.name) || ref.name || ref.uploadId;
|
|
127
|
+
throw new Error(`附件 ${label} 的上传已失效(暂存被清理或未上传成功):请重新选择文件后再启动`);
|
|
128
|
+
}
|
|
129
|
+
const safeName = sanitizeFileName(staged.name);
|
|
130
|
+
if (!safeName) throw new Error(`附件文件名不可用:${String(staged.name ?? '')}`);
|
|
131
|
+
|
|
132
|
+
let dest = join(dir, safeName);
|
|
133
|
+
assertInsideDir(dir, dest);
|
|
134
|
+
const isOwnPrevious = previousNames.has(safeName) && !seenThisRun.has(safeName);
|
|
135
|
+
if (existsSync(dest) && !isOwnPrevious) {
|
|
136
|
+
// 同名冲突(用户这次另选了一份同名文件):加序号前缀,**绝不覆盖**先前的材料
|
|
137
|
+
let index = 2;
|
|
138
|
+
let candidate = join(dir, `${index}-${safeName}`);
|
|
139
|
+
while (existsSync(candidate)) {
|
|
140
|
+
index += 1;
|
|
141
|
+
candidate = join(dir, `${index}-${safeName}`);
|
|
142
|
+
assertInsideDir(dir, candidate);
|
|
143
|
+
}
|
|
144
|
+
dest = candidate;
|
|
145
|
+
}
|
|
146
|
+
// copy + 清理暂存,而不是 rename:暂存在 ~/.dsh(常在 C:),工作区可能在别的盘,
|
|
147
|
+
// 跨盘 rename 在 Windows 上会 EXDEV 失败。
|
|
148
|
+
await copyFile(staged.path, dest);
|
|
149
|
+
// 校验真的落到位(copyFile 成功但目标不可读的异常场景下宁可报错)
|
|
150
|
+
const info = await stat(dest).catch(() => null);
|
|
151
|
+
if (!info || !info.isFile()) throw new Error(`附件 ${safeName} 写入失败:${dest}`);
|
|
152
|
+
written.push(dest);
|
|
153
|
+
seenThisRun.add(safeName);
|
|
154
|
+
paths.push(dest);
|
|
155
|
+
// reusable(调度器附件):暂存由调用方每次触发重新准备,这里**不能**丢弃,
|
|
156
|
+
// 否则第二次触发就报"暂存已失效"。一次性交接(表单)保持默认丢弃语义。
|
|
157
|
+
if (!reusable) await uploads.discard(staged.uploadId);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// 回滚本次已写入的文件:不留半套材料
|
|
161
|
+
for (const p of written) await rm(p, { force: true }).catch(() => {});
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 记下"本任务物化过哪些名字",供下次运行区分"自己的旧文件"与"用户新选的同名文件"
|
|
166
|
+
await writeFile(marker, JSON.stringify([...new Set([...previousNames, ...seenThisRun])]), 'utf8').catch(() => {});
|
|
167
|
+
|
|
168
|
+
const inputs = { ...(task.inputs && typeof task.inputs === 'object' ? task.inputs : {}) };
|
|
169
|
+
inputs[ATTACHMENTS_INPUT_NAME] = paths.join('\n');
|
|
170
|
+
task.inputs = inputs;
|
|
171
|
+
// reusable 时保留 pendingAttachments:下一次触发还要用它(配合每次重新 stageAttachments)
|
|
172
|
+
if (!reusable) delete task.pendingAttachments;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 读取"本任务物化过的文件名"标记;缺失或损坏时返回空数组(不因此中断启动)。 */
|
|
176
|
+
function readMaterializedNames(markerPath) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = readFileSync(markerPath, 'utf8');
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : [];
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|