@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0

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.
Files changed (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,1059 @@
1
+ /**
2
+ * 路由族:项目 / 用户偏好 / 目录浏览 / 项目文件 / 监控状态 / 案例库与 bug 报告(M3 读路径)
3
+ *
4
+ * 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头;必须与 `server.py:_route_get` 同序):
5
+ * - `GET /api/projects` → `_api_list_projects`
6
+ * - `GET /api/projects/dup_check` → `_api_dup_check`
7
+ * - `GET /api/fs/browse` → `_api_fs_browse`
8
+ * - `GET /api/prefs` → `_api_prefs_get`
9
+ * - `GET /api/state` → `_api_state`
10
+ * - `GET /api/stream` → `_stream`(SSE,StateCache 指纹驱动)
11
+ * - `GET /api/projects/<id>` → `_api_get_project`
12
+ * - `GET /api/projects/<id>/file` → `_api_project_file`(`raw=1` 走 `_send_project_file_raw`)
13
+ * - `GET /api/projects/<id>/bugs` → `_api_list_bugs`
14
+ * - `GET /api/projects/<id>/bugs/<dir>` → `_api_get_bug`
15
+ * - `POST /api/fs/mkdir` → `_api_fs_mkdir`(2357)(M6.9 写路径)
16
+ * - `POST /api/projects` → `_api_create_project`(2212)
17
+ * - `POST /api/prefs` → `_api_prefs_set`(2497)
18
+ * - `POST /api/projects/<id>/(archive|unarchive)` → `_api_archive_project`(2462)
19
+ * - `POST /api/projects/<id>/bugs/<dir>/reject` → `_api_reject_bug`(3997)(M6-6)
20
+ * - `PATCH /api/projects/<id>` → `_api_update_project`(2268)
21
+ * - `DELETE /api/projects/<id>` → `_api_delete_project`(2321)
22
+ *
23
+ * 写路径的顺序=Python 的 `do_POST`(1050→1102) / `do_PATCH`(1231) / `do_DELETE`(1273) 链。
24
+ * RAG/内置资产/agent 扫描写端点属其他族(`admin.js`/`assets.js`/`agents.js`)。
25
+ *
26
+ * ── 三处「看着能简化、其实一改就分叉」的地方(逐条对着 server.py 核过) ─────────────
27
+ * 1. **`_owned_project` 与 `ctx.ownedProject` 不是同一件东西**:Python 的 `_owned_project`
28
+ * **只返回行、不写响应**,404 由各调用点自己发。`ctx.ownedProject` 则「越权即回 404 并返回
29
+ * null」(这是 M3 给其他族的统一契约,省得每处重写)。于是:
30
+ * - `_api_get_project`/`_api_project_file`/`_api_list_bugs`/`_api_get_bug` 的 Python 形态是
31
+ * 「拿到 None → 自己 404」,与 `ctx.ownedProject` **正好等价**(用它,然后直接 return);
32
+ * - `_resolve_project`(`/api/state`、`/api/stream` 用)**不同**:它拿不到越权项目会
33
+ * **继续回落**到「当前用户第一个项目」并最终 200。若在这里误用 `ctx.ownedProject`,
34
+ * 会先发一个 404 再发一个 200(Python 侧只有一个 200)⇒ 故本文件自带
35
+ * `ownedProjectQuiet()`(只查行、不写响应),语义与 Python 的 `_owned_project` 逐字对应。
36
+ * 2. **`_api_dup_check` 的 `norm()` 是 `os.path.normpath`**:POSIX 下 `//a/b` 保留双斜杠,
37
+ * `path.normalize` 会压成 `/a/b` ⇒ 走 `lib.pyNormpath`(单测钉住这几个形态)。
38
+ * 3. **`_api_get_bug` 的 `os.path.join(row["bug_dir"], bug_dir)`**:`bug_dir` 来自 URL 且先
39
+ * `unquote`,**可能是绝对路径**(`bugs/%2Ftmp`)——Python 的 `join` 遇到绝对段会**丢弃**前面的
40
+ * 目录,Node 的 `path.join` 只会拼接 ⇒ 必须 `lib.pyJoin`。这是「照抄既有行为」而不是加固:
41
+ * 两侧保持同一行为才有对拍价值(该行为本身已在报告中登记,未擅自改安全语义)。
42
+ *
43
+ * ── 错误正文的两种字节形态(本族两条都用到了) ───────────────────────────────────
44
+ * Python 的 `_respond(code, body)` 第二参有两种写法,字节形态不同,Node 侧必须逐点跟住:
45
+ * - **硬编码字节串**(本项目/bug/prefs 的 404):`b'{"error":"project not found"}'` ⇒ 紧凑
46
+ * ⇒ 用 `ctx.error(...)`;
47
+ * - **`json.dumps({"error": ...})`**(fs/browse 的 400、project_file 的四档、raw 的 404/413)
48
+ * ⇒ 冒号后带空格 ⇒ 用 `ctx.errorSpaced(...)`。
49
+ * 判法:去 `server.py` 看该处 `_respond` 的第二参是字节串字面量还是 `json.dumps(...)`。
50
+ *
51
+ * ── 写路径(M6.9)的三条契约 ─────────────────────────────────────────────────────
52
+ * 1. **读体的时机**是契约,不是「想读就读」。Python 在 `do_POST:1048` 给**每个** POST
53
+ * 统一读体(`logout`/`change_password` 排在那行之前,故那两条不许读);`do_PATCH:1238`
54
+ * 在**调用 handler 之前**读(`self._api_update_project(..., self._read_json() or {})`);
55
+ * `do_DELETE` 全程不读,只有 `_api_delete_project` 内部在**归档闸之后**才读。
56
+ * `ctx.readBody()` 是记忆化的,但**没被读掉的请求体会留在 socket 上**污染同一条 keep-alive
57
+ * 连接的下一个请求(`server.js` 只在未命中路由时补读)⇒ 必须逐端点照 Python 的位置读:
58
+ * - `POST /api/projects`、`/api/prefs`、`/api/fs/mkdir`、`/archive|/unarchive`:**一进
59
+ * handler 就读**(对应 `do_POST:1048` 在读体之后才分派);
60
+ * - `PATCH /api/projects/<id>`:**在 `ctx.ownedProject` 之前读**(404 档也要读掉);
61
+ * - `DELETE /api/projects/<id>`:**在归档闸之后读**(未归档的 400 档**不读体**)。
62
+ * 2. **错误体的两种形态**见上一节;本节的 400 全部是 `json.dumps` 口径 ⇒ `ctx.errorSpaced`,
63
+ * 而 prefs 的两条 400 与 404 是硬编码字节串 ⇒ `ctx.error`。成功体里
64
+ * `{"ok":true}` 是字面量 ⇒ `ctx.text`;`{"id": N}` 与 fs/mkdir 的 data 是 `json.dumps`
65
+ * ⇒ `ctx.json`。混用一处就差一个空格。
66
+ * 3. **`OSError` 的文案要逐字节相同**(`工作目录不可用: [Errno 17] File exists: '<path>'`):
67
+ * Python 的 `str(e)` 形态是 `[Errno {errno}] {os.strerror(errno)}: '{filename}'`,而 Node
68
+ * 的 `util.getSystemErrorMessage()` 给的是 libuv 的措辞(`file already exists` 小写)⇒
69
+ * 本文件自带 `PY_STRERROR` 实测表 + `pyOsErrorText()`(见该函数注释)。
70
+ *
71
+ * ── 与 Python 的一处**有意**差异 ─────────────────────────────────────────────────
72
+ * `/api/stream` 在「当前用户一个项目都没有」时,Python 的 `_stream_note` 写完
73
+ * `data: {"error":"no project"}\n\n` **不断开连接**(响应既无 Content-Length 也非 chunked,
74
+ * 客户端只能靠自己主动断开)。Node 侧写完即 `end()`:客户端可观察的帧序列完全一致,
75
+ * 差别只在「服务端是否保持这条半开连接」。保持半开会让 Node 侧每请求漏一个 socket。
76
+ */
77
+ import fs from 'node:fs';
78
+ import os from 'node:os';
79
+ import path from 'node:path';
80
+ import util from 'node:util';
81
+
82
+ import * as dbmod from '../lib/db.js';
83
+ import { listTasks, nowStr } from '../lib/db.js';
84
+ import {
85
+ FILE_RAW_MAX, PY_WS, bugCases, findCaseDirs, fsBrowse, fsMkdir, isDir, normalizeProjectName,
86
+ parseFields, parseStatusMd, pathLastSegment, pyBasename, pyIntOr, pyIsDigits, pyJoin, pyMtimeInt,
87
+ pyNormpath, pyRelpath, pyStrip, readText, readTextPreview, resolveProjectFile, setMdField,
88
+ } from '../lib/lib.js';
89
+ import { bugLastTask, listProjectBugs, pyJsonDumpsSorted } from '../lib/casescan.js';
90
+ import { pyJsonDumps } from '../lib/pyjson.js';
91
+ import { quote, unquote } from '../lib/query.js';
92
+ import * as runner from '../lib/runner.js';
93
+ import { normalizeEffort, normalizePermissionMode } from '../lib/servercaps.js';
94
+
95
+ /** bug 报告目录名:`20260829_0635_FS_cli_config_load`(标题取 `_FS_` 之后那段) */
96
+ const BUG_DIR_RE = /^\d{8}_\d{4}_([^\n]+)$/;
97
+
98
+ /** `(?m)^## 拒绝记录\s*$`(server.py:4036):`\s` 取 Python 空白集(PY_WS 与全仓同源) */
99
+ const REJECT_SECTION_RE = new RegExp(`^## 拒绝记录${PY_WS}*$`, 'm');
100
+
101
+ /**
102
+ * `_append_reject_record`(4030):把拒绝理由追加到 bug_report.md 的「拒绝记录」小节。
103
+ *
104
+ * 三个必须照抄的点:① `read_text` 的通用换行归一(`\r\n`→`\n`)发生在正则之前;
105
+ * ② 正则里的 `\s*` 是**贪婪**的,会吃掉小节标题后的空行 ⇒ `m.end()` 落在空行之后,
106
+ * 拼回去时 `rest` 要再 `lstrip("\n")`;③ 没有该小节时 `rstrip("\n")` 后新建。
107
+ * 写失败(OSError)**整体吞掉**——拒绝理由写不进去也不该让整条端点半途失败。
108
+ */
109
+ function appendRejectRecord(reportPath, reason) {
110
+ const content = readText(reportPath);
111
+ if (!content) return;
112
+ const line = `- ${nowStr()} ${reason}`;
113
+ const m = REJECT_SECTION_RE.exec(content);
114
+ let text;
115
+ if (m) {
116
+ const rest = content.slice(m.index + m[0].length).replace(/^\n+/, '');
117
+ text = content.slice(0, m.index + m[0].length) + '\n\n' + line + (rest ? `\n${rest}` : '\n');
118
+ } else {
119
+ text = `${content.replace(/\n+$/, '')}\n\n## 拒绝记录\n\n${line}\n`;
120
+ }
121
+ try {
122
+ fs.writeFileSync(reportPath, text, 'utf8');
123
+ } catch {
124
+ // 照 Python:OSError 静默(拒绝记录写不进去不影响主流程)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * `_api_reject_bug`(3997):拒绝 bug 报告 = 标记「已拒绝」+ 追加拒绝记录 + 创建修例任务。
130
+ *
131
+ * 归属 → bug 目录存在性 → 理由非空 → 落盘标记 → 去重(同 bug 已有排队/运行中的 reject 任务
132
+ * 时复用其 id)→ 落库建任务 → `runner.INSTANCE.submit`。响应 `{ok, task_id}` 走 `json.dumps`。
133
+ */
134
+ async function apiRejectBug(ctx, projectId, bugDirRaw) {
135
+ const body = await ctx.readBody(); // Python 在 do_POST:1048 统一读体
136
+ const row = ctx.ownedProject(projectId);
137
+ if (row === null) return;
138
+ const bugDir = pyStripSlashes(pyStrip(unquote(bugDirRaw)));
139
+ const dirPath = pyJoin(row.bug_dir, bugDir);
140
+ if (!isDir(dirPath)) {
141
+ ctx.error(404, 'bug not found'); // 紧凑(Python 是字节串字面量)
142
+ return;
143
+ }
144
+ const reason = pyStrip(pyStrOrEmpty(body.reason));
145
+ if (!reason) {
146
+ ctx.text(400, '{"error":"请填写拒绝理由"}', 'application/json; charset=utf-8');
147
+ return;
148
+ }
149
+ const reportPath = pyJoin(dirPath, 'bug_report.md');
150
+ // 状态 → 已拒绝;理由追加到「拒绝记录」小节(重复拒绝累加,最新在最上)
151
+ setMdField(reportPath, '状态', '已拒绝');
152
+ appendRejectRecord(reportPath, reason);
153
+ let tid = null;
154
+ for (const t of listTasks(row.id, { db: ctx.db })) {
155
+ if (t.task_type !== 'reject' || !['queued', 'running'].includes(t.status)) continue;
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(t.payload || '{}');
159
+ } catch {
160
+ continue; // = Python 的 ValueError 分支:跳过这条任务
161
+ }
162
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
163
+ // 照 Python:`json.loads(...).get` 对非 dict 抛 AttributeError ⇒ 只断这一条连接
164
+ throw new TypeError(`'${Array.isArray(parsed) ? 'list' : typeof parsed}'`
165
+ + " object has no attribute 'get'");
166
+ }
167
+ if (parsed.bug_dir === bugDir) {
168
+ tid = t.id;
169
+ break;
170
+ }
171
+ }
172
+ if (tid === null) {
173
+ const bm = BUG_DIR_RE.exec(bugDir);
174
+ const name = `修例 ${bm ? bm[1] : bugDir}`;
175
+ let casesBase = 0;
176
+ try {
177
+ const state = ctx.stateCache.get(row.id, row.cases_root, row.bug_dir, row.work_dir);
178
+ casesBase = (state.library.stats || {}).total || 0;
179
+ } catch {
180
+ casesBase = 0; // 照 Python:任何异常 ⇒ 基线 0
181
+ }
182
+ tid = dbmod.insertTask(row.id, {
183
+ name,
184
+ autoFix: 0,
185
+ retest: '不复测',
186
+ stopType: 'rounds',
187
+ stopValue: '1',
188
+ taskType: 'reject',
189
+ payload: pyJsonDumps({ bug_dir: bugDir, reason }),
190
+ casesBase,
191
+ }, { db: ctx.db });
192
+ runner.INSTANCE.submit(tid);
193
+ }
194
+ ctx.json(200, { ok: true, task_id: tid });
195
+ }
196
+
197
+ /** Python `str(x or "")`(标量面;容器按 JSON 文本兜底——与既有私有助手同口径) */
198
+ function pyStrOrEmpty(value) {
199
+ if (value === null || value === undefined || value === false || value === 0) return '';
200
+ if (typeof value === 'string') return value;
201
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
202
+ if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '';
203
+ if (typeof value === 'object') return Object.keys(value).length ? JSON.stringify(value) : '';
204
+ return String(value);
205
+ }
206
+
207
+ /** Python `s.strip("/")`:只剥两端的 `/`(本处 bug_dir 来自 `([^/]+)`,理论不触发) */
208
+ function pyStripSlashes(text) {
209
+ return String(text).replace(/^\/+/, '').replace(/\/+$/, '');
210
+ }
211
+
212
+ /**
213
+ * 聚合状态缓存 —— **全进程唯一实例在 `routes/index.js` 的 `STATE_CACHE`**(M4.5 收口):
214
+ * M3 时本文件与 `routes/tasks.js` 各建一个实例(行为等价但同盘扫两遍),现统一由
215
+ * `ctx.stateCache` 下发(= `server.py:Handler.state_cache`,`/api/state`、`/api/stream`
216
+ * 与 `_task_json.new_cases` 三处共用一份)。
217
+ */
218
+
219
+ /** `time.sleep(0.5)` 的 Promise 版(`_stream` 的轮询间隔) */
220
+ function sleep(ms) {
221
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
222
+ }
223
+
224
+ /**
225
+ * `_project_json`:项目行的对外形状(键序与 Python 逐条相同;`archived` 折成布尔)。
226
+ * @param {object} row
227
+ * @returns {object}
228
+ */
229
+ function projectJson(row) {
230
+ return {
231
+ id: row.id,
232
+ name: row.name,
233
+ project_dir: row.project_dir,
234
+ agent_path: row.agent_path,
235
+ work_dir: row.work_dir,
236
+ bug_dir: row.bug_dir,
237
+ guide_text: row.guide_text,
238
+ commit_spec: row.commit_spec,
239
+ deploy_spec: row.deploy_spec,
240
+ env_label: row.env_label,
241
+ model: row.model,
242
+ skill_understand: row.skill_understand,
243
+ skill_deploy: row.skill_deploy,
244
+ skill_commit: row.skill_commit,
245
+ skill_test: row.skill_test,
246
+ skill_cases: row.skill_cases,
247
+ // 项目级会话默认值(2026-10-04):思考等级 / 权限档(空=不指定)
248
+ reasoning_effort: row.reasoning_effort,
249
+ permission_mode: row.permission_mode,
250
+ archived: Boolean(row.archived),
251
+ created_at: row.created_at,
252
+ };
253
+ }
254
+
255
+ /** 看板三列数量的缺省值(`db.board_counts_many` 无该 id 时的回落,同 Python 的字面量) */
256
+ function emptyBoardCounts() {
257
+ return { doing: 0, blocked: 0, review: 0 };
258
+ }
259
+
260
+ /**
261
+ * `_owned_project` 的**静默**版:只查行、不写响应。
262
+ *
263
+ * 为什么需要它:`_resolve_project` 依赖「拿不到就继续回落」这一副作用(见文件头 §1),
264
+ * 用会发 404 的 `ctx.ownedProject` 会多出一个响应。
265
+ * @param {object} ctx
266
+ * @param {number} projectId
267
+ * @returns {object|null}
268
+ */
269
+ function ownedProjectQuiet(ctx, projectId) {
270
+ const row = dbmod.getProject(projectId, { db: ctx.db });
271
+ if (row === null || row === undefined || row.user_id !== ctx.user.id) return null;
272
+ return row;
273
+ }
274
+
275
+ /**
276
+ * `_resolve_project`:按 `?project=<id>` 取当前用户的项目;缺省(或该 id 不可见)取当前用户
277
+ * 第一个项目(列表按 id 升序 ⇒ 「第一个」= 最小 id)。
278
+ * @param {object} ctx
279
+ * @returns {object|null}
280
+ */
281
+ function resolveProject(ctx) {
282
+ const pid = ctx.q('project', '');
283
+ if (pyIsDigits(pid)) {
284
+ const row = ownedProjectQuiet(ctx, pyIntOr(pid, 0));
285
+ if (row) return row;
286
+ }
287
+ const rows = dbmod.listProjects(ctx.user.id, { db: ctx.db });
288
+ return rows.length ? rows[0] : null;
289
+ }
290
+
291
+ /**
292
+ * `_send_project_file_raw`:原文(新标签打开/二进制下载):文本按 text/plain,二进制按附件下载。
293
+ *
294
+ * 为什么不用 `ctx.file()`:`sendFile` 缺三样 Python 侧有的东西 —— `X-Content-Type-Options:
295
+ * nosniff`、二进制的 `Content-Disposition`、以及「超 32MiB 回 413」;且它把整个文件读进内存,
296
+ * 而 Python 是 64KB 分块流式写。故这里直接写 `ctx.res`(逐条对齐 Python 的头与分块)。
297
+ * @param {object} ctx
298
+ * @param {string} filePath
299
+ */
300
+ function sendProjectFileRaw(ctx, filePath) {
301
+ let size;
302
+ try {
303
+ size = fs.statSync(filePath).size;
304
+ } catch {
305
+ ctx.errorSpaced(404, '文件不存在或不可读');
306
+ return;
307
+ }
308
+ if (size > FILE_RAW_MAX) {
309
+ ctx.errorSpaced(413, '文件过大,无法按原文打开');
310
+ return;
311
+ }
312
+ const name = pyBasename(filePath);
313
+ let binary;
314
+ try {
315
+ const fd = fs.openSync(filePath, 'r');
316
+ try {
317
+ const buf = Buffer.allocUnsafe(65536);
318
+ const n = fs.readSync(fd, buf, 0, Math.min(size, 65536), 0);
319
+ binary = buf.subarray(0, n).includes(0);
320
+ } finally {
321
+ fs.closeSync(fd);
322
+ }
323
+ } catch {
324
+ binary = true; // 探测失败按二进制处理(只影响下载方式)
325
+ }
326
+ const headers = {
327
+ 'Content-Type': binary ? 'application/octet-stream' : 'text/plain; charset=utf-8',
328
+ 'Content-Length': String(size),
329
+ 'Cache-Control': 'no-store',
330
+ 'X-Content-Type-Options': 'nosniff',
331
+ };
332
+ if (binary) {
333
+ const asciiName = name.replace(/[^A-Za-z0-9._-]/g, '_') || 'download';
334
+ headers['Content-Disposition'] =
335
+ `attachment; filename="${asciiName}"; filename*=UTF-8''${quote(name)}`;
336
+ }
337
+ ctx.res.writeHead(200, headers);
338
+ try {
339
+ const fd = fs.openSync(filePath, 'r');
340
+ try {
341
+ const buf = Buffer.allocUnsafe(64 * 1024);
342
+ for (;;) {
343
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
344
+ if (!n) break;
345
+ ctx.res.write(buf.subarray(0, n));
346
+ }
347
+ } finally {
348
+ fs.closeSync(fd);
349
+ }
350
+ } catch {
351
+ // 客户端提前断开属常态(对齐 Python 的 `except (BrokenPipeError, ConnectionResetError, OSError)`)
352
+ }
353
+ try {
354
+ ctx.res.end();
355
+ } catch {
356
+ // 已断开:忽略
357
+ }
358
+ }
359
+
360
+ /**
361
+ * `_api_project_file` 的 err → (状态码, 消息) 映射(照抄 Python 的字面量,含中文标点)。
362
+ * @param {string} err
363
+ * @returns {[number, string]|null}
364
+ */
365
+ function projectFileError(err) {
366
+ const messages = {
367
+ empty: [400, 'path 不能为空'],
368
+ outside: [403, '路径不在项目目录/工作目录范围内'],
369
+ dir: [400, '该路径是目录,暂不支持预览'],
370
+ missing: [404, '文件不存在或不可读'],
371
+ };
372
+ return messages[err] || null;
373
+ }
374
+
375
+ /**
376
+ * `os.path.splitext(path)[1].lstrip(".").lower()`:扩展名(可有多个点,末尾空扩展名得空串)。
377
+ * @param {string} filePath
378
+ * @returns {string}
379
+ */
380
+ function fileExt(filePath) {
381
+ return path.extname(filePath).replace(/^\.+/, '').toLowerCase();
382
+ }
383
+
384
+ // ==========================================================================
385
+ // 写路径(M6.9):`POST /api/projects`、`PATCH|DELETE /api/projects/<id>`、
386
+ // `/archive|/unarchive`、`POST /api/prefs`、`POST /api/fs/mkdir`
387
+ //
388
+ // 期望值全部来自 `server.py`(行号写在每个函数头),不是从 Node 实现反推。
389
+ // ==========================================================================
390
+
391
+ // ---------- Python 语义助手(本文件私有;口径与 `routes/tasks.js` 的同名助手一致) ----------
392
+
393
+ /** Python 真值:`""`/`0`/`None`/`False`/空容器为假(JS 的 `[]`/`{}` 是 truthy,必须显式收口) */
394
+ function pyTruthy(value) {
395
+ if (Array.isArray(value)) return value.length > 0;
396
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
397
+ return Boolean(value);
398
+ }
399
+
400
+ /**
401
+ * Python `str()`(只覆盖 JSON 里会出现的类型):str→原样;bool→`True`/`False`;null→`None`;
402
+ * 数字→十进制文本;容器→JSON 文本兜底(Python 是 `repr` 形态,容器只可能来自手写调用,
403
+ * 与 `routes/tasks.js:pyStr` 同一条已知差异)。
404
+ *
405
+ * **为什么不用同文件既有的 `pyStrOrEmpty`**:那个是早期写点留下的版本,`true` 会变成
406
+ * `'true'`(Python 是 `'True'`),`str(row["commit_spec"])` 的 `None → 'None'` 也不成立;
407
+ * 它现在只被 `_api_reject_bug` 的 reason 用,不在本次改动范围内(已知差异,未顺手改)。
408
+ */
409
+ function pyStr(value) {
410
+ if (typeof value === 'string') return value;
411
+ if (value === null || value === undefined) return 'None';
412
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
413
+ if (typeof value === 'number') return String(value);
414
+ return JSON.stringify(value);
415
+ }
416
+
417
+ /** Python `dict.get(key, dflt)`:键存在但值为 null 时**不**回落 dflt(与 Python 同) */
418
+ function pyGet(obj, key, dflt = '') {
419
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : dflt;
420
+ }
421
+
422
+ /** Python `str(x or "").strip()`(`str()` 永不抛) */
423
+ function pyStripStr(value) {
424
+ return pyStrip(pyStr(pyTruthy(value) ? value : ''));
425
+ }
426
+
427
+ /**
428
+ * Python `(x or "").strip()` —— **没有** `str()` 包裹的那种写法。
429
+ *
430
+ * 真值但非字符串时 Python 抛 `AttributeError`(`5 .strip()`),一路冒到 socketserver
431
+ * ⇒ **该连接不回响应、直接断**。这里同形抛错,由 `routes/index.js:dispatchSafely` 收口成
432
+ * `res.destroy()`(与 Python 只断一条连接同形)。受影响的写点:建项目的
433
+ * `project_dir`/`name`/`agent_path`/`work_dir`(`server.py:2213-2218`)、改项目的
434
+ * `project_dir`/`work_dir`/`name`/`agent_path`、删项目的 `confirm_name`。
435
+ */
436
+ function pyStripBare(value) {
437
+ if (!pyTruthy(value)) return '';
438
+ if (typeof value !== 'string') {
439
+ throw new TypeError(`AttributeError: '${Array.isArray(value) ? 'list' : typeof value}'`
440
+ + " object has no attribute 'strip'");
441
+ }
442
+ return pyStrip(value);
443
+ }
444
+
445
+ /**
446
+ * `sqlite3.IntegrityError` 的 Node 判据:`SQLITE_CONSTRAINT`(主码 19)**一族**,不只唯一索引。
447
+ *
448
+ * 口径与 `lib/waitq.js:214` 的 `isIntegrityError` 相同(Python 的
449
+ * `except sqlite3.IntegrityError` 也吃 NOT NULL 等约束错);**不 import**:那是 waitq 的私有
450
+ * 函数(未导出),本文件自带等价物,避免改动那条车道的文件。
451
+ */
452
+ function isIntegrityError(error) {
453
+ return Boolean(error) && error.code === 'ERR_SQLITE_ERROR'
454
+ && (Number(error.errcode) & 0xff) === 19;
455
+ }
456
+
457
+ /** Node 的 fs 错误(带 code/syscall),与「解码/解析」类错误区分开(同 `lib/loadgen.js` 口径) */
458
+ function isOsError(error) {
459
+ return Boolean(error) && typeof error.code === 'string' && typeof error.syscall === 'string';
460
+ }
461
+
462
+ /**
463
+ * errno → glibc `strerror(3)` 文案表(**实测值**:`python3 -c "import os;print(os.strerror(n))"`)。
464
+ *
465
+ * 为什么不直接用 `util.getSystemErrorMessage()`:那是 **libuv** 的表,措辞与 glibc 不同
466
+ * (实测 17 ⇒ `file already exists`,glibc 是 `File exists`),而这段文案要逐字节进 400 正文。
467
+ */
468
+ const PY_STRERROR = new Map([
469
+ [1, 'Operation not permitted'],
470
+ [2, 'No such file or directory'],
471
+ [13, 'Permission denied'],
472
+ [17, 'File exists'],
473
+ [20, 'Not a directory'],
474
+ [28, 'No space left on device'],
475
+ [30, 'Read-only file system'],
476
+ [36, 'File name too long'],
477
+ [40, 'Too many levels of symbolic links'],
478
+ ]);
479
+
480
+ /**
481
+ * Python `str(OSError)` 的逐字节还原:`[Errno {errno}] {strerror}: '{filename}'`。
482
+ *
483
+ * 三个要点:
484
+ * ① Node 的 `err.errno` 是**负数**(libuv 口径)⇒ 取 `Math.abs`;`getSystemErrorMessage`
485
+ * 反而要求负数(传正数抛 `ERR_OUT_OF_RANGE`);
486
+ * ② `filename` 由调用方传**当时传给 `mkdirSync` 的那个路径**(Python 的 `e.filename` 就是
487
+ * 它,实测两档都是传入路径本身;Node 的 `err.path` 在递归建父目录的某些档位可能指向
488
+ * 中间层 ⇒ 以 Python 的路径为准);
489
+ * ③ 表里没有的 errno **不编文案**:退回 `util.getSystemErrorMessage()`(libuv 自带的表),
490
+ * 该档措辞与 glibc 可能不同 —— **已登记为未验项**,本机实测只覆盖了 errno 17/20 两档。
491
+ *
492
+ * @param {any} error `mkdirSync` 抛出的 fs 错误
493
+ * @param {string} [filename] Python 侧 `e.filename` 对应的路径
494
+ * @returns {string}
495
+ */
496
+ function pyOsErrorText(error, filename) {
497
+ const raw = Number(error && error.errno);
498
+ const where = filename !== undefined && filename !== null ? filename : (error && error.path);
499
+ const suffix = where === undefined || where === null ? '' : `: '${where}'`;
500
+ if (!Number.isFinite(raw) || raw === 0) {
501
+ // 理论上到不了(mkdir 一定带 errno):宁可原样透出 Node 的 message,也不硬编假文案
502
+ return `${(error && error.message) || String(error)}${suffix}`;
503
+ }
504
+ const errno = Math.abs(raw);
505
+ const known = PY_STRERROR.get(errno);
506
+ const text = known === undefined ? util.getSystemErrorMessage(-errno) : known;
507
+ return `[Errno ${errno}] ${text}${suffix}`;
508
+ }
509
+
510
+ /**
511
+ * `for d in (work_dir, db.cases_root_of(work_dir), db.bug_root_of(work_dir)): os.makedirs(d, exist_ok=True)`
512
+ * (`server.py:2249` 建项目 / `2298` 改项目共用)。
513
+ *
514
+ * `mkdirSync(recursive: true)` 对应 `makedirs` 的递归建父目录,`exist_ok` 语义由 Node 的
515
+ * recursive 覆盖(同名目录不报错、同名普通文件报 EEXIST)。失败时把**当时那条路径**一并回传
516
+ * ——不是「谁抛的谁猜」,而是 Python 的 `e.filename` 口径。
517
+ *
518
+ * **已知差异(未验)**:路径中间有**悬空符号链接**时 Python 抛 `NotADirectoryError`(20),
519
+ * Node 的 recursive mkdir 给 `ENOENT`(-2)(本机实测)⇒ 该档文案会不同;正常前端不产生。
520
+ *
521
+ * @param {string} workDir
522
+ * @returns {{error: any, path: string}|null}
523
+ */
524
+ function makedirsProjectDirs(workDir) {
525
+ for (const dir of [workDir, dbmod.casesRootOf(workDir), dbmod.bugRootOf(workDir)]) {
526
+ try {
527
+ fs.mkdirSync(dir, { recursive: true });
528
+ } catch (error) {
529
+ return { error, path: dir };
530
+ }
531
+ }
532
+ return null;
533
+ }
534
+
535
+ /** `except OSError as e: 400 {"error": f"工作目录不可用: {e}"}`(建/改项目共用,带空格口径) */
536
+ function respondWorkDirUnavailable(ctx, failure) {
537
+ ctx.errorSpaced(400, `工作目录不可用: ${pyOsErrorText(failure.error, failure.path)}`);
538
+ }
539
+
540
+ // ---------- 处理器 ----------
541
+
542
+ /**
543
+ * `_api_create_project`(2212):新建项目(`body` 在 `do_POST:1048` 已统一读掉)。
544
+ *
545
+ * 四个照抄点:
546
+ * ① `project_dir` 缺省 `os.path.expanduser("~")` ⇒ `os.homedir()`(**不是** `process.env.HOME`
547
+ * 手拼:HOME 未设时 Python 会回落到 pwd 库,`os.homedir()` 是同一回落,手拼会得空串);
548
+ * ② `work_dir` 缺省 `<project_dir>/.touchstone`,用 `pyJoin`(Python 的 join **保留**
549
+ * `project_dir` 结尾的重复斜杠,`path.join` 会压掉 ⇒ 落库的 work_dir 会差字节);
550
+ * ③ `commit_spec`/`deploy_spec` 恒传空串(已废弃),而 `skill_test`/`skill_cases`
551
+ * **必须往下传**——历史缺陷:这两个字段读了却没传给 `insert_project`,新建项目时
552
+ * 「测试项目/用例规范」静默丢失,只有再编辑一次才存得下;
553
+ * ④ 建目录在 `insert_project` **之前**:目录建不出来就一条行都不许落。
554
+ */
555
+ async function apiCreateProject(ctx) {
556
+ const body = await ctx.readBody(); // Python do_POST:1048 统一读体
557
+ const projectDir = pyStripBare(pyGet(body, 'project_dir', undefined)) || os.homedir();
558
+ // 项目名称留空时自动取项目路径最后一段(前端已先行填充,此处兜底)
559
+ const name = normalizeProjectName(
560
+ pyStripBare(pyGet(body, 'name', undefined)) || pathLastSegment(projectDir));
561
+ const agentPath = pyStripBare(pyGet(body, 'agent_path', undefined));
562
+ // 只填一个工作目录(未填默认 <项目目录>/.touchstone);案例库/free_style 与
563
+ // bug_report 由平台按固定子目录派生、自动创建
564
+ const workDir = pyStripBare(pyGet(body, 'work_dir', undefined))
565
+ || pyJoin(projectDir, '.touchstone');
566
+ const guideRaw = pyGet(body, 'guide_text', '');
567
+ const guideText = pyStr(pyTruthy(guideRaw) ? guideRaw : '');
568
+ const envRaw = pyGet(body, 'env_label', '');
569
+ const envLabel = pyStr(pyTruthy(envRaw) ? envRaw : '');
570
+ const model = pyStripStr(pyGet(body, 'model', ''));
571
+ const skillUnderstand = pyStripStr(pyGet(body, 'skill_understand', ''));
572
+ const skillDeploy = pyStripStr(pyGet(body, 'skill_deploy', ''));
573
+ const skillCommit = pyStripStr(pyGet(body, 'skill_commit', ''));
574
+ const skillTest = pyStripStr(pyGet(body, 'skill_test', ''));
575
+ const skillCases = pyStripStr(pyGet(body, 'skill_cases', ''));
576
+ // 项目级会话默认值(2026-10-04):思考等级 / 权限档(空=不指定,非法=400 不静默丢弃)
577
+ const reasoningEffort = normalizeEffort(pyGet(body, 'reasoning_effort', undefined));
578
+ const permissionMode = normalizePermissionMode(pyGet(body, 'permission_mode', undefined));
579
+ if (reasoningEffort === null || permissionMode === null) {
580
+ ctx.errorSpaced(400, '思考等级或权限档非法');
581
+ return;
582
+ }
583
+ // Python 的 try 同时罩着 makedirs 与 insert_project;能抛 OSError 的只有 makedirs
584
+ // (sqlite3 的错是 DatabaseError/OperationalError,不是 OSError)⇒ 拆开是等价的
585
+ const failure = makedirsProjectDirs(workDir);
586
+ if (failure !== null) {
587
+ respondWorkDirUnavailable(ctx, failure);
588
+ return;
589
+ }
590
+ let projectId;
591
+ try {
592
+ projectId = dbmod.insertProject(ctx.user.id, name, projectDir, agentPath, workDir,
593
+ guideText, '', '', envLabel, model,
594
+ skillUnderstand, skillDeploy, skillCommit, skillTest, skillCases,
595
+ reasoningEffort, permissionMode, { db: ctx.db });
596
+ } catch (error) {
597
+ if (isIntegrityError(error)) {
598
+ ctx.errorSpaced(400, '项目名已存在');
599
+ return;
600
+ }
601
+ throw error;
602
+ }
603
+ ctx.json(200, { id: projectId });
604
+ }
605
+
606
+ /**
607
+ * `_api_update_project`(2268):改项目。
608
+ *
609
+ * 读体位置:Python `do_PATCH:1238` 是 `self._api_update_project(id, self._read_json() or {})`
610
+ * ⇒ **读体发生在 `_owned_project` 之前**(404 档也要把体读掉),本函数的顺序照抄。
611
+ *
612
+ * 未带键=保持存量(`dict.get(key, row[key])` 语义,**不是** JS 的缺省参数);两列
613
+ * `commit_spec`/`deploy_spec` 保留存量但**不接受更新**(`str(row[...])` 直传)。
614
+ */
615
+ async function apiUpdateProject(ctx, projectId) {
616
+ const body = await ctx.readBody();
617
+ const row = ctx.ownedProject(projectId);
618
+ if (row === null) return; // 已回 404 project not found(紧凑字节串)
619
+ const reasoningEffort = normalizeEffort(pyGet(body, 'reasoning_effort', row.reasoning_effort));
620
+ const permissionMode = normalizePermissionMode(
621
+ pyGet(body, 'permission_mode', row.permission_mode));
622
+ if (reasoningEffort === null || permissionMode === null) {
623
+ ctx.errorSpaced(400, '思考等级或权限档非法');
624
+ return;
625
+ }
626
+ try {
627
+ const bodyDir = pyGet(body, 'project_dir', undefined);
628
+ const newDir = pyStripBare(pyTruthy(bodyDir) ? bodyDir : row.project_dir);
629
+ const bodyWork = pyGet(body, 'work_dir', undefined);
630
+ const newWork = pyStripBare(pyTruthy(bodyWork) ? bodyWork : row.work_dir);
631
+ if (newWork !== row.work_dir) {
632
+ // 工作目录变更:新目录与派生子目录一并创建(幂等),旧目录文件不动
633
+ const failure = makedirsProjectDirs(newWork);
634
+ if (failure !== null) {
635
+ respondWorkDirUnavailable(ctx, failure);
636
+ return;
637
+ }
638
+ }
639
+ const bodyAgent = pyGet(body, 'agent_path', undefined);
640
+ const bodyGuide = pyGet(body, 'guide_text', row.guide_text);
641
+ const bodyEnv = pyGet(body, 'env_label', row.env_label);
642
+ dbmod.updateProject(projectId,
643
+ // 名称留空时自动取项目路径最后一段(与新建逻辑一致)
644
+ normalizeProjectName(
645
+ pyStripBare(pyGet(body, 'name', undefined)) || pathLastSegment(newDir)),
646
+ newDir,
647
+ pyStripBare(pyTruthy(bodyAgent) ? bodyAgent : row.agent_path),
648
+ newWork,
649
+ pyStr(pyTruthy(bodyGuide) ? bodyGuide : ''),
650
+ pyStr(row.commit_spec), // 已废弃:保留存量、不再接受更新
651
+ pyStr(row.deploy_spec),
652
+ pyStr(pyTruthy(bodyEnv) ? bodyEnv : ''),
653
+ pyStripStr(pyGet(body, 'model', row.model)),
654
+ pyStripStr(pyGet(body, 'skill_understand', row.skill_understand)),
655
+ pyStripStr(pyGet(body, 'skill_deploy', row.skill_deploy)),
656
+ pyStripStr(pyGet(body, 'skill_commit', row.skill_commit)),
657
+ pyStripStr(pyGet(body, 'skill_test', row.skill_test)),
658
+ pyStripStr(pyGet(body, 'skill_cases', row.skill_cases)),
659
+ reasoningEffort, permissionMode, { db: ctx.db });
660
+ } catch (error) {
661
+ if (isIntegrityError(error)) {
662
+ ctx.errorSpaced(400, '项目名已存在');
663
+ return;
664
+ }
665
+ throw error;
666
+ }
667
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
668
+ }
669
+
670
+ /**
671
+ * `_api_delete_project`(2321):删除已归档项目(仅删库行 + 级联任务/轮次,磁盘文件一律保留)。
672
+ *
673
+ * **读体时机是本端点的契约**:Python 先判归属、再判归档闸,然后才 `self._read_json()`
674
+ * ⇒ 未归档/不存在这两档**不消费请求体**。`ctx.readBody()` 是记忆化的,这里若提前读,
675
+ * 未归档的 400 档就会把一个「Python 侧没读掉的体」读掉 —— 对拍里表现为 keep-alive
676
+ * 连接上下一个请求的行为分叉(`server.js` 只在未命中路由时补读)。
677
+ */
678
+ async function apiDeleteProject(ctx, projectId) {
679
+ const row = ctx.ownedProject(projectId);
680
+ if (row === null) return; // 已回 404 project not found
681
+ if (!row.archived) { // Python `if not row["archived"]`(列存整数 0/1)
682
+ ctx.errorSpaced(400, '仅已归档的项目可删除,请先归档');
683
+ return; // ⚠️ 此处不读体(见函数头)
684
+ }
685
+ const body = await ctx.readBody();
686
+ if (pyStripBare(pyGet(body, 'confirm_name', undefined)) !== row.name) {
687
+ ctx.errorSpaced(400, '确认名称与项目名不一致');
688
+ return;
689
+ }
690
+ dbmod.deleteProject(projectId, { db: ctx.db });
691
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
692
+ }
693
+
694
+ /** `_api_archive_project`(2462):归档/恢复(幂等,只切 `archived` 标记)。 */
695
+ async function apiArchiveProject(ctx, projectId, archived) {
696
+ await ctx.readBody(); // Python do_POST:1048 在读体之后才分派
697
+ const row = ctx.ownedProject(projectId);
698
+ if (row === null) return;
699
+ dbmod.setProjectArchived(projectId, archived, { db: ctx.db });
700
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
701
+ }
702
+
703
+ /**
704
+ * `_api_prefs_set`(2497):UPSERT 一条 UI 偏好。
705
+ *
706
+ * 三条口径:
707
+ * ① `key` 先 `str(...).strip()` 再 `re.fullmatch(r"[a-z_]{1,64}")`(`" k "` ⇒ 合法);
708
+ * ② `value` 必须是 dict 且 `json.dumps(ensure_ascii=False)` 的**字符数** ≤16384
709
+ * (中文不转义 ⇒ 必须走 `pyJsonDumps`;`len()` 按码点,JS 的 `.length` 是 UTF-16 码元
710
+ * ⇒ 展开计数,emoji 才不会提前 400);
711
+ * ③ `int(body.get("project_id") or 0)` 失败折 0(**不是** 400)。Node 用 `pyIntOr`,
712
+ * 已知差异:`int(2.7)==2`、`int(True)==1` 在 `pyIntOr` 里走 fallback 0,`int("1_0")==10`
713
+ * 与 Unicode 数字(`int("١٢")==12`)同样回落 0 —— 这些档正常前端不产生,**未为它改 lib.js**。
714
+ */
715
+ async function apiPrefsSet(ctx) {
716
+ const body = await ctx.readBody();
717
+ const key = pyStripStr(pyGet(body, 'key', undefined));
718
+ if (!/^[a-z_]{1,64}$/.test(key)) {
719
+ ctx.error(400, 'key 非法'); // 紧凑(Python 是硬编码字节串)
720
+ return;
721
+ }
722
+ const value = pyGet(body, 'value', undefined);
723
+ let text = null;
724
+ if (value !== null && value !== undefined && typeof value === 'object' && !Array.isArray(value)) {
725
+ text = pyJsonDumps(value); // 与 Python `json.dumps(..., ensure_ascii=False)` 同口径
726
+ }
727
+ if (text === null || [...text].length > 16384) {
728
+ ctx.error(400, 'value 非法'); // 紧凑
729
+ return;
730
+ }
731
+ const pid = pyIntOr(pyGet(body, 'project_id', undefined) || 0, 0);
732
+ if (pid && ctx.ownedProject(pid) === null) return; // 已回 404 project not found(紧凑)
733
+ dbmod.setUserPref(ctx.user.id, pid, key, value, { db: ctx.db });
734
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
735
+ }
736
+
737
+ /**
738
+ * `_api_fs_mkdir`(2357):在 `body.path` 下新建子目录 `body.name`。
739
+ *
740
+ * 七档中文拒绝文案全在 `lib/lib.js:fsMkdir` 里(含 `os.path.lexists`、`str.rstrip(". ")`
741
+ * 等逐条对齐),这里只做接线与判错口径(`data is None ⇒ 400 json.dumps({"error": err})`
742
+ * ⇒ **带空格**)。
743
+ *
744
+ * **已知差异(在 lib.js,不在本文件)**:Python 的 `(raw_path or "").strip()` 遇到真值非字符串
745
+ * (如 `{"path": 5}`)抛 `AttributeError` ⇒ 断连;`fsMkdir` 内部走 `pyOrEmpty`/`pyStrip`
746
+ * 会把它转成文本 `"5"` 再判目录 ⇒ 这一档回 400 文案而不是断连。未改 lib.js。
747
+ */
748
+ async function apiFsMkdir(ctx) {
749
+ const body = await ctx.readBody();
750
+ const [data, err] = fsMkdir(pyGet(body, 'path', undefined) || '', pyGet(body, 'name', undefined));
751
+ if (data === null) {
752
+ ctx.errorSpaced(400, err);
753
+ return;
754
+ }
755
+ ctx.json(200, data);
756
+ }
757
+
758
+ export const routes = [
759
+ {
760
+ method: 'GET',
761
+ path: '/api/projects',
762
+ /** 项目列表 + 看板三列计数批量预计算(侧栏展示用)。 */
763
+ handler: (ctx) => {
764
+ const rows = dbmod.listProjects(ctx.user.id, { db: ctx.db });
765
+ const counts = dbmod.boardCountsMany(rows.map((r) => r.id), { db: ctx.db });
766
+ const out = rows.map((r) => {
767
+ const pj = projectJson(r);
768
+ pj.board_counts = counts[r.id] || emptyBoardCounts();
769
+ return pj;
770
+ });
771
+ ctx.json(200, out);
772
+ },
773
+ },
774
+ {
775
+ method: 'GET',
776
+ path: '/api/projects/dup_check',
777
+ /**
778
+ * 目录重复检测(跨用户):`project_dir`/`work_dir` 与已存在项目相同时返回重复项。
779
+ * 仅提醒不阻止写入;`exclude=<id>` 用于编辑项目时排除自身。
780
+ * 注意 `?exclude=` 非数字时 Python `isdigit()` 为假 ⇒ 不排除任何项目(不报错)。
781
+ */
782
+ handler: (ctx) => {
783
+ const pdir = pyStrip(ctx.q('project_dir', '') || '');
784
+ const wdir = pyStrip(ctx.q('work_dir', '') || '');
785
+ const exclude = ctx.q('exclude', '');
786
+ const excludeId = pyIsDigits(exclude) ? pyIntOr(exclude, null) : null;
787
+ const dups = [];
788
+ for (const p of dbmod.listProjectsAll({ db: ctx.db })) {
789
+ if (excludeId !== null && p.id === excludeId) continue;
790
+ const owner = dbmod.getUserById(ctx.db, p.user_id);
791
+ const uname = owner ? owner.username : '未知';
792
+ if (pdir && pyNormpath(pdir) === pyNormpath(p.project_dir)) {
793
+ dups.push({
794
+ field: 'project_dir', value: pdir, project_id: p.id,
795
+ project_name: p.name, username: uname,
796
+ });
797
+ }
798
+ if (wdir && pyNormpath(wdir) === pyNormpath(p.work_dir)) {
799
+ dups.push({
800
+ field: 'work_dir', value: wdir, project_id: p.id,
801
+ project_name: p.name, username: uname,
802
+ });
803
+ }
804
+ }
805
+ ctx.json(200, dups);
806
+ },
807
+ },
808
+ {
809
+ method: 'GET',
810
+ path: '/api/fs/browse',
811
+ /** 目录浏览:返回当前目录的子目录列表 + 服务端平台 + 根列表(供目录选择对话框)。 */
812
+ handler: (ctx) => {
813
+ const data = fsBrowse(ctx.q('path', ''));
814
+ if (data === null) {
815
+ ctx.errorSpaced(400, '目录不存在或不可读');
816
+ return;
817
+ }
818
+ ctx.json(200, data);
819
+ },
820
+ },
821
+ {
822
+ method: 'GET',
823
+ path: '/api/prefs',
824
+ /**
825
+ * 当前用户在某项目下的全部 UI 偏好:`{prefs: {key: value}}`。
826
+ * `project_id` 缺省按 0(跨项目全局,预留);给定时须为本人项目(多用户隔离)。
827
+ * 非数字 `project_id` 按 Python `int()` 的 ValueError 分支回落到 0(不 404)。
828
+ */
829
+ handler: (ctx) => {
830
+ const pid = pyIntOr(ctx.q('project_id', '0') || '0', 0);
831
+ if (pid && ctx.ownedProject(pid) === null) return; // 已回 404 project not found
832
+ ctx.json(200, { prefs: dbmod.getUserPrefs(ctx.user.id, pid, { db: ctx.db }) });
833
+ },
834
+ },
835
+ {
836
+ method: 'GET',
837
+ path: '/api/state',
838
+ /** 聚合状态(案例树/统计/bug/轮次 + live 运行状态 + project 段)。无项目时 200 `{"error":"no project"}`。 */
839
+ handler: (ctx) => {
840
+ const project = resolveProject(ctx);
841
+ if (project === null) {
842
+ ctx.json(200, { error: 'no project' });
843
+ return;
844
+ }
845
+ const state = ctx.stateCache.get(project.id, project.cases_root, project.bug_dir, project.work_dir);
846
+ state.project = projectJson(project);
847
+ ctx.json(200, state);
848
+ },
849
+ },
850
+ {
851
+ method: 'GET',
852
+ path: '/api/stream',
853
+ /**
854
+ * SSE 长连接:每 0.5s 检查聚合状态,有变化才推 `data:` 帧,空闲每 ~15s 发 `: hb` 心跳。
855
+ *
856
+ * 帧里带 `generated_at`(构建时刻)⇒ **本帧不参与 content-hash 类判据**,对拍用例用
857
+ * `ignore: ['generated_at']` 显式声明(理由写在用例文件里)。指纹的主体(缓存指纹)
858
+ * 已在构建时剔除 `generated_at`,故「没有真实变化就不推帧」这条语义仍然成立。
859
+ */
860
+ handler: async (ctx) => {
861
+ const project = resolveProject(ctx);
862
+ if (project === null) {
863
+ ctx.res.writeHead(200, {
864
+ 'Content-Type': 'text/event-stream; charset=utf-8',
865
+ 'Cache-Control': 'no-store',
866
+ });
867
+ ctx.res.end('data: {"error":"no project"}\n\n');
868
+ return;
869
+ }
870
+ const sse = ctx.sse();
871
+ let closed = false;
872
+ ctx.res.on('close', () => { closed = true; });
873
+ // Python 靠「往断开的 socket 写」抛 BrokenPipeError 收尾;Node 的 write 未必同步抛错,
874
+ // 故额外挂一个 error 监听(无它时断连会变成未捕获异常,把进程带走)
875
+ ctx.res.on('error', () => { closed = true; });
876
+ let lastFingerprint = null;
877
+ let idleTicks = 0;
878
+ try {
879
+ while (!closed) {
880
+ const state = ctx.stateCache.get(project.id, project.cases_root, project.bug_dir, project.work_dir);
881
+ state.project = projectJson(project);
882
+ // 指纹 = 缓存指纹(剔除 generated_at)+ project 段的 sort_keys dumps(与 Python 同式)
883
+ const fingerprint = `${ctx.stateCache.fingerprint(project.id) || ''}|${pyJsonDumpsSorted(state.project)}`;
884
+ if (fingerprint !== lastFingerprint) {
885
+ lastFingerprint = fingerprint;
886
+ if (!sse.data(state)) break;
887
+ idleTicks = 0;
888
+ } else {
889
+ idleTicks += 1;
890
+ if (idleTicks >= 30) { // ~15s 一次心跳,防中间层断连
891
+ idleTicks = 0;
892
+ if (!sse.comment('hb')) break;
893
+ }
894
+ }
895
+ await sleep(500);
896
+ }
897
+ } finally {
898
+ sse.end();
899
+ }
900
+ },
901
+ },
902
+ {
903
+ method: 'GET',
904
+ re: /^\/api\/projects\/(\d+)$/,
905
+ /** 项目详情(多用户隔离);`board_counts` 与列表端点同口径(前端 refreshProject 替换列表项)。 */
906
+ handler: (ctx, m) => {
907
+ const row = ctx.ownedProject(Number(m[1]));
908
+ if (row === null) return; // 已回 404 project not found
909
+ const pj = projectJson(row);
910
+ pj.board_counts = dbmod.boardCountsMany([row.id], { db: ctx.db })[row.id];
911
+ ctx.json(200, pj);
912
+ },
913
+ },
914
+ {
915
+ method: 'GET',
916
+ re: /^\/api\/projects\/(\d+)\/file$/,
917
+ /**
918
+ * 读取项目范围内的文件内容供会话页预览(只读,多用户隔离)。
919
+ *
920
+ * query: `path=`相对/绝对路径(相对按 项目目录 → 工作目录 解析);`raw=1` 直接返回原文。
921
+ * 安全:路径须落在本项目「项目目录/工作目录」之内(realpath 包含判定,防目录穿越与越权
922
+ * 读他人文件);raw 一律 text/plain + nosniff —— 这些文件是 agent 在工作区写的内容,
923
+ * 按扩展名猜类型内联下发会让同源页面执行其中脚本。
924
+ */
925
+ handler: (ctx, m) => {
926
+ const row = ctx.ownedProject(Number(m[1]));
927
+ if (row === null) return;
928
+ const resolved = resolveProjectFile(row, ctx.q('path', ''));
929
+ const mapped = projectFileError(resolved.err);
930
+ if (mapped) {
931
+ ctx.errorSpaced(mapped[0], mapped[1]);
932
+ return;
933
+ }
934
+ if (['1', 'true', 'yes'].includes(ctx.q('raw', '0'))) {
935
+ sendProjectFileRaw(ctx, resolved.path);
936
+ return;
937
+ }
938
+ let preview;
939
+ let mtime;
940
+ try {
941
+ preview = readTextPreview(resolved.path);
942
+ mtime = pyMtimeInt(fs.statSync(resolved.path, { bigint: true }).mtimeNs);
943
+ } catch {
944
+ ctx.errorSpaced(404, '文件不存在或不可读');
945
+ return;
946
+ }
947
+ const rootDir = (resolved.rootKey === 'project' ? row.project_dir : row.work_dir) || '';
948
+ const rel = rootDir
949
+ ? pyRelpath(resolved.path, rootDir).split(path.sep).join('/')
950
+ : resolved.path;
951
+ ctx.json(200, {
952
+ path: resolved.path,
953
+ rel,
954
+ root: resolved.rootKey || 'project',
955
+ name: pyBasename(resolved.path),
956
+ ext: fileExt(resolved.path),
957
+ size: preview.size,
958
+ mtime,
959
+ text: preview.text,
960
+ truncated: preview.truncated,
961
+ binary: preview.binary,
962
+ });
963
+ },
964
+ },
965
+ {
966
+ method: 'GET',
967
+ re: /^\/api\/projects\/(\d+)\/bugs$/,
968
+ /** bug 报告列表(扫描结果 + 每个 bug 最近的修复/复测/修例任务)。 */
969
+ handler: (ctx, m) => {
970
+ const row = ctx.ownedProject(Number(m[1]));
971
+ if (row === null) return;
972
+ ctx.json(200, listProjectBugs(row, { db: ctx.db }));
973
+ },
974
+ },
975
+ {
976
+ method: 'GET',
977
+ re: /^\/api\/projects\/(\d+)\/bugs\/(.+)$/,
978
+ /** 单枚 bug 报告详情:全文 + 关联 case 的当前状态 + 最近任务。 */
979
+ handler: (ctx, m) => {
980
+ const row = ctx.ownedProject(Number(m[1]));
981
+ if (row === null) return;
982
+ const bugDir = unquote(m[2]);
983
+ const dirPath = pyJoin(row.bug_dir, bugDir);
984
+ if (!isDir(dirPath)) {
985
+ ctx.error(404, 'bug not found');
986
+ return;
987
+ }
988
+ const { content, ids } = bugCases(dirPath);
989
+ const fields = parseFields(content);
990
+ const bm = BUG_DIR_RE.exec(bugDir);
991
+ const cases = [];
992
+ for (const [cid, dir] of findCaseDirs(row.cases_root, ids)) {
993
+ const st = parseStatusMd(pyJoin(dir, 'status.md'));
994
+ cases.push({
995
+ id: cid,
996
+ name: pyBasename(dir).slice(cid.length + 1),
997
+ status: st.status,
998
+ last_run: st.last_run,
999
+ fail_reason: st.fail_reason,
1000
+ });
1001
+ }
1002
+ ctx.json(200, {
1003
+ dir: bugDir,
1004
+ title: bm ? bm[1] : bugDir,
1005
+ status: fields['状态'] || '',
1006
+ cases,
1007
+ content,
1008
+ last_task: bugLastTask(listTasks(row.id, { db: ctx.db }), bugDir),
1009
+ });
1010
+ },
1011
+ },
1012
+ // ---------- 写路径(M6.9):顺序照 Python 的 do_POST / do_PATCH / do_DELETE 链 ----------
1013
+ // do_POST: /api/fs/mkdir(1050) → /api/projects(1075) → /api/prefs(1085)
1014
+ // → /api/projects/<id>/(archive|unarchive)(1089)
1015
+ // → /api/projects/<id>/bugs/<dir>/reject(1102)
1016
+ // do_PATCH: /api/projects/<id>(1231)
1017
+ // do_DELETE: /api/projects/<id>(1273)
1018
+ {
1019
+ method: 'POST',
1020
+ path: '/api/fs/mkdir',
1021
+ /** 建目录(目录选择对话框「新建文件夹」):`body {path, name}`;错误体带空格。 */
1022
+ handler: (ctx) => apiFsMkdir(ctx),
1023
+ },
1024
+ {
1025
+ method: 'POST',
1026
+ path: '/api/projects',
1027
+ /** 新建项目:名称/工作目录派生 + 真建目录(work_dir + free_style + bug_report)。 */
1028
+ handler: (ctx) => apiCreateProject(ctx),
1029
+ },
1030
+ {
1031
+ method: 'POST',
1032
+ path: '/api/prefs',
1033
+ /** UPSERT 一条 UI 偏好(key 白名单 + value 形状/大小门 + project_id 折 0)。 */
1034
+ handler: (ctx) => apiPrefsSet(ctx),
1035
+ },
1036
+ {
1037
+ method: 'POST',
1038
+ re: /^\/api\/projects\/(\d+)\/(archive|unarchive)$/,
1039
+ /** 归档/恢复:一条正则两个分支(`m[2] === 'archive'`),不动任务与文件。 */
1040
+ handler: (ctx, m) => apiArchiveProject(ctx, Number(m[1]), m[2] === 'archive'),
1041
+ },
1042
+ {
1043
+ method: 'POST',
1044
+ re: /^\/api\/projects\/(\d+)\/bugs\/([^/]+)\/reject$/,
1045
+ handler: (ctx, m) => apiRejectBug(ctx, Number(m[1]), m[2]),
1046
+ },
1047
+ {
1048
+ method: 'PATCH',
1049
+ re: /^\/api\/projects\/(\d+)$/,
1050
+ /** 改项目:未带键保持存量;换工作目录时新派生目录一并创建。 */
1051
+ handler: (ctx, m) => apiUpdateProject(ctx, Number(m[1])),
1052
+ },
1053
+ {
1054
+ method: 'DELETE',
1055
+ re: /^\/api\/projects\/(\d+)$/,
1056
+ /** 删项目:仅已归档可删 + `confirm_name` 双保险;只删库行,磁盘文件保留。 */
1057
+ handler: (ctx, m) => apiDeleteProject(ctx, Number(m[1])),
1058
+ },
1059
+ ];