@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,1220 @@
1
+ /**
2
+ * 路由族:看板读 + 看板两条 SSE(M3 读路径)
3
+ *
4
+ * 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头):
5
+ * - `GET /api/projects/<id>/board/stream` → `_api_board_stream`(SSE)
6
+ * - `GET /api/projects/<id>/board/sessions/<sid>/stream` → `_api_board_session_stream`(SSE)
7
+ * - `GET /api/projects/<id>/board` → `_api_get_board`
8
+ * - `GET /api/projects/<id>/board/sessions` → `_api_board_sessions`
9
+ * - `GET /api/projects/<id>/board/media/<fid>` → `_api_board_get_media`(字节)
10
+ * - `GET /api/projects/<id>/board/trash` → `_api_board_trash_list`
11
+ * - `GET /api/projects/<id>/board/cards/<id>/worktree` → `_api_board_card_worktree_preview`
12
+ *
13
+ * 两条 SSE 的路径正则**必须先于**其他 `/board/*` 正则(Python `_route_get` 里就在最前,
14
+ * `server.py:1360-1366` 对 `server.py:1384-1413`)。卡片/回收站/评论/设置的写路径属 M5;
15
+ * `/board/session/*`(会话内容)归 `./sessions.js`。
16
+ *
17
+ * ── 逐点照抄 Python 的四处(改前先对着 server.py 核) ────────────────────────────────
18
+ * 1. **两条 SSE 的帧语义**(`server.py:5029`/`5102`):`hello` 首帧 → `localbus.wait(topic,
19
+ * since, 15s)` 循环 —— 有变更发 `refresh`、无变更发注释帧 `: keepalive\n\n`;
20
+ * `since` 每次刷新;**头部带 `X-Accel-Buffering: no`**(只有看板这两条流有);
21
+ * 客户端断开一律静默收尾(Python 吞 BrokenPipe/ConnectionReset/OSError/ValueError)。
22
+ * 2. **订阅者成对摘除**:`dshevents.subscribe(on_dsh_frame)` 后,`finally` 必须
23
+ * `unsubscribe` —— 漏摘就是「每开一次看板页泄漏一个订阅者」。客户端断开、写失败、
24
+ * 抛异常三条路径都走同一个 finally。
25
+ * 3. **看板流的绑定 sid 集合**:本项目全部卡片的 `session_id ∪ sessions[]`(坏 JSON 跳过,
26
+ * 对齐 `_sids()` 的 `except (ValueError, TypeError)`);dsh 状态帧只有 sid 落在集合内才
27
+ * `localbus.publish(board_topic)`(其余帧被过滤,不产生刷新)。**每 30s 重算一次**
28
+ * (新绑定的会话最多滞后 30s 才能被事件覆盖)。
29
+ * 4. **卡片附件**:fid 白名单 `^m\d+_[0-9a-f]{10}\.[A-Za-z0-9]{1,10}$` 不匹配即 400
30
+ * `bad fid`(防路径穿越);MIME 由扩展名反查 `BOARD_MEDIA_EXTS`(17 项,表照抄
31
+ * `server.py:2822-2830`),查不到落 `application/octet-stream`;目录 =
32
+ * `<work_dir or <project_dir>/.touchstone>/board_media`;读不到走 `ctx.file` 的
33
+ * 404 `not found` + text/plain(Python `_send_file` 同形)。
34
+ *
35
+ * ── 与 Python 的两处**形态**差异(语义等价,写在这里防评审重复提问) ────────────────
36
+ * ① **等待口 Promise 化**:Python 每条 SSE 占一个线程阻塞在 `localbus.wait`;Node 是事件
37
+ * 循环,断开信号走 `res.on('close')` 与 wait 竞速(`Promise.race`)——不然已断开的连接
38
+ * 要等满 15s 才收尾。收尾时那个 wait 的定时器最多再活 15s(`ChangeBus` 无取消口),
39
+ * 但它不写任何东西、不持有连接。
40
+ * ② **宽容读**:`_api_board_sessions` / `_board_owns_sid` 在 Python 里对坏 `sessions` JSON
41
+ * 是**未捕获**的 `json.loads`(会 500);Node 侧与 `board.card_json` 同口径按空表处理
42
+ * (M3 全仓既定口径,见 `lib/board.js` 文件头差异⑦)。正常库里 `sessions` 恒为合法 JSON。
43
+ *
44
+ * ── 单测时序缝(不是生产开关) ─────────────────────────────────────────────────────
45
+ * `_setSseTiming()` 可缩短两个纯时序窗口(keepalive 等待 15s、绑定集合重算 30s),仅供
46
+ * `backend/test/board-route.test.mjs` 使用——与 Python 测试 monkeypatch `server.localbus.wait`
47
+ * 是同一件事。生产路径恒取模块常量,**不读环境变量、无外部开关**。
48
+ */
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import { randomBytes } from 'node:crypto';
52
+
53
+ import * as agents from '../lib/agents.js';
54
+ import * as board from '../lib/board.js';
55
+ import * as dbmod from '../lib/db.js';
56
+ import { DshDriverError } from '../lib/dshdriver.js';
57
+ import * as dshdriver from '../lib/dshdriver.js';
58
+ import * as dshevents from '../lib/dshevents.js';
59
+ import * as localbus from '../lib/localbus.js';
60
+ import { pyStrip } from '../lib/lib.js';
61
+ import { pyJsonDumps } from '../lib/pyjson.js';
62
+ import { PyB64Error, b64decode } from '../lib/pybase64.js';
63
+ import { unquote } from '../lib/query.js';
64
+ import { DSH_EFFORT_LEVELS, DSH_PERMISSION_PRESETS, familyCapabilities, normalizeEffort,
65
+ sessFamily } from '../lib/servercaps.js';
66
+ import * as sessparse from '../lib/sessparse.js';
67
+ import { injectMsg } from './sessions.js';
68
+
69
+ // --------------------------------------------------------------------------
70
+ // 常量(照抄 server.py:2820-2830 / 5029-5154 的字面量)
71
+ // --------------------------------------------------------------------------
72
+
73
+ /** 卡片附件子目录名 + 单文件上限(上限只写路径侧用到,M5 上传端点才校验) */
74
+ const BOARD_MEDIA_SUBDIR = 'board_media';
75
+
76
+ /** MIME → 扩展名(`_api_board_upload_media` / `_api_board_get_media` 共用;逐项照抄) */
77
+ const BOARD_MEDIA_EXTS = {
78
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
79
+ 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp',
80
+ 'image/x-icon': 'ico', 'text/plain': 'txt', 'text/csv': 'csv',
81
+ 'text/html': 'html', 'application/pdf': 'pdf', 'application/json': 'json',
82
+ 'application/zip': 'zip', 'application/gzip': 'gz',
83
+ 'application/x-tar': 'tar', 'application/x-sh': 'sh',
84
+ 'application/x-python': 'py',
85
+ };
86
+
87
+ /** 扩展名 → MIME(Python 每次请求现建 `{v: k for k, v in ...}`;预建一份等价,扩展名无重复) */
88
+ const BOARD_MEDIA_TYPES = Object.fromEntries(
89
+ Object.entries(BOARD_MEDIA_EXTS).map(([mime, ext]) => [ext, mime]),
90
+ );
91
+
92
+ /** 反查不到的兜底类型(Python 同字面量) */
93
+ const BOARD_MEDIA_FALLBACK_TYPE = 'application/octet-stream';
94
+
95
+ /** fid 白名单(`_api_board_get_media` 的路径穿越闸;与 Python 正则逐字相同) */
96
+ const BOARD_MEDIA_FID_RE = /^m\d+_[0-9a-f]{10}\.[A-Za-z0-9]{1,10}$/;
97
+
98
+ /** keepalive 等待窗口(毫秒):`localbus.wait(topic, since, 15.0)` */
99
+ export const SSE_WAIT_MS = 15000;
100
+ /** 看板流绑定 sid 集合重算周期(毫秒):`time.time() - bound_at > 30` */
101
+ export const SSE_BOUND_REFRESH_MS = 30000;
102
+
103
+ /** 单测可缩短的时序窗口(初值=生产常量;见文件头「单测时序缝」) */
104
+ let sseTiming = { waitMs: SSE_WAIT_MS, boundRefreshMs: SSE_BOUND_REFRESH_MS };
105
+
106
+ /**
107
+ * **仅供单测**:覆盖 SSE 的两个时序窗口,返回旧值便于还原。
108
+ * 生产代码不得调用(Python 侧等价物是测试里 monkeypatch `localbus.wait`)。
109
+ * @param {{waitMs?: number, boundRefreshMs?: number}} patch
110
+ */
111
+ export function _setSseTiming(patch) {
112
+ const old = { ...sseTiming };
113
+ sseTiming = { ...sseTiming, ...patch };
114
+ return old;
115
+ }
116
+
117
+ // --------------------------------------------------------------------------
118
+ // 小工具
119
+ // --------------------------------------------------------------------------
120
+
121
+ /** `session_id ∪ sessions[]`(坏 JSON 按空表,见文件头差异②) */
122
+ function cardSids(row) {
123
+ const out = new Set();
124
+ if (row.session_id) out.add(String(row.session_id));
125
+ try {
126
+ const parsed = JSON.parse(row.sessions || '[]');
127
+ for (const v of parsed) out.add(String(v));
128
+ } catch {
129
+ // 坏 JSON:跳过(Python 在 `_sids()` 里同样吞 ValueError/TypeError)
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** `_sids()`:本项目全部卡片的绑定会话 id 集合 */
135
+ function boundSids(projectId, handle) {
136
+ const out = new Set();
137
+ for (const card of dbmod.listBoardCards(projectId, { db: handle })) {
138
+ for (const sid of cardSids(card)) out.add(sid);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * `_board_owned(project_id, card_id=None)`:项目归属 + 卡片归属联合校验。
145
+ * `ctx.ownedProject` 越权/不存在时**已回 404**(契约见 `./index.js`),这里直接返回 null 对。
146
+ * @returns {[object|null, object|null]}
147
+ */
148
+ function boardOwned(ctx, projectId, cardId = null) {
149
+ const row = ctx.ownedProject(projectId);
150
+ if (row === null) return [null, null];
151
+ if (cardId === null) return [row, null];
152
+ const card = dbmod.getBoardCard(cardId, { db: ctx.db });
153
+ if (card === null || card === undefined || card.project_id !== row.id) {
154
+ ctx.error(404, 'card not found');
155
+ return [null, null];
156
+ }
157
+ return [row, card];
158
+ }
159
+
160
+ /**
161
+ * `_board_owns_sid(row, sid)`:sid 是否归属本项目卡片的会话并集。
162
+ * 返回归属卡片行(调用方可读 id/column_key);不归属返回 null(防经会话端点读任意会话)。
163
+ */
164
+ function boardOwnsSid(row, sid, handle) {
165
+ for (const c of dbmod.listBoardCards(row.id, { db: handle })) {
166
+ if (cardSids(c).has(sid)) return c;
167
+ if (c.session_id && sid === c.session_id) return c;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** 卡片附件目录:`<work_dir or <project_dir>/.touchstone>/board_media`(Python 同表达式) */
173
+ function mediaDir(row) {
174
+ const base = row.work_dir || path.join(row.project_dir, '.touchstone');
175
+ return path.join(base, BOARD_MEDIA_SUBDIR);
176
+ }
177
+
178
+ /**
179
+ * 看板两条 SSE 的公共循环(`_api_board_stream` / `_api_board_session_stream` 的骨架)。
180
+ *
181
+ * 时序与帧逐点照 Python:hello → 循环 { wait(≤15s):有变更发 refresh / 超时发 keepalive }
182
+ * → 客户端断开静默收尾。`onCleanup` 在 finally 里无条件执行(摘订阅者)。
183
+ */
184
+ async function sseLoop(ctx, { topic, since, hello, refresh, onTick = null, onCleanup }) {
185
+ let conn = null;
186
+ let closed = false;
187
+ let signalClose = null;
188
+ const closedPromise = new Promise((resolve) => { signalClose = resolve; });
189
+ const onClose = () => {
190
+ closed = true;
191
+ signalClose();
192
+ };
193
+ try {
194
+ // 先挂断开监听再写首帧/打开流:三条收尾路径(断开 / 写失败 / 抛异常)都走下面同一个 finally
195
+ if (typeof ctx.res.on === 'function') ctx.res.on('close', onClose);
196
+ conn = ctx.sse({ accelBuffering: true }); // X-Accel-Buffering: no(只这两条流有)
197
+ if (!conn.event('hello', hello)) return; // 客户端在写首帧前就断了
198
+ for (;;) {
199
+ // 与「客户端断开」竞速:不然已断开的连接要等满一个 keepalive 窗口才收尾
200
+ const next = await Promise.race([
201
+ localbus.wait(topic, since, sseTiming.waitMs),
202
+ closedPromise.then(() => null),
203
+ ]);
204
+ if (closed) break;
205
+ if (next !== null && next !== since) {
206
+ since = next;
207
+ if (!conn.event('refresh', refresh)) break;
208
+ } else if (next !== null) {
209
+ if (!conn.comment('keepalive')) break; // 写失败=对端已断
210
+ }
211
+ if (typeof onTick === 'function') onTick(); // 看板流在此重算绑定 sid 集合
212
+ }
213
+ } finally {
214
+ if (typeof ctx.res.off === 'function') ctx.res.off('close', onClose);
215
+ try {
216
+ onCleanup(); // 摘订阅者:与 subscribe 成对
217
+ } finally {
218
+ if (conn !== null) conn.end(); // 对端已断时 end 是 no-op
219
+ }
220
+ }
221
+ }
222
+
223
+ // --------------------------------------------------------------------------
224
+ // 端点
225
+ // --------------------------------------------------------------------------
226
+
227
+ /** `_api_get_board`(`server.py:2776`):看板全量 + 族/能力声明 + 卡片会话标题映射 */
228
+ async function getBoard(ctx, m) {
229
+ const projectId = Number(m[1]);
230
+ const row = ctx.ownedProject(projectId);
231
+ if (row === null) return;
232
+ const payload = board.boardPayload(row.id, { runningMap: board.runningMap(), db: ctx.db });
233
+ // 项目级 agent 族与能力声明(前端看板按 capabilities 泛化门控)
234
+ const family = agents.agentFamily(row.agent_path);
235
+ payload.family = family;
236
+ payload.capabilities = familyCapabilities(family);
237
+ // 卡片相关会话的标题映射(前端列表展示;解析族归一后逐 sid 读取,sessparse 侧有缓存)
238
+ const pfamily = sessFamily(family);
239
+ const titles = {};
240
+ for (const c of payload.cards) {
241
+ for (const sid of c.sessions) {
242
+ if (!Object.prototype.hasOwnProperty.call(titles, sid)) {
243
+ titles[sid] = sessparse.session_title(pfamily, sid);
244
+ }
245
+ }
246
+ }
247
+ payload.session_titles = titles;
248
+ ctx.json(200, payload);
249
+ }
250
+
251
+ /** `_api_board_trash_list`(`server.py:2994`):回收站列表(项目卡片按删除时间倒序) */
252
+ async function boardTrashList(ctx, m) {
253
+ const projectId = Number(m[1]);
254
+ const row = ctx.ownedProject(projectId);
255
+ if (row === null) return;
256
+ const rm = board.runningMap();
257
+ const cards = dbmod.listTrashedCards(row.id, { db: ctx.db })
258
+ .map((r) => board.cardJson(r, { running: Boolean(rm[r.id]) }, { db: ctx.db }));
259
+ ctx.json(200, { cards });
260
+ }
261
+
262
+ /** `_api_board_sessions`(`server.py:3421`):工作区已有 agent 会话 + `bound` 标记 */
263
+ async function boardSessions(ctx, m) {
264
+ const projectId = Number(m[1]);
265
+ const row = ctx.ownedProject(projectId);
266
+ if (row === null) return;
267
+ const family = sessFamily(agents.agentFamily(row.agent_path));
268
+ const items = sessparse.list_sessions(family, row.project_dir);
269
+ const bound = new Set();
270
+ for (const c of dbmod.listBoardCards(row.id, { db: ctx.db })) {
271
+ for (const sid of cardSids(c)) bound.add(sid);
272
+ }
273
+ for (const it of items) it.bound = bound.has(it.sid);
274
+ ctx.json(200, { sessions: items });
275
+ }
276
+
277
+ /** `_api_board_get_media`(`server.py:2880`):卡片附件读取(鉴权 + fid 白名单 + MIME 反查) */
278
+ async function boardGetMedia(ctx, m) {
279
+ const projectId = Number(m[1]);
280
+ const fid = m[2];
281
+ const row = ctx.ownedProject(projectId);
282
+ if (row === null) return;
283
+ if (!BOARD_MEDIA_FID_RE.test(fid)) {
284
+ ctx.error(400, 'bad fid');
285
+ return;
286
+ }
287
+ const ext = fid.slice(fid.lastIndexOf('.') + 1).toLowerCase();
288
+ const ctype = Object.prototype.hasOwnProperty.call(BOARD_MEDIA_TYPES, ext)
289
+ ? BOARD_MEDIA_TYPES[ext] : BOARD_MEDIA_FALLBACK_TYPE;
290
+ await ctx.file(path.join(mediaDir(row), fid), ctype);
291
+ }
292
+
293
+ /** `_api_board_card_worktree_preview`(`server.py:3158`):独立 worktree 预览(只读、不落盘) */
294
+ async function cardWorktreePreview(ctx, m) {
295
+ const projectId = Number(m[1]);
296
+ const cardId = Number(m[2]);
297
+ const [row, card] = boardOwned(ctx, projectId, cardId);
298
+ if (row === null) return;
299
+ ctx.json(200, board.worktreePreview(row, card));
300
+ }
301
+
302
+ /**
303
+ * `_api_board_stream`(`server.py:5029`):看板变更事件流。
304
+ *
305
+ * 帧:`hello {project_id}` / `refresh {project_id}` / 注释帧 `: keepalive`。
306
+ * 触发:① 本项目卡片的 db 写路径(`localbus` 由 db 层发布);② 本项目绑定会话的 dsh 状态帧。
307
+ */
308
+ async function boardStream(ctx, m) {
309
+ const projectId = Number(m[1]);
310
+ const project = ctx.ownedProject(projectId);
311
+ if (project === null) return;
312
+ const topic = localbus.boardTopic(projectId);
313
+ // 绑定 sid 集合(dsh 帧按它过滤;新卡会话最多滞后 boundRefreshMs)
314
+ let bound = boundSids(projectId, ctx.db);
315
+ let boundAt = Date.now();
316
+ const onDshFrame = (frame) => {
317
+ // 跑在 dshevents 消费上下文:只做集合判断 + 计数(O(1),绝不抛)
318
+ const sid = String((frame && frame.session_id) || '');
319
+ if (sid && bound.has(sid)) localbus.publish(topic);
320
+ };
321
+ dshevents.subscribe(onDshFrame);
322
+ // 摘除在 sseLoop 的 finally 里(断开/写失败/异常三条路径同一出口),此处不再重复
323
+ await sseLoop(ctx, {
324
+ topic,
325
+ since: localbus.seq(topic),
326
+ hello: { project_id: projectId },
327
+ refresh: { project_id: projectId },
328
+ onTick: () => {
329
+ if (Date.now() - boundAt > sseTiming.boundRefreshMs) {
330
+ bound = boundSids(projectId, ctx.db);
331
+ boundAt = Date.now();
332
+ }
333
+ },
334
+ onCleanup: () => dshevents.unsubscribe(onDshFrame),
335
+ });
336
+ }
337
+
338
+ /**
339
+ * `_api_board_session_stream`(`server.py:5102`):会话实时事件流。
340
+ *
341
+ * 帧:`hello {project_id, session_id, events:true}` / `refresh {session_id}` / `: keepalive`。
342
+ * 触发:该 sid 的任一状态帧(`transcript` 逐条消息、`turn/*`、`agent/status`、
343
+ * `driver/interaction`)到达即发一次 refresh —— 前端收到做一次增量拉取。
344
+ * sid 必须归属本项目卡片(`_board_owns_sid`),否则 404 `session not found`。
345
+ */
346
+ async function boardSessionStream(ctx, m) {
347
+ const projectId = Number(m[1]);
348
+ // 路由正则对**原始路径**匹配(与 Python `re.match(...self.path)` 同),sid 再 unquote
349
+ const sid = unquote(m[2]);
350
+ const row = ctx.ownedProject(projectId);
351
+ if (row === null) return;
352
+ const card = boardOwnsSid(row, sid, ctx.db);
353
+ if (card === null) {
354
+ ctx.error(404, 'session not found');
355
+ return;
356
+ }
357
+ const topic = localbus.sessionTopic(sid);
358
+ const onDshFrame = (frame) => {
359
+ if (String((frame && frame.session_id) || '') === sid) localbus.publish(topic);
360
+ };
361
+ dshevents.subscribe(onDshFrame);
362
+ await sseLoop(ctx, {
363
+ topic,
364
+ since: localbus.seq(topic),
365
+ hello: { project_id: projectId, session_id: sid, events: true },
366
+ refresh: { session_id: sid },
367
+ onCleanup: () => dshevents.unsubscribe(onDshFrame),
368
+ });
369
+ }
370
+
371
+ // ══════════════════════════════════════════════════════════════════════════
372
+ // M5-4a 看板写端点(第一批:只依赖 M5-1 的库面,不碰会话驱动)
373
+ //
374
+ // 逐条对齐 `server.py`:建卡 2798 / 回收站还原 3006 / 清空回收站 3031 / 列内拖排 3041 /
375
+ // 标记已读 3069 / 加评论 3295 / 删评论 3352 / 改看板设置 3369。
376
+ // 需要会话驱动的端点(删卡 / 移列启停 / 投递评论 / 压缩分叉 / worktree 回流 / 附件上传 /
377
+ // 作答送达)归 M5-4b,等 `board.py` 的会话驱动面(M5-2/M5-3)落地后再接——见实施计划 §3.5。
378
+ //
379
+ // ── 三条「照抄 Python 的坏形态」的复刻(不是我们的品味,是 oracle 的行为)────────
380
+ // ① `str.strip()[:N]` 的切片按**码点**(Python `str` 语义),`.slice()` 是 UTF-16 码元
381
+ // ——emoji/增补平面字符会分叉,故本文件自带 `cpSlice`(M4.5 在 `chat.register` 踩过同一坑);
382
+ // ② `(body.get("description") or "")[:20000]`:真值非字符串(数字/数组)在 Python 里是
383
+ // `5[:20000]` ⇒ TypeError ⇒ 该连接**无响应即断**;Node 用 `res.destroy()` 复刻同一可观察
384
+ // 结果(与 M4.5 `pyStripBare` 同口径:不许「宽容成空串」——那是行为差异);
385
+ // ③ 手写紧凑字节串(`b'{"ok":true}'`)与 `json.dumps({...})`(带 `", "` 分隔)**两种正文形态**
386
+ // 在同族端点里混用,逐端点照抄(`ctx.text` vs `ctx.json`)。
387
+ // ══════════════════════════════════════════════════════════════════════════
388
+
389
+ /** Python `dict.get(k)`:只对普通对象取值;数组/标量/缺键一律给缺省(`readJsonBody` 已保证是普通对象) */
390
+ function pyGet(obj, key, dflt = null) {
391
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return dflt;
392
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : dflt;
393
+ }
394
+
395
+ /** Python `bool(x)`:空串/0/空容器/None 皆假(`float('nan')` 为真——`NaN !== 0` 天然成立) */
396
+ function pyTruthy(value) {
397
+ if (value === null || value === undefined) return false;
398
+ if (typeof value === 'boolean') return value;
399
+ if (typeof value === 'number') return value !== 0;
400
+ if (typeof value === 'string') return value.length > 0;
401
+ return Object.keys(value).length > 0;
402
+ }
403
+
404
+ /** Python `str(x)` 的**标量面**(容器 repr 是已登记偏差,见 spec §23.7) */
405
+ function pyStrScalar(value) {
406
+ if (value === null || value === undefined) return 'None';
407
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
408
+ return String(value);
409
+ }
410
+
411
+ /** Python `s[:n]`:按**码点**截断(`.slice` 按 UTF-16 码元,emoji 会切出半个代理对) */
412
+ function cpSlice(text, limit) {
413
+ const s = String(text === null || text === undefined ? '' : text);
414
+ const points = Array.from(s);
415
+ return points.length <= limit ? s : points.slice(0, limit).join('');
416
+ }
417
+
418
+ /**
419
+ * 卡片归属校验成功后取「评论行」(Python 是遍历 `list_board_comments` 找 id+card_id 双匹配)。
420
+ *
421
+ * **必须传 `options`**(`{db: ctx.db}`):读口缺省会自开**缺省库路径**的连接(M4.5 事故的
422
+ * 同一条坑——那次是写口落在生产库)。这里虽只是读,也不许碰生产库。
423
+ */
424
+ function findComment(projectId, cardId, commentId, options) {
425
+ for (const c of dbmod.listBoardComments(projectId, options)) {
426
+ if (c.id === commentId && c.card_id === cardId) return c;
427
+ }
428
+ return null;
429
+ }
430
+
431
+ /** `POST /api/projects/<pid>/board/cards`:建卡(`server.py:2798`) */
432
+ async function apiBoardCreateCard(ctx, projectId) {
433
+ const body = await ctx.readBody();
434
+ const row = ctx.ownedProject(projectId);
435
+ if (row === null) return;
436
+ // 允许空标题建卡(用户先建空卡再单击打开详情编辑,标题在详情内补)
437
+ let title = pyGet(body, 'title');
438
+ if (typeof title !== 'string') title = ''; // 非字符串(数字/数组等)按缺失处理
439
+ title = cpSlice(pyStrip(title), 200);
440
+ const descRaw = pyGet(body, 'description');
441
+ const desc = pyTruthy(descRaw) ? descRaw : '';
442
+ if (typeof desc !== 'string') { ctx.res.destroy(); return; } // Python `5[:20000]` ⇒ TypeError
443
+ const jiraRaw = pyGet(body, 'jira_key');
444
+ const jira = pyTruthy(jiraRaw) ? jiraRaw : '';
445
+ if (typeof jira !== 'string') { ctx.res.destroy(); return; }
446
+ const cid = dbmod.insertBoardCard(row.id, title, cpSlice(desc, 20000), cpSlice(jira, 100),
447
+ { db: ctx.db });
448
+ ctx.json(200, board.cardJson(dbmod.getBoardCard(cid, { db: ctx.db }), {}, { db: ctx.db }));
449
+ }
450
+
451
+ /** `POST /api/projects/<pid>/board/reorder`:列内拖排(`server.py:3041`) */
452
+ async function apiBoardReorder(ctx, projectId) {
453
+ const body = await ctx.readBody();
454
+ const row = ctx.ownedProject(projectId);
455
+ if (row === null) return;
456
+ const cardId = board.pyIntLoose(pyGet(body, 'card_id'));
457
+ if (cardId === null) { ctx.error(400, 'card_id required'); return; }
458
+ let beforeId = null;
459
+ const rawBefore = pyGet(body, 'before_id');
460
+ if (rawBefore !== null && rawBefore !== undefined) {
461
+ beforeId = board.pyIntLoose(rawBefore); // 解析失败按 None(Python 吞掉异常给 None)
462
+ }
463
+ const err = board.reorderCard(row.id, cardId, beforeId, { db: ctx.db });
464
+ if (err) { ctx.errorSpaced(400, err); return; } // Python `json.dumps({"error": err})` ⇒ 带空格
465
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
466
+ }
467
+
468
+ /** `POST /api/projects/<pid>/board/trash/<cid>/restore`:回收站还原(`server.py:3006`) */
469
+ async function apiBoardTrashRestore(ctx, projectId, cardId) {
470
+ const [row, card] = boardOwned(ctx, projectId, cardId);
471
+ if (row === null) return;
472
+ if (!card.trashed) { ctx.error(400, 'card not in trash'); return; }
473
+ dbmod.restoreBoardCard(cardId, { db: ctx.db });
474
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
475
+ }
476
+
477
+ /** `POST /api/projects/<pid>/board/trash/empty`:清空回收站(`server.py:3031`) */
478
+ async function apiBoardTrashEmpty(ctx, projectId) {
479
+ const row = ctx.ownedProject(projectId);
480
+ if (row === null) return;
481
+ dbmod.purgeTrashedCards(row.id, { db: ctx.db });
482
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
483
+ }
484
+
485
+ /** `DELETE /api/projects/<pid>/board/trash/<cid>`:彻底删卡(连带评论,`server.py:3018`) */
486
+ async function apiBoardTrashPurge(ctx, projectId, cardId) {
487
+ const [row, card] = boardOwned(ctx, projectId, cardId);
488
+ if (row === null) return;
489
+ if (!card.trashed) { ctx.error(400, 'card not in trash'); return; }
490
+ await board.deleteCardCleanup(cardId, { db: ctx.db }); // 幂等兜底:真删前清平台侧残留占用
491
+ dbmod.purgeBoardCard(cardId, { db: ctx.db });
492
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
493
+ }
494
+
495
+ /** `POST /api/projects/<pid>/board/cards/<cid>/viewed`:清「有更新」标记(`server.py:3069`) */
496
+ async function apiBoardMarkViewed(ctx, projectId, cardId) {
497
+ const [row] = boardOwned(ctx, projectId, cardId);
498
+ if (row === null) return;
499
+ const changed = dbmod.markCardViewed(cardId, { db: ctx.db });
500
+ ctx.json(200, { ok: true, unread: false, changed: Boolean(changed) });
501
+ }
502
+
503
+ /** `POST /api/projects/<pid>/board/cards/<cid>/comments`:加评论(`server.py:3295`) */
504
+ async function apiBoardAddComment(ctx, projectId, cardId) {
505
+ const body = await ctx.readBody();
506
+ const [row] = boardOwned(ctx, projectId, cardId);
507
+ if (row === null) return;
508
+ const rawText = pyGet(body, 'text');
509
+ const textRaw = pyTruthy(rawText) ? rawText : '';
510
+ if (typeof textRaw !== 'string') { ctx.res.destroy(); return; } // Python `5.strip()` ⇒ AttributeError
511
+ const text = pyStrip(textRaw);
512
+ if (!text) { ctx.error(400, 'text required'); return; }
513
+ const mid = dbmod.insertBoardComment(cardId, cpSlice(text, 20000), { db: ctx.db });
514
+ const rowC = findComment(row.id, cardId, mid, { db: ctx.db });
515
+ if (rowC === null) { ctx.res.destroy(); return; } // Python `[...][0]` ⇒ IndexError ⇒ 断连
516
+ ctx.json(200, board.commentJson(rowC));
517
+ }
518
+
519
+ /** `DELETE /api/projects/<pid>/board/cards/<cid>/comments/<comid>`(`server.py:3352`) */
520
+ async function apiBoardDeleteComment(ctx, projectId, cardId, commentId) {
521
+ const [row] = boardOwned(ctx, projectId, cardId);
522
+ if (row === null) return;
523
+ // 评论归属校验(评论 id 全表自增可枚举,防跨项目删除他人评论)
524
+ if (findComment(row.id, cardId, commentId, { db: ctx.db }) === null) {
525
+ ctx.error(404, 'comment not found');
526
+ return;
527
+ }
528
+ dbmod.deleteBoardComment(commentId, { db: ctx.db });
529
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
530
+ }
531
+
532
+ /** `PATCH /api/projects/<pid>/board/settings`:看板设置(`server.py:3369`) */
533
+ async function apiBoardUpdateSettings(ctx, projectId) {
534
+ const body = await ctx.readBody();
535
+ const row = ctx.ownedProject(projectId);
536
+ if (row === null) return;
537
+ const s = board.settingsOf(row.id, { db: ctx.db });
538
+ const mode = pyGet(body, 'mode');
539
+ // 并行模式白名单(serial/parallel 两档;readonly-parallel 2026-09-17 删档,旧前端残余值静默忽略)
540
+ if (board.MODE_VALUES.includes(mode)) s.mode = mode;
541
+ const jira = pyGet(body, 'jira');
542
+ if (jira !== null && typeof jira === 'object' && !Array.isArray(jira)) {
543
+ for (const k of ['url', 'user', 'token']) {
544
+ if (Object.prototype.hasOwnProperty.call(jira, k)) {
545
+ s.jira[k] = cpSlice(pyStrScalar(jira[k]), 500);
546
+ }
547
+ }
548
+ }
549
+ const syncSessions = pyGet(body, 'sync_sessions');
550
+ if (typeof syncSessions === 'boolean') s.sync_sessions = syncSessions;
551
+ // 每列排序方案:逐列白名单(key∈五列、value∈SORT_MODES、entered_desc 仅 done 列);
552
+ // 逐列合并进现值,非法整份拒绝
553
+ const sort = pyGet(body, 'sort');
554
+ if (sort !== null && typeof sort === 'object' && !Array.isArray(sort)) {
555
+ for (const [col, mode2] of Object.entries(sort)) {
556
+ if (!board.COLUMNS.includes(col) || !board.SORT_MODES.includes(mode2)) {
557
+ ctx.error(400, 'bad sort');
558
+ return;
559
+ }
560
+ if (mode2 === 'entered_desc' && col !== 'done') {
561
+ ctx.error(400, 'bad sort');
562
+ return;
563
+ }
564
+ s.sort[col] = mode2;
565
+ }
566
+ }
567
+ dbmod.setBoardSettings(row.id, s, { db: ctx.db });
568
+ ctx.json(200, s);
569
+ }
570
+
571
+ // ══════════════════════════════════════════════════════════════════════════
572
+ // M5-4b 看板写端点(第二批:依赖 M5-2 的会话驱动面)
573
+ //
574
+ // 逐条对齐 `server.py`:卡动作 move|start|stop 3087 / worktree 清理 3172 / worktree 回流 3193 /
575
+ // compact 3215 / fork-compact 3238 / fork 3268 / 投递评论 3310 / 删卡 2980。
576
+ // 仍需 M5-3 的端点(PATCH 改卡要 `rename_card_session`/`archive_bound_session`、作答送达、
577
+ // 会话 profile/inject/rewind、jira、附件上传)留到下一批。
578
+ //
579
+ // 三处照抄 Python 的形态(别「顺手统一」):
580
+ // ① **写死的紧凑体**(`b'{"ok":true}'` / `b'{"error":"sid required"}'`)与
581
+ // `json.dumps(...)`(带空格)在同族端点里混用 —— 逐端点照抄;
582
+ // ② 「父任务拦截 / 待合并询问」**回 200**(`blocked` / `merge_pending` 在 err 里但**不算错误**,
583
+ // 前端据此弹确认框,列未变);
584
+ // ③ `except RuntimeError` ⇒ 400(业务拒绝),`except DshDriverError` ⇒ 409/500 两档
585
+ // (409 是「会话运行中」的忙拒绝,其余按 500 `agent 调用失败: <e>`)。
586
+ // ══════════════════════════════════════════════════════════════════════════
587
+
588
+ /** Python `str(e)`:异常 → 可进正文的文本(`DshDriverError.toString()` 是纯消息;普通 Error 取 `.message`) */
589
+ function pyStrErr(err) {
590
+ if (err === null || err === undefined) return String(err);
591
+ if (err instanceof DshDriverError) return String(err);
592
+ if (err instanceof Error) return err.message;
593
+ return String(err);
594
+ }
595
+
596
+ /** `POST .../board/cards/<cid>/(move|start|stop)`:卡动作(`server.py:3087`) */
597
+ async function apiBoardCardAction(ctx, projectId, cardId, action) {
598
+ const [row, card] = boardOwned(ctx, projectId, cardId);
599
+ if (row === null) return;
600
+ if (action === 'stop') {
601
+ const hit = await board.stopCard(cardId, { db: ctx.db });
602
+ ctx.json(200, { ok: true, stopped: hit });
603
+ return;
604
+ }
605
+ const body = await ctx.readBody();
606
+ if (action === 'move') {
607
+ const target = pyStrip(pyStrScalar(pyGet(body, 'column') || ''));
608
+ let beforeId = null;
609
+ const rawBefore = pyGet(body, 'before_id');
610
+ if (rawBefore !== null && rawBefore !== undefined) beforeId = board.pyIntLoose(rawBefore);
611
+ const blockTextRaw = pyGet(body, 'block_text');
612
+ const blockText = cpSlice(pyTruthy(blockTextRaw) ? pyStrScalar(blockTextRaw) : '', 2000);
613
+ const [cardDict, err] = await board.moveCard(row, cardId, target, blockText, beforeId,
614
+ pyTruthy(pyGet(body, 'merge_ack')), { db: ctx.db });
615
+ if (err) {
616
+ if ('blocked' in err || 'merge_pending' in err) { ctx.json(200, err); return; }
617
+ ctx.errorSpaced(400, err.error ?? '');
618
+ return;
619
+ }
620
+ ctx.json(200, { card: cardDict });
621
+ return;
622
+ }
623
+ // start:归档项目禁止;门禁+起会话+落列原子化走 `startIntoDoing`
624
+ if (row.archived) { ctx.error(400, '项目已归档'); return; }
625
+ const opinionRaw = pyGet(body, 'opinion');
626
+ const opinion = cpSlice(pyTruthy(opinionRaw) ? pyStrScalar(opinionRaw) : '', 20000);
627
+ // doing+queue 排队占位卡放行 start(force=true 直起跳队,非 force 幂等补队自愈滞留卡);
628
+ // done 列放行(已完成卡拖入「正在开发」= 接着干,续接主会话只发「继续」)
629
+ if (!['todo', 'review', 'blocked', 'done'].includes(card.column_key)
630
+ && !(card.column_key === 'doing' && card.block_kind === 'queue')) {
631
+ ctx.error(400, '当前状态不可开始');
632
+ return;
633
+ }
634
+ const [cardDict, err] = await board.startIntoDoing(row, card, opinion,
635
+ pyTruthy(pyGet(body, 'force')), pyTruthy(pyGet(body, 'worktree')), false, { db: ctx.db });
636
+ if (err) {
637
+ if ('blocked' in err) { ctx.json(200, err); return; } // 父任务拦截:不算错误
638
+ ctx.errorSpaced(400, err.error ?? '');
639
+ return;
640
+ }
641
+ ctx.json(200, { card: cardDict });
642
+ }
643
+
644
+ /** `DELETE /api/projects/<pid>/board/cards/<cid>`:删卡=移入回收站(`server.py:2980`) */
645
+ async function apiBoardDeleteCard(ctx, projectId, cardId) {
646
+ const [row] = boardOwned(ctx, projectId, cardId);
647
+ if (row === null) return;
648
+ await board.stopCard(cardId, { db: ctx.db }); // 运行中进程先停
649
+ await board.deleteCardCleanup(cardId, { db: ctx.db }); // 清平台侧残留(幂等)
650
+ dbmod.trashBoardCard(cardId, { db: ctx.db });
651
+ ctx.text(200, '{"ok":true,"trashed":true}', 'application/json; charset=utf-8');
652
+ }
653
+
654
+ /** `POST .../board/cards/<cid>/comments/<comid>/send`:投递评论(`server.py:3310`) */
655
+ async function apiBoardSendComment(ctx, projectId, cardId, commentId) {
656
+ const body = await ctx.readBody();
657
+ const [row, card] = boardOwned(ctx, projectId, cardId);
658
+ if (row === null) return;
659
+ const target = findComment(row.id, cardId, commentId, { db: ctx.db });
660
+ if (target === null) { ctx.error(404, 'comment not found'); return; }
661
+ let rec;
662
+ try {
663
+ // inject=true 时立即注入运行中的当前轮;项目忙时登记统一队列消息单元(回 queued=true)
664
+ rec = await board.deliverComment(row, card, target, pyTruthy(pyGet(body, 'inject')),
665
+ { db: ctx.db });
666
+ } catch (e) {
667
+ if (e instanceof DshDriverError) {
668
+ // 409 = 会话运行中(deliver_comment 的忙拒绝);其余 code 按 500
669
+ if (Array.isArray(e.args) && e.args[0] === 409) {
670
+ ctx.errorSpaced(409, e.args.length > 1 ? e.args[1] : '会话运行中,稍后重试');
671
+ } else {
672
+ ctx.errorSpaced(500, `agent 调用失败: ${pyStrErr(e)}`);
673
+ }
674
+ return;
675
+ }
676
+ ctx.errorSpaced(400, pyStrErr(e)); // Python `except RuntimeError` ⇒ 400(顺序照抄)
677
+ }
678
+ if (rec === undefined) return;
679
+ ctx.json(200, { ok: true, queued: Boolean(rec && rec.queued), msg_id: (rec || {}).id || '' });
680
+ }
681
+
682
+ /** `POST .../board/cards/<cid>/compact|fork-compact|fork`(`server.py:3215/3238/3268`) */
683
+ async function apiBoardSessionOp(ctx, projectId, cardId, op) {
684
+ const body = await ctx.readBody();
685
+ const [row, card] = boardOwned(ctx, projectId, cardId);
686
+ if (row === null) return;
687
+ const sidRaw = pyGet(body, 'sid');
688
+ const sid = pyStrip(pyTruthy(sidRaw) ? pyStrScalar(sidRaw) : (card.session_id || ''));
689
+ if (!sid) { ctx.error(400, 'sid required'); return; }
690
+ if (board.runningMap().get(cardId)) {
691
+ ctx.errorSpaced(409, '会话运行中,稍后再试');
692
+ return;
693
+ }
694
+ try {
695
+ if (op === 'compact') {
696
+ await board.compactSession(row, card, sid, { db: ctx.db });
697
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
698
+ return;
699
+ }
700
+ if (op === 'fork-compact') {
701
+ const [newSid, compactOk] = await board.forkCompactSession(row, card, sid, { db: ctx.db });
702
+ ctx.json(200, { ok: true, new_sid: newSid, compact_ok: compactOk });
703
+ return;
704
+ }
705
+ const newSid = await board.forkSession(row, card, sid, { db: ctx.db });
706
+ ctx.json(200, { ok: true, new_sid: newSid });
707
+ } catch (e) {
708
+ ctx.errorSpaced(400, pyStrErr(e)); // Python `except RuntimeError` ⇒ 400
709
+ }
710
+ }
711
+
712
+ /** `DELETE .../board/cards/<cid>/worktree`:清理独立 worktree(`server.py:3172`) */
713
+ async function apiBoardWorktreeCleanup(ctx, projectId, cardId) {
714
+ const [row, card] = boardOwned(ctx, projectId, cardId);
715
+ if (row === null) return;
716
+ if (board.runningMap().get(cardId)) {
717
+ ctx.errorSpaced(409, '会话运行中,请先停止卡片再清理 worktree');
718
+ return;
719
+ }
720
+ const [ok, err] = board.cleanupCardWorktree(row, card, { db: ctx.db });
721
+ if (!ok) { ctx.errorSpaced(400, err); return; }
722
+ ctx.text(200, '{"ok": true}', 'application/json; charset=utf-8');
723
+ }
724
+
725
+ /** `POST .../board/cards/<cid>/worktree/merge`:把回流合并任务交给卡片会话(`server.py:3193`) */
726
+ async function apiBoardWorktreeMerge(ctx, projectId, cardId) {
727
+ const [row, card] = boardOwned(ctx, projectId, cardId);
728
+ if (row === null) return;
729
+ const [cardDict, err] = await board.handoffWorktreeMerge(row, card, { db: ctx.db });
730
+ if (err) { ctx.errorSpaced(400, err.error ?? pyStrErr(err)); return; }
731
+ ctx.json(200, { card: cardDict });
732
+ }
733
+
734
+ // ══════════════════════════════════════════════════════════════════════════
735
+ // M5-4c 看板写端点(第三批:附件上传 / 改卡 / 作答送达 / 会话 profile·inject·rewind / jira)
736
+ //
737
+ // 逐条对齐 `server.py`:附件上传 2832 / 改卡 2899 / jira 3404 / 会话 profile 3577 /
738
+ // 作答 3703 / 立即送达 3750 / 会话 inject 3765 / 会话 rewind 3833(+ 共用实现
739
+ // `_inject_msg` 3795、`_rewind_session_dsh` 3878)。
740
+ //
741
+ // 四处照抄 Python 的形态(别「顺手统一」):
742
+ // ① **错误正文两种形态混用**:`PATCH` 改卡的 `bad scheduled_at`/`title required`/循环依赖
743
+ // 是硬编码紧凑串(`ctx.error`),会话 profile/rewind 的是 `json.dumps`(`ctx.errorSpaced`);
744
+ // ② 附件上传**不是 multipart**:正文是 base64 JSON(`{name,mime,data}`),所以不需要
745
+ // 表单解析器 —— 计划 §3.5 里「按同一字节口径自解析 multipart」的预案作废(Python 侧
746
+ // 就是 JSON,见 `_api_board_upload_media` 文档串);
747
+ // ③ `data` 的解码走 `lib/pybase64.js`(Python `base64.b64decode` 的**报错口径**,
748
+ // 不能用 `Buffer.from(s,'base64')`——它永不报错,坏 base64 会变成垃圾附件 + 200);
749
+ // ④ 会话 inject 与任务族共用 `sessions.js:injectMsg`(`_inject_msg` 单实现),
750
+ // `taskId` 留 null ⇒ 归属三重判定自动退化成看板版。
751
+ // ══════════════════════════════════════════════════════════════════════════
752
+
753
+ /** 单文件上限 10MB(`server.py:2821` `BOARD_MEDIA_MAX`) */
754
+ const BOARD_MEDIA_MAX = 10 * 1024 * 1024;
755
+
756
+ /**
757
+ * Python `k in obj`(`dict`/`list` 两形态;`str` 是子串判定)。
758
+ *
759
+ * 为什么需要它:改卡的字段判定全是 `if "title" in body` 形态,Python 的 `in` 对 dict 是
760
+ * **键**、对 list 是**成员**、对 str 是**子串**。标量(数字/布尔)在 Python 抛 TypeError
761
+ * (断连)——Node 侧按「宽容」给 false(与 `card_json` 的宽容读同口径,见文件头差异②)。
762
+ */
763
+ function pyHas(obj, key) {
764
+ if (obj === null || obj === undefined) return false;
765
+ if (Array.isArray(obj)) return obj.includes(key);
766
+ if (typeof obj === 'object') return Object.prototype.hasOwnProperty.call(obj, key);
767
+ if (typeof obj === 'string') return obj.includes(key);
768
+ return false;
769
+ }
770
+
771
+ /** Python `str(x)`:走 `pyStrScalar`(标量面;容器 repr 是已登记偏差) */
772
+ function pyStrFull(value) {
773
+ return pyStrScalar(value);
774
+ }
775
+
776
+ /** `json.loads(card["sessions"] or "[]")`:坏 JSON 按空表(M3 宽容口径,见文件头差异②) */
777
+ function cardSessionList(card) {
778
+ try {
779
+ const parsed = JSON.parse(card.sessions || '[]');
780
+ return Array.isArray(parsed) ? parsed : [];
781
+ } catch {
782
+ return [];
783
+ }
784
+ }
785
+
786
+ /**
787
+ * `POST /api/projects/<pid>/board/media`:卡片附件上传(`server.py:2832`)。
788
+ *
789
+ * 正文 `{name, mime, data}`(data = base64);单文件 ≤10MB;mime 未知按 `bin` 存。
790
+ * 响应 `{fid, url, abs, name, size}`(键序照 Python)。
791
+ */
792
+ async function apiBoardUploadMedia(ctx, projectId) {
793
+ const body = await ctx.readBody();
794
+ const row = ctx.ownedProject(projectId);
795
+ if (row === null) return;
796
+ const mimeRaw = pyGet(body, 'mime');
797
+ const mime = cpSlice(pyStrScalar(pyTruthy(mimeRaw) ? mimeRaw : 'application/octet-stream'),
798
+ 100);
799
+ const nameRaw = pyGet(body, 'name');
800
+ const name = cpSlice(pyStrScalar(pyTruthy(nameRaw) ? nameRaw : 'file'), 200);
801
+ const dataRaw = pyGet(body, 'data');
802
+ const dataStr = pyStrScalar(pyTruthy(dataRaw) ? dataRaw : '');
803
+ let data;
804
+ try {
805
+ data = b64decode(dataStr);
806
+ } catch (e) {
807
+ if (e instanceof PyB64Error) { ctx.error(400, 'bad base64'); return; } // 紧凑
808
+ throw e;
809
+ }
810
+ if (data.length === 0) { ctx.error(400, 'empty file'); return; }
811
+ if (data.length > BOARD_MEDIA_MAX) { ctx.error(400, 'file too large'); return; }
812
+ const ext = Object.prototype.hasOwnProperty.call(BOARD_MEDIA_EXTS, mime)
813
+ ? BOARD_MEDIA_EXTS[mime] : 'bin';
814
+ // Python `f"m{int(time.time()*1000)}_{secrets.token_hex(5)}.{ext}"`:毫秒 + 10 位小写 hex
815
+ const fid = `m${Date.now()}_${randomBytes(5).toString('hex')}.${ext}`;
816
+ const mdir = mediaDir(row);
817
+ const abs = path.join(mdir, fid);
818
+ try {
819
+ fs.mkdirSync(mdir, { recursive: true });
820
+ fs.writeFileSync(abs, data);
821
+ } catch (e) {
822
+ // Python `except OSError as e` ⇒ 500 `save failed: {e}`(带空格;错误文案本身是已登记偏差)
823
+ ctx.errorSpaced(500, `save failed: ${pyStrErr(e)}`);
824
+ return;
825
+ }
826
+ ctx.json(200, { fid, url: `/api/projects/${projectId}/board/media/${fid}`,
827
+ abs, name, size: data.length });
828
+ }
829
+
830
+ /**
831
+ * `PATCH /api/projects/<pid>/board/cards/<cid>`:改卡(`server.py:2899`)。
832
+ *
833
+ * 白名单字段直过;`parent_card_id` 走 `set_parent` 防循环;`bind_session`/`unbind_session`
834
+ * 维护 `sessions`/`session_id`;新绑定会话在 done 卡上补归档同步;卡面改名同步 DSH 会话名
835
+ * (`session_rename` 只如实上报,**不回写卡面**)。
836
+ */
837
+ async function apiBoardUpdateCard(ctx, projectId, cardId) {
838
+ const body = await ctx.readBody();
839
+ const [row, card] = boardOwned(ctx, projectId, cardId);
840
+ if (row === null) return;
841
+ if (pyHas(body, 'title') && !pyStrip(pyStrFull(pyGet(body, 'title')))) {
842
+ ctx.error(400, 'title required'); // 紧凑
843
+ return;
844
+ }
845
+ const fields = {};
846
+ let newlyBound = ''; // 本次真正新增的绑定会话
847
+ for (const k of ['title', 'description', 'block_text']) {
848
+ if (pyHas(body, k)) {
849
+ // 标题与建卡同限 200 字,其余长文本 20000
850
+ fields[k] = cpSlice(pyStrFull(pyGet(body, k)), k === 'title' ? 200 : 20000);
851
+ }
852
+ }
853
+ if (pyHas(body, 'scheduled_at')) {
854
+ const v = pyGet(body, 'scheduled_at');
855
+ if (!pyTruthy(v)) {
856
+ fields.scheduled_at = null; // Python `int(v) if v else None`
857
+ } else {
858
+ const n = board.pyIntLoose(v);
859
+ if (n === null) { ctx.error(400, 'bad scheduled_at'); return; }
860
+ fields.scheduled_at = n;
861
+ }
862
+ }
863
+ if (pyHas(body, 'session_id')) { // 设主会话(须已在 sessions 列表内)
864
+ const sid = pyStrFull(pyGet(body, 'session_id'));
865
+ if (cardSessionList(card).includes(sid)) fields.session_id = sid;
866
+ }
867
+ if (pyHas(body, 'bind_session')) { // 绑定外部会话 id
868
+ const sid = pyStrip(pyStrFull(pyGet(body, 'bind_session')));
869
+ if (sid) {
870
+ const sessions = cardSessionList(card);
871
+ if (!sessions.includes(sid)) {
872
+ sessions.push(sid);
873
+ newlyBound = sid;
874
+ }
875
+ fields.sessions = pyJsonDumps(sessions); // Python `json.dumps(..., ensure_ascii=False)`
876
+ if (!card.session_id) fields.session_id = sid;
877
+ }
878
+ }
879
+ if (pyHas(body, 'unbind_session')) { // 解绑(主会话被解绑时取剩余第一个)
880
+ const sid = pyStrFull(pyGet(body, 'unbind_session'));
881
+ const sessions = cardSessionList(card).filter((s) => s !== sid);
882
+ fields.sessions = pyJsonDumps(sessions);
883
+ if (card.session_id === sid) fields.session_id = sessions.length ? sessions[0] : '';
884
+ }
885
+ if (pyHas(body, 'parent_card_id')) {
886
+ const err = board.setParent(row.id, cardId, pyGet(body, 'parent_card_id'), { db: ctx.db });
887
+ if (err) { ctx.error(400, '循环依赖或父任务不存在'); return; }
888
+ }
889
+ if (Object.keys(fields).length) {
890
+ dbmod.updateBoardCard(cardId, true, fields, { db: ctx.db });
891
+ }
892
+ if (newlyBound) {
893
+ // 归档同步(I2「done 卡全部绑定会话都归档」):best-effort,失败只记提示 + 退避重试。
894
+ // 注意 `archiveBoundSession` 的第三参是 **reason**(Python 同签名),没有 options 位。
895
+ await board.archiveBoundSession(dbmod.getBoardCard(cardId, { db: ctx.db }), newlyBound);
896
+ }
897
+ let renameInfo = null;
898
+ if (pyHas(fields, 'title') && pyStrip(fields.title)
899
+ !== pyStrip(card.title || '')) {
900
+ // 卡面改名 → 同步改 DSH 会话名(只认改后的主会话);best-effort,失败不改卡面
901
+ const fresh = dbmod.getBoardCard(cardId, { db: ctx.db });
902
+ const sid = String((fresh ? fresh.session_id : '') || '');
903
+ if (sid) {
904
+ const [ok, err, accepted] = await board.renameCardSession(sid, pyStrip(fields.title));
905
+ renameInfo = { ok, session_id: sid };
906
+ if (!ok) {
907
+ renameInfo.error = err;
908
+ } else if (accepted && accepted !== fields.title) {
909
+ // 宿主规范化/截断:只如实上报,**不回写卡面**(卡面是用户原文)
910
+ renameInfo.accepted_title = accepted;
911
+ }
912
+ }
913
+ }
914
+ const payload = board.cardJson(dbmod.getBoardCard(cardId, { db: ctx.db }), {}, { db: ctx.db });
915
+ if (renameInfo) payload.session_rename = renameInfo;
916
+ ctx.json(200, payload);
917
+ }
918
+
919
+ /** `POST .../board/cards/<cid>/interaction/answer`:作答/审批(`server.py:3703`) */
920
+ async function apiBoardAnswerInteraction(ctx, projectId, cardId) {
921
+ const body = await ctx.readBody();
922
+ const [row, card] = boardOwned(ctx, projectId, cardId);
923
+ if (row === null) return;
924
+ const fam = agents.agentFamily(row.agent_path);
925
+ if (fam !== 'dsh_plugin') {
926
+ ctx.errorSpaced(400, '该 agent 族不支持回答问题');
927
+ return;
928
+ }
929
+ let err;
930
+ if (pyTruthy(pyGet(body, 'approval_id'))) {
931
+ // Python 四处都是 `str(body.get(x) or "")` —— **必须带 `or ""`**:`str(None)` 是 `'None'`,
932
+ // 少了它,缺 `qid` 的请求会拿 `'None'` 去比对 qid 白名单,错误文案从
933
+ // `qid/approval_id required` 悄悄变成「提问不存在或已过期」(M5-4c 单测抓到)。
934
+ const orEmpty = (k) => pyStrScalar(pyTruthy(pyGet(body, k)) ? pyGet(body, k) : '');
935
+ err = await board.answerApproval(row, card, orEmpty('approval_id'),
936
+ orEmpty('decision'), orEmpty('scope'), { db: ctx.db });
937
+ } else {
938
+ const qid = pyStrScalar(pyTruthy(pyGet(body, 'qid')) ? pyGet(body, 'qid') : '');
939
+ if (!qid) { ctx.error(400, 'qid/approval_id required'); return; }
940
+ err = await board.answerInteraction(row, card, qid, pyGet(body, 'answers') || [],
941
+ { db: ctx.db });
942
+ }
943
+ if (err) { ctx.errorSpaced(400, err); return; }
944
+ // 一律入队(闲时直送已删)⇒ queued 由 `is_answer_pending` 现读表派生
945
+ ctx.json(200, { ok: true, queued: Boolean(board.isAnswerPending(card.id, { db: ctx.db })) });
946
+ }
947
+
948
+ /** `POST .../board/cards/<cid>/answer/deliver`:「立即送达」待送达答案(`server.py:3750`) */
949
+ async function apiBoardDeliverAnswer(ctx, projectId, cardId) {
950
+ const [row] = boardOwned(ctx, projectId, cardId);
951
+ if (row === null) return;
952
+ const [ok, err] = await board.deliverPendingAnswerNow(cardId, { db: ctx.db });
953
+ if (!ok) { ctx.errorSpaced(400, err); return; }
954
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
955
+ }
956
+
957
+ /** `POST .../board/sessions/<sid>/profile`:会话级模型/权限档/思考等级(`server.py:3577`) */
958
+ async function apiBoardSessionProfile(ctx, projectId, sid) {
959
+ const body = await ctx.readBody();
960
+ const row = ctx.ownedProject(projectId);
961
+ if (row === null) return;
962
+ if (boardOwnsSid(row, sid, ctx.db) === null) { ctx.error(404, 'session not found'); return; }
963
+ const fam = agents.agentFamily(row.agent_path);
964
+ const model = pyStrScalar(pyTruthy(pyGet(body, 'model')) ? pyGet(body, 'model') : '');
965
+ const pmode = pyStrScalar(pyTruthy(pyGet(body, 'permission_mode'))
966
+ ? pyGet(body, 'permission_mode') : '');
967
+ const effort = pyHas(body, 'reasoning_effort')
968
+ ? normalizeEffort(pyGet(body, 'reasoning_effort')) : '';
969
+ if (fam !== 'dsh_plugin') {
970
+ ctx.errorSpaced(400, '该 agent 族不支持会话级配置');
971
+ return;
972
+ }
973
+ if (effort === null) {
974
+ ctx.errorSpaced(400, `思考等级非法(${DSH_EFFORT_LEVELS.join('/')} 或留空)`);
975
+ return;
976
+ }
977
+ if (!model && !pmode && !effort) {
978
+ ctx.error(400, 'model/permission_mode/reasoning_effort required'); // 紧凑
979
+ return;
980
+ }
981
+ if (pmode && !Object.prototype.hasOwnProperty.call(DSH_PERMISSION_PRESETS, pmode)) {
982
+ ctx.errorSpaced(400, '权限档非法(manual/yolo/auto)');
983
+ return;
984
+ }
985
+ try {
986
+ if (pmode) {
987
+ // 三档 mode 一并下传(宿主 preset 到三档是多对一,只靠回读 preset 反推不出档位)
988
+ await dshdriver.setPermission(sid, DSH_PERMISSION_PRESETS[pmode], pmode);
989
+ }
990
+ if (model || effort) {
991
+ // 只改思考等级时 model 传空串:插件从会话当前模型选择回读 provider/model
992
+ await dshdriver.setModel(sid, model, '', effort);
993
+ }
994
+ } catch (e) {
995
+ if (e instanceof DshDriverError) { ctx.errorSpaced(502, pyStrErr(e)); return; }
996
+ throw e;
997
+ }
998
+ ctx.json(200, { ok: true, model, permission_mode: pmode, reasoning_effort: effort || '' });
999
+ }
1000
+
1001
+ /** `POST .../board/sessions/<sid>/inject`:排队消息立即注入(`server.py:3765`) */
1002
+ async function apiBoardSessionInject(ctx, projectId, sid) {
1003
+ const body = await ctx.readBody();
1004
+ const row = ctx.ownedProject(projectId);
1005
+ if (row === null) return;
1006
+ if (boardOwnsSid(row, sid, ctx.db) === null) { ctx.error(404, 'session not found'); return; }
1007
+ // 与任务族同实现(`_inject_msg`);taskId=null ⇒ 归属判定只查 sid/project_id
1008
+ await injectMsg(ctx, row, sid, body, null);
1009
+ }
1010
+
1011
+ /** `POST .../board/sessions/<sid>/rewind`:会话回退=按边界 fork(`server.py:3833`) */
1012
+ async function apiBoardSessionRewind(ctx, projectId, sid) {
1013
+ const body = await ctx.readBody();
1014
+ const row = ctx.ownedProject(projectId);
1015
+ if (row === null) return;
1016
+ if (boardOwnsSid(row, sid, ctx.db) === null) { ctx.error(404, 'session not found'); return; }
1017
+ const fam = agents.agentFamily(row.agent_path);
1018
+ if (fam !== 'dsh_plugin') {
1019
+ ctx.errorSpaced(400, '该 agent 族不支持会话回退');
1020
+ return;
1021
+ }
1022
+ const mid = pyStrScalar(pyTruthy(pyGet(body, 'mid')) ? pyGet(body, 'mid') : '');
1023
+ const boundary = sessparse.dsh_fork_boundary(sid, mid);
1024
+ if (boundary === null) {
1025
+ ctx.errorSpaced(409, '该提问已被回退或会话已变化,请刷新后重试');
1026
+ return;
1027
+ }
1028
+ let newSid;
1029
+ try {
1030
+ newSid = String(((await dshdriver.fork(sid, boundary)) || {}).new_session_id || '');
1031
+ if (!newSid) throw new DshDriverError(-2, 'fork 未返回新会话 id');
1032
+ // 接进驱动池:UI 切过去后可直接续聊(不接池则 /rename、/compact 会 404)
1033
+ await dshdriver.ensureSession(newSid, row.project_dir, `rewind-${sid.slice(0, 8)}`);
1034
+ } catch (e) {
1035
+ if (e instanceof DshDriverError) {
1036
+ ctx.errorSpaced(502, `回退失败:${pyStrErr(e)}`);
1037
+ return;
1038
+ }
1039
+ throw e;
1040
+ }
1041
+ // 只创建新会话,**不改绑定**(切换是 UI 的显式动作);`rebound` 恒 false(看板路径无 task)
1042
+ ctx.json(200, { ok: true, new_session_id: newSid, boundary, rebound: false });
1043
+ }
1044
+
1045
+ /** `POST .../board/jira/(test|import)`:Jira 试连/导入(`server.py:3404`) */
1046
+ async function apiBoardJira(ctx, projectId, action) {
1047
+ const body = await ctx.readBody();
1048
+ const row = ctx.ownedProject(projectId);
1049
+ if (row === null) return;
1050
+ let result;
1051
+ if (action === 'test') {
1052
+ const cfg = {
1053
+ url: pyStrScalar(pyGet(body, 'url') || ''),
1054
+ user: pyStrScalar(pyGet(body, 'user') || ''),
1055
+ token: pyStrScalar(pyGet(body, 'token') || ''),
1056
+ };
1057
+ result = await board.jiraMyself(cfg);
1058
+ } else {
1059
+ result = await board.jiraImport(row.id, { db: ctx.db });
1060
+ }
1061
+ ctx.json(pyTruthy(pyGet(result, 'ok')) ? 200 : 400, result);
1062
+ }
1063
+
1064
+ /**
1065
+ * 路由表(顺序=匹配顺序;见 `./index.js` 文件头与 `server.py:_route_get`)。
1066
+ * 两条 SSE 必须排在最前:`/board/sessions/<sid>/stream` 会被 `/board/sessions` 前缀吞掉吗?
1067
+ * 不会——匹配是整段正则,但**顺序仍照 Python**(先流、后普通),免得以后有人放宽正则时踩坑。
1068
+ */
1069
+ export const routes = [
1070
+ { method: 'GET', re: /^\/api\/projects\/(\d+)\/board\/stream$/, handler: boardStream },
1071
+ {
1072
+ method: 'GET',
1073
+ re: /^\/api\/projects\/(\d+)\/board\/sessions\/([^/]+)\/stream$/,
1074
+ handler: boardSessionStream,
1075
+ },
1076
+ { method: 'GET', re: /^\/api\/projects\/(\d+)\/board$/, handler: getBoard },
1077
+ { method: 'GET', re: /^\/api\/projects\/(\d+)\/board\/sessions$/, handler: boardSessions },
1078
+ {
1079
+ method: 'GET',
1080
+ re: /^\/api\/projects\/(\d+)\/board\/media\/([A-Za-z0-9._-]+)$/,
1081
+ handler: boardGetMedia,
1082
+ },
1083
+ { method: 'GET', re: /^\/api\/projects\/(\d+)\/board\/trash$/, handler: boardTrashList },
1084
+ {
1085
+ method: 'GET',
1086
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/worktree$/,
1087
+ handler: cardWorktreePreview,
1088
+ },
1089
+ // ---------- 写路径(顺序照 Python 各 do_* 链里的先后;M5-4c 的条目已按同位补进来)----------
1090
+ // do_POST 链:建卡 → 附件上传 → 拖排 → 回收站还原 → 清空回收站 → 卡片动作
1091
+ // → worktree 回流 → 标记已读 → compact/fork → 作答 → 立即送达
1092
+ // → 会话 profile/inject/rewind → 加评论 → 投递评论 → jira
1093
+ {
1094
+ method: 'POST',
1095
+ re: /^\/api\/projects\/(\d+)\/board\/cards$/,
1096
+ handler: (ctx, m) => apiBoardCreateCard(ctx, Number(m[1])),
1097
+ },
1098
+ {
1099
+ method: 'POST',
1100
+ re: /^\/api\/projects\/(\d+)\/board\/media$/,
1101
+ handler: (ctx, m) => apiBoardUploadMedia(ctx, Number(m[1])),
1102
+ },
1103
+ {
1104
+ method: 'POST',
1105
+ re: /^\/api\/projects\/(\d+)\/board\/reorder$/,
1106
+ handler: (ctx, m) => apiBoardReorder(ctx, Number(m[1])),
1107
+ },
1108
+ {
1109
+ method: 'POST',
1110
+ re: /^\/api\/projects\/(\d+)\/board\/trash\/(\d+)\/restore$/,
1111
+ handler: (ctx, m) => apiBoardTrashRestore(ctx, Number(m[1]), Number(m[2])),
1112
+ },
1113
+ {
1114
+ method: 'POST',
1115
+ re: /^\/api\/projects\/(\d+)\/board\/trash\/empty$/,
1116
+ handler: (ctx, m) => apiBoardTrashEmpty(ctx, Number(m[1])),
1117
+ },
1118
+ {
1119
+ method: 'POST',
1120
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/(move|start|stop)$/,
1121
+ handler: (ctx, m) => apiBoardCardAction(ctx, Number(m[1]), Number(m[2]), m[3]),
1122
+ },
1123
+ {
1124
+ method: 'POST',
1125
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/worktree\/merge$/,
1126
+ handler: (ctx, m) => apiBoardWorktreeMerge(ctx, Number(m[1]), Number(m[2])),
1127
+ },
1128
+ {
1129
+ method: 'POST',
1130
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/viewed$/,
1131
+ handler: (ctx, m) => apiBoardMarkViewed(ctx, Number(m[1]), Number(m[2])),
1132
+ },
1133
+ {
1134
+ method: 'POST',
1135
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/compact$/,
1136
+ handler: (ctx, m) => apiBoardSessionOp(ctx, Number(m[1]), Number(m[2]), 'compact'),
1137
+ },
1138
+ {
1139
+ method: 'POST',
1140
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/fork-compact$/,
1141
+ handler: (ctx, m) => apiBoardSessionOp(ctx, Number(m[1]), Number(m[2]), 'fork-compact'),
1142
+ },
1143
+ {
1144
+ method: 'POST',
1145
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/fork$/,
1146
+ handler: (ctx, m) => apiBoardSessionOp(ctx, Number(m[1]), Number(m[2]), 'fork'),
1147
+ },
1148
+ {
1149
+ method: 'POST',
1150
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/interaction\/answer$/,
1151
+ handler: (ctx, m) => apiBoardAnswerInteraction(ctx, Number(m[1]), Number(m[2])),
1152
+ },
1153
+ {
1154
+ method: 'POST',
1155
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/answer\/deliver$/,
1156
+ handler: (ctx, m) => apiBoardDeliverAnswer(ctx, Number(m[1]), Number(m[2])),
1157
+ },
1158
+ {
1159
+ method: 'POST',
1160
+ re: /^\/api\/projects\/(\d+)\/board\/sessions\/([\w-]+)\/profile$/,
1161
+ handler: (ctx, m) => apiBoardSessionProfile(ctx, Number(m[1]), m[2]),
1162
+ },
1163
+ {
1164
+ method: 'POST',
1165
+ re: /^\/api\/projects\/(\d+)\/board\/sessions\/([\w-]+)\/inject$/,
1166
+ handler: (ctx, m) => apiBoardSessionInject(ctx, Number(m[1]), m[2]),
1167
+ },
1168
+ {
1169
+ method: 'POST',
1170
+ re: /^\/api\/projects\/(\d+)\/board\/sessions\/([\w-]+)\/rewind$/,
1171
+ handler: (ctx, m) => apiBoardSessionRewind(ctx, Number(m[1]), m[2]),
1172
+ },
1173
+ {
1174
+ method: 'POST',
1175
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/comments\/(\d+)\/send$/,
1176
+ handler: (ctx, m) => apiBoardSendComment(ctx, Number(m[1]), Number(m[2]), Number(m[3])),
1177
+ },
1178
+ {
1179
+ method: 'POST',
1180
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/comments$/,
1181
+ handler: (ctx, m) => apiBoardAddComment(ctx, Number(m[1]), Number(m[2])),
1182
+ },
1183
+ {
1184
+ method: 'POST',
1185
+ re: /^\/api\/projects\/(\d+)\/board\/jira\/(test|import)$/,
1186
+ handler: (ctx, m) => apiBoardJira(ctx, Number(m[1]), m[2]),
1187
+ },
1188
+ // do_PATCH 链:改卡 → 看板设置
1189
+ {
1190
+ method: 'PATCH',
1191
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)$/,
1192
+ handler: (ctx, m) => apiBoardUpdateCard(ctx, Number(m[1]), Number(m[2])),
1193
+ },
1194
+ {
1195
+ method: 'PATCH',
1196
+ re: /^\/api\/projects\/(\d+)\/board\/settings$/,
1197
+ handler: (ctx, m) => apiBoardUpdateSettings(ctx, Number(m[1])),
1198
+ },
1199
+ // do_DELETE 链:彻底删卡 → worktree 清理 → 删卡(软删)→ 删评论
1200
+ {
1201
+ method: 'DELETE',
1202
+ re: /^\/api\/projects\/(\d+)\/board\/trash\/(\d+)$/,
1203
+ handler: (ctx, m) => apiBoardTrashPurge(ctx, Number(m[1]), Number(m[2])),
1204
+ },
1205
+ {
1206
+ method: 'DELETE',
1207
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/worktree$/,
1208
+ handler: (ctx, m) => apiBoardWorktreeCleanup(ctx, Number(m[1]), Number(m[2])),
1209
+ },
1210
+ {
1211
+ method: 'DELETE',
1212
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)$/,
1213
+ handler: (ctx, m) => apiBoardDeleteCard(ctx, Number(m[1]), Number(m[2])),
1214
+ },
1215
+ {
1216
+ method: 'DELETE',
1217
+ re: /^\/api\/projects\/(\d+)\/board\/cards\/(\d+)\/comments\/(\d+)$/,
1218
+ handler: (ctx, m) => apiBoardDeleteComment(ctx, Number(m[1]), Number(m[2]), Number(m[3])),
1219
+ },
1220
+ ];