@gavinc-cn/touchstone-dsh 0.2.0 → 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 (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,207 @@
1
+ /**
2
+ * SSE 消费(`dshdriver._stream_events` 的 Node 对应物):会话事件流与全局状态流共用。
3
+ *
4
+ * 为什么要单独一层:两版后端都要订阅 dsh 插件的事件流(`/events?scope=session|state`),
5
+ * 而这一层的**坑全在收尾与超时**上(Python 侧踩过):SocketIO 读超时会永久毒化连接、
6
+ * `read(n)` 对 chunked 响应会攒够 n 字节才返回(长连 SSE 下等于永不返回)。Node 侧对应的是
7
+ * `fetch` + `ReadableStream` 读取 + 空闲计时器 + AbortController 主动断开。
8
+ *
9
+ * 契约(与 Python `_stream_events` 逐条对齐):
10
+ * - 帧 = `data:` 行里的 JSON(`dshdriver.parse_stream_line`);注释/空行/其他字段忽略;
11
+ * - 非 200 ⇒ 抛 `SseError(status, '事件流被拒: …')`(正文取前 200 字,便于排障);
12
+ * - **空闲超时**:`idleTimeoutMs` 内没有任何字节(含 keepalive 注释)⇒ `SseError(-2, '事件流空闲超时…')`
13
+ * —— 任一 chunk(哪怕只是注释)都会重置计时;
14
+ * - **对端关闭**:流 EOF 且未请求停止 ⇒ `SseError(-2, '事件流被对端关闭')`;
15
+ * - **主动断开不算异常**:`stop()` 返回真(或 `signal` 被 abort)⇒ 正常返回,`stopped=true`;
16
+ * - 返回 `{frames, lastSeq, stopped}`,`lastSeq` 是见过的最大 `seq`(重连时传回去续传)。
17
+ */
18
+ /** 驱动侧链路错误(与 Python `DshDriverError` 同形:`code` + 中文消息) */
19
+ export class SseError extends Error {
20
+ /**
21
+ * @param {number} code 0=HTTP 状态异常;-1=未配置;-2=链路问题(超时/被关)
22
+ * @param {string} message
23
+ */
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.name = 'SseError';
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ /** 停止轮询间隔(对齐 Python `dshdriver.STOP_WATCH_INTERVAL=0.25s`) */
32
+ export const STOP_WATCH_INTERVAL_MS = 250;
33
+
34
+ /**
35
+ * 把一行 SSE 文本解析成帧;注释/空行/非 data 行返回 null(对齐 `parse_stream_line`)。
36
+ * @param {string} line
37
+ * @returns {object|null}
38
+ */
39
+ export function parseStreamLine(line) {
40
+ if (typeof line !== 'string' || !line.startsWith('data:')) return null;
41
+ try {
42
+ const value = JSON.parse(line.slice('data:'.length).trim());
43
+ return value !== null && typeof value === 'object' ? value : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 解析一个 `\n\n` 分隔的 SSE 块 → 帧数组(Python 是逐行解析,故一个块可能出多帧)。
51
+ * @param {string} block
52
+ * @returns {object[]}
53
+ */
54
+ export function parseSseBlock(block) {
55
+ const frames = [];
56
+ for (const line of String(block).split('\n')) {
57
+ const frame = parseStreamLine(line.trim());
58
+ if (frame !== null) frames.push(frame);
59
+ }
60
+ return frames;
61
+ }
62
+
63
+ /**
64
+ * 订阅一个 SSE 流直到:`stop()` 为真 / `signal` 被 abort / 对端关闭 / 空闲超时。
65
+ *
66
+ * @param {string} url 完整 URL(含 query,调用方负责拼 `since`/`scope`)
67
+ * @param {object} [opts]
68
+ * @param {object} [opts.headers] 附加请求头(驱动 token 等)
69
+ * @param {number} [opts.since] 起始 seq(只用于统计;补发由服务端按 query 处理)
70
+ * @param {(frame: object) => void} [opts.onFrame] 每帧回调
71
+ * @param {() => boolean} [opts.stop] 主动停止探测(每 250ms 查一次)
72
+ * @param {AbortSignal} [opts.signal] 外部取消信号(与 stop 等价)
73
+ * @param {number} [opts.idleTimeoutMs] 空闲超时(缺省 90s,对齐 `STREAM_IDLE_TIMEOUT`)
74
+ * @param {typeof fetch} [opts.fetchImpl] 注入用(测试)
75
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}>}
76
+ */
77
+ export async function streamEvents(url, {
78
+ headers = {},
79
+ since = 0,
80
+ onFrame,
81
+ stop,
82
+ signal,
83
+ idleTimeoutMs = 90000,
84
+ fetchImpl = fetch,
85
+ } = {}) {
86
+ const controller = new AbortController();
87
+ let lastSeq = Number.isInteger(since) ? since : 0;
88
+ let frames = 0;
89
+ let stopped = false;
90
+ let idleTimer = null;
91
+ // `stop()` 按**真值**判定(对齐 Python `if stop is not None and stop():`):只认 `=== true`
92
+ // 会把 `() => 1` 这类真值回调误判成「未停止」,长寿命订阅下永不收尾。
93
+ const stopWanted = () => (typeof stop === 'function' && Boolean(stop()))
94
+ || (signal !== undefined && signal.aborted === true);
95
+ const armIdle = () => {
96
+ if (idleTimer) clearTimeout(idleTimer);
97
+ idleTimer = setTimeout(() => controller.abort(new SseError(-2,
98
+ `事件流空闲超时(>${Math.round(idleTimeoutMs / 1000)}s 无数据)`)), idleTimeoutMs);
99
+ if (idleTimer.unref) idleTimer.unref();
100
+ };
101
+ // 主动停止探测:fetch 的 body reader 阻塞时不响应外部状态,只能旁路轮询 + abort
102
+ const watcher = setInterval(() => {
103
+ if (stopWanted()) {
104
+ stopped = true;
105
+ controller.abort(new SseError(0, 'stopped'));
106
+ }
107
+ }, STOP_WATCH_INTERVAL_MS);
108
+ if (watcher.unref) watcher.unref();
109
+ // 外部取消信号:监听器必须成对摘除(`signal` 往往是长寿命对象——平台侧复用它做整体收尾,
110
+ // 每轮重连都挂一个监听器会在它上面线性堆积 ⇒ 内存与误触发风险)
111
+ const onAbort = () => {
112
+ stopped = true;
113
+ controller.abort(new SseError(0, 'aborted'));
114
+ };
115
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
116
+
117
+ let response;
118
+ let reader = null;
119
+ try {
120
+ armIdle();
121
+ try {
122
+ response = await fetchImpl(url, {
123
+ method: 'GET',
124
+ headers: { accept: 'text/event-stream', ...headers },
125
+ signal: controller.signal,
126
+ });
127
+ } catch (error) {
128
+ // 连接/响应头阶段被 abort 时 fetch 抛的是 AbortError。**先判主动停止**(那不是异常),
129
+ // 再把「我们自己塞进去的 SseError」还原出来(如空闲超时),最后才当链路失败。
130
+ if (stopped || stopWanted()) return { frames, lastSeq, stopped: true };
131
+ const reason = controller.signal.reason;
132
+ if (reason instanceof SseError) throw reason;
133
+ throw new SseError(-2, `事件流连接失败: ${(error && error.message) || error}`);
134
+ }
135
+ if (response.status !== 200) {
136
+ const detail = await response.text().catch(() => '');
137
+ throw new SseError(response.status, `事件流被拒: ${detail.slice(0, 200)}`);
138
+ }
139
+ reader = response.body.getReader();
140
+ const decoder = new TextDecoder('utf-8');
141
+ let buffer = '';
142
+ for (;;) {
143
+ armIdle();
144
+ let chunk;
145
+ try {
146
+ chunk = await reader.read();
147
+ } catch (error) {
148
+ // abort 唤醒的读会以 AbortError 形式回来:主动断开不算链路异常
149
+ if (stopped || stopWanted()) { stopped = true; break; }
150
+ const reason = controller.signal.reason;
151
+ if (reason instanceof SseError) throw reason;
152
+ throw new SseError(-2, `事件流读取失败: ${(error && error.message) || error}`);
153
+ }
154
+ if (chunk.done) {
155
+ if (stopped || stopWanted()) { stopped = true; break; }
156
+ throw new SseError(-2, '事件流被对端关闭');
157
+ }
158
+ buffer += decoder.decode(chunk.value, { stream: true });
159
+ while (buffer.includes('\n\n')) {
160
+ const idx = buffer.indexOf('\n\n');
161
+ const block = buffer.slice(0, idx);
162
+ buffer = buffer.slice(idx + 2);
163
+ for (const frame of parseSseBlock(block)) {
164
+ frames += 1;
165
+ if (Number.isInteger(frame.seq) && frame.seq > lastSeq) lastSeq = frame.seq;
166
+ if (typeof onFrame === 'function') {
167
+ try {
168
+ onFrame(frame);
169
+ } catch {
170
+ // 回调抛异常不影响订阅循环(对齐 Python feed_into 的 on_frame 容错)
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+ } finally {
177
+ if (idleTimer) clearTimeout(idleTimer);
178
+ clearInterval(watcher);
179
+ if (signal) signal.removeEventListener('abort', onAbort);
180
+ // 收尾顺序与实测口径(评审 2026-10-11 复核):
181
+ // ① `reader.cancel()` 让读取端立即放弃(不 cancel 也不会长期泄漏——实测 5 轮「连上→stop」
182
+ // 后服务端打开 socket 数都是 0,真正兜底的是 ② 的 abort;但显式 cancel 能让
183
+ // HTTP 层尽早复用/关闭连接,且避免 reader 挂着 pending read);
184
+ // ② `controller.abort()` 才是**保证**连接释放的那一步(fetch 的 socket 归它管)。
185
+ // ⚠ 不要写成「不 cancel 会挂到 GC 才释放」——那是过度声称,实测不成立。
186
+ if (reader) {
187
+ try {
188
+ await reader.cancel();
189
+ } catch { /* 已关闭/已取消:忽略 */ }
190
+ }
191
+ try {
192
+ controller.abort();
193
+ } catch { /* 已 abort:忽略 */ }
194
+ }
195
+ return { frames, lastSeq, stopped };
196
+ }
197
+
198
+ /** 小工具:把 base + path + query 拼成 URL(对齐 Python 的 `f"{base}/events?{query}"`) */
199
+ export function sseUrl(base, pathName, query = {}) {
200
+ const usp = new URLSearchParams();
201
+ for (const [k, v] of Object.entries(query)) {
202
+ if (v === undefined || v === null || v === '') continue;
203
+ usp.append(k, String(v));
204
+ }
205
+ const qs = usp.toString();
206
+ return `${String(base).replace(/\/$/, '')}${pathName}${qs ? `?${qs}` : ''}`;
207
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 静态托管 + SPA 全路由回退(Node 后端的 `server.py:_serve_static()` / `_send_file()`)。
3
+ *
4
+ * 为什么逐条对齐 Python:前端 `webui/dist-plugin` 两版共用同一份产物,它是契约测试本身
5
+ * (方案 §3.5)——静态语义差一点,SPA 的深链、资源 404 与 MIME 就会在 Node 版下变异。
6
+ * 语义(照抄 Python):
7
+ * ① 空相对路径 ⇒ index.html;
8
+ * ② normpath 后逃出 WEB_DIR ⇒ 403 `forbidden`(text/plain);
9
+ * ③ 目录**或文件不存在** ⇒ 回退 index.html(这就是 SPA 回退,`/app`、`/haha.js` 都走它);
10
+ * ④ 扩展名 → MIME 表(未知 = application/octet-stream);⑤ 读不到文件 ⇒ 404 `not found`。
11
+ * 路径不做百分号解码:Python 侧 `self.path` 也不解码,`%2e%2e` 在两个实现里同样不构成越界。
12
+ */
13
+ import { statSync } from 'node:fs';
14
+ import { readFile, stat } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ import { respond } from './http.js';
18
+
19
+ /** 扩展名 → MIME。表抄自 `server.py:_serve_static()`。 */
20
+ export function mimeFor(ext) {
21
+ const table = {
22
+ '.html': 'text/html; charset=utf-8',
23
+ '.js': 'application/javascript; charset=utf-8',
24
+ '.mjs': 'application/javascript; charset=utf-8',
25
+ '.css': 'text/css; charset=utf-8',
26
+ '.svg': 'image/svg+xml',
27
+ '.png': 'image/png',
28
+ '.ico': 'image/x-icon',
29
+ '.woff': 'font/woff',
30
+ '.woff2': 'font/woff2',
31
+ '.ttf': 'font/ttf',
32
+ '.json': 'application/json; charset=utf-8',
33
+ '.map': 'application/json; charset=utf-8',
34
+ };
35
+ return table[String(ext).toLowerCase()] || 'application/octet-stream';
36
+ }
37
+
38
+ /**
39
+ * 静态路径解析(`_serve_static` 的解析段,不含 IO)。
40
+ * 返回 `{ forbidden: true }` 或 `{ file, contentType }`。
41
+ * 用同步 stat 是有意的:解析段要在路由分派里同步决定「转发给静态还是回退」,
42
+ * 一次本地 stat 的代价远小于把它拆成异步状态机。
43
+ */
44
+ export function resolveStaticPath(webDir, urlPath) {
45
+ const base = path.resolve(webDir);
46
+ const rel = String(urlPath || '').replace(/^\/+/, '') || 'index.html';
47
+ const full = path.normalize(path.join(base, rel));
48
+ if (full !== base && !full.startsWith(base + path.sep)) return { forbidden: true };
49
+ const index = path.join(base, 'index.html');
50
+ let target = full;
51
+ try {
52
+ if (!statSync(full).isFile()) target = index;
53
+ } catch {
54
+ target = index;
55
+ }
56
+ return { file: target, contentType: mimeFor(path.extname(target)) };
57
+ }
58
+
59
+ /**
60
+ * 服务一个静态请求:越界 → 403,命中 → 200 + MIME,解析到的目标读不到 → 404。
61
+ * (`_send_file` 的 OSError 分支=文件在解析与读取之间消失,Node 侧同样兜住。)
62
+ */
63
+ export async function serveStatic(res, webDir, urlPath) {
64
+ const resolved = resolveStaticPath(webDir, urlPath);
65
+ if (resolved.forbidden) {
66
+ respond(res, 403, 'forbidden', 'text/plain; charset=utf-8');
67
+ return;
68
+ }
69
+ try {
70
+ const data = await readFile(resolved.file);
71
+ respond(res, 200, data, resolved.contentType);
72
+ } catch {
73
+ respond(res, 404, 'not found', 'text/plain; charset=utf-8');
74
+ }
75
+ }
76
+
77
+ /** WEB_DIR 是否就绪(index.html 在不在):启动期校验,别把错误留到首个请求 */
78
+ export async function webDirReady(webDir) {
79
+ try {
80
+ return (await stat(path.join(webDir, 'index.html'))).isFile();
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * 任务族派生逻辑(`server.py:_task_json` / `_session_title` + `runner.py:read_dialogue` /
3
+ * `read_log_tail` 的 Node 对应物,M3 读路径)。
4
+ *
5
+ * 为什么单独一层:`_task_json` 有 29 个字段,其中三个是**派生**的 —— `new_cases` 要走案例库
6
+ * 扫描(StateCache)、`model` 走 `effectiveModel`、`session_title` 走 dsh 会话存储;日志端点
7
+ * 还要把轮次日志解析成对话条目。这些都不属于「鉴权/取数/回响应」的路由职责,单独放一层才能
8
+ * 脱离 HTTP 单测(`backend/test/tasks-route.test.mjs` 直接喂行对象/日志文件)。
9
+ *
10
+ * 归属说明:`read_dialogue` / `read_log_tail` 在 Python 里住在 `runner.py`;Node 侧 B 族的
11
+ * `lib/runner.js` 目前只有队列读函数(M3 只搬了看板/队列需要的那些)。任务族不能因此停摆,
12
+ * 故这两个函数落在这里,**并在文件头显式记录**:若 B 族把 runner 的两个日志读函数收编过去,
13
+ * 本文件应改为复用,避免两份实现。
14
+ *
15
+ * 逐点对齐的细节(都是「看着一样其实不一样」的坑):
16
+ * - 日志行切分是 Python **文本模式 universal newlines**(`readlines()` 把 `\r`/`\r\n` 也算换行)
17
+ * ⇒ 复用 `lib.js:readText()`(已做归一),不是 `split('\n')` 硬切;
18
+ * - `read_log_tail(path, limit)` 的 `limit` 走 Python 切片语义 `[-limit:]`:`0` = 全文、
19
+ * 负数 = 丢掉开头 |limit| 行(实测探针确认);
20
+ * - `### PROMPT ` 后面的内容按 JSON 解析,解析失败**保留原文**(不是跳过该行);
21
+ * - 工具结果按 6000 **码点**截断(Python `len`/切片按码点;JS 的 UTF-16 长度会把 emoji 算两个,
22
+ * 截断还可能劈开代理对 ⇒ 用 `Array.from` 按码点切);
23
+ * - `x or default` 用 Python 真值集合(`[]`/`{}` 是假值,JS 里是真值)⇒ 见 `pyOr`。
24
+ *
25
+ * 已知有意放宽(只影响**坏日志/坏行**,正常库不出现;比 Python 更稳):
26
+ * Python 在 `obj["tool_calls"][i]["function"]` 不是 dict 时抛 AttributeError,会把整个
27
+ * `/dialogue` 打成 500(连接被关)。Node 侧按「缺字段取默认」降级继续 —— 一行坏日志不该让
28
+ * 整个对话流不可读。这类差异在正常 fixture 上不可见。
29
+ */
30
+ import { agentFamily } from './agents.js';
31
+ import { getProject } from './db.js';
32
+ import { isFile, readText } from './lib.js';
33
+ import { dsh_title as dshTitle } from './sessparse.js';
34
+ import { effectiveModel, sessFamily } from './servercaps.js';
35
+
36
+ /** 工具结果/助手摘要单条可返回的最大码点数(对齐 `runner.DIALOGUE_TEXT_LIMIT`) */
37
+ export const DIALOGUE_TEXT_LIMIT = 6000;
38
+ /**
39
+ * 单条对话条目中 JSON 内容「按长度直接截断」时保留的码点数(对齐 `runner.DIALOGUE_RAW_LIMIT`)。
40
+ * M3 的六个读端点用不到它;它属于 `runner.summary_entry_text()`(前端折叠摘要)——那一处随
41
+ * 会话/看板族移植时用这个常量,别在调用点写死 1200。
42
+ */
43
+ export const DIALOGUE_RAW_LIMIT = 1200;
44
+
45
+ /**
46
+ * Python `d.get(key, fallback)`:键**存在**就取键值(哪怕值是 null),否则取默认。
47
+ * 不能用 `obj[key] ?? fallback`(那会把显式 null 也折成默认)。
48
+ * @param {object} obj
49
+ * @param {string} key
50
+ * @param {any} fallback
51
+ * @returns {any}
52
+ */
53
+ function pyGet(obj, key, fallback) {
54
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
55
+ }
56
+
57
+ /**
58
+ * Python `value or fallback` 的真值口径:`None/False/0/''/[]/{}` 都是假值。
59
+ * JS 里 `[]` 与 `{}` 是真值,直接写 `||` 会在「空数组内容」上分叉。
60
+ * @param {any} value
61
+ * @param {any} fallback
62
+ * @returns {any}
63
+ */
64
+ function pyOr(value, fallback) {
65
+ if (value === null || value === undefined || value === false || value === '' || value === 0) {
66
+ return fallback;
67
+ }
68
+ if (Array.isArray(value) && value.length === 0) return fallback;
69
+ if (typeof value === 'object' && Object.keys(value).length === 0) return fallback;
70
+ return value;
71
+ }
72
+
73
+ /**
74
+ * Python `len(s)` / `s[:limit]` 的**码点**口径(JS 按 UTF-16 码元会把代理对算成 2 个)。
75
+ * @param {any} value 字符串或数组(Python 的 str/list 都可切片)
76
+ * @param {number} limit
77
+ * @returns {{length: number, sliced: any}}
78
+ */
79
+ function pySlice(value, limit) {
80
+ if (Array.isArray(value)) return { length: value.length, sliced: value.slice(0, limit) };
81
+ const chars = Array.from(String(value));
82
+ return { length: chars.length, sliced: chars.slice(0, limit).join('') };
83
+ }
84
+
85
+ /**
86
+ * Python `f.readlines()` 的等价:按 `\n` 切分,**每行带结尾换行**,且结尾换行不产生额外空行。
87
+ *
88
+ * 为什么不能用 `text.split('\n')`:文件以换行结尾时它会多出一个 `''` 元素,
89
+ * `[-limit:]` 的窗口就整体错一位(实测:`tail=5` 少回一行 —— 对拍里是正文哈希差异)。
90
+ * @param {string} text 已归一换行的文本(见 `lib.js:readText`)
91
+ * @returns {string[]}
92
+ */
93
+ function pyReadLines(text) {
94
+ const lines = [];
95
+ let start = 0;
96
+ for (;;) {
97
+ const idx = text.indexOf('\n', start);
98
+ if (idx < 0) break;
99
+ lines.push(text.slice(start, idx + 1)); // 行尾换行跟着行走(同 Python readlines)
100
+ start = idx + 1;
101
+ }
102
+ if (start < text.length) lines.push(text.slice(start)); // 末行无换行
103
+ return lines;
104
+ }
105
+
106
+ /**
107
+ * `runner.read_dialogue(rounds)`:把多轮日志解析成结构化对话条目。
108
+ *
109
+ * 识别内容(与 Python 逐条对应):
110
+ * - `### PROMPT <json>` → `{kind:'user'}`(JSON 解析失败则原样当文本)
111
+ * - `{"role":"assistant"}` → `{kind:'assistant', text, tool_calls:[{name,args}]}`
112
+ * - `{"role":"tool"}` → `{kind:'tool_result', tool_call_id, text(截断), truncated, tool_calls:[]}`
113
+ * - `{"role":"meta","type":"session.resume_hint"}` → `{kind:'meta', text: session_id}`
114
+ *
115
+ * @param {object[]} rounds `db.list_rounds()` 的行(按 round_no 升序;本函数再按 round_no 排一次,
116
+ * 与 Python 的 `sorted(rounds, key=...)` 同形 —— JS 的 `sort` 默认按字符串比,必须给数值比较器)
117
+ * @returns {object[]} 条目数组,按日志出现顺序
118
+ */
119
+ export function readDialogue(rounds) {
120
+ const entries = [];
121
+ const ordered = [...rounds].sort((a, b) => (
122
+ a.round_no < b.round_no ? -1 : a.round_no > b.round_no ? 1 : 0));
123
+ for (const round of ordered) {
124
+ const logPath = round.log_path;
125
+ if (!logPath || !isFile(logPath)) continue;
126
+ const text = readText(logPath); // OSError → ''(Python 的 except OSError → continue 同效)
127
+ for (const rawLine of pyReadLines(text)) {
128
+ const line = rawLine.replace(/\n+$/, ''); // 对齐 raw.rstrip("\n")
129
+ if (line.startsWith('### PROMPT ')) {
130
+ let prompt = line.slice('### PROMPT '.length);
131
+ try {
132
+ prompt = JSON.parse(prompt);
133
+ } catch {
134
+ // Python:json.loads 抛 ValueError 就保留原文(不是跳过这一行)
135
+ }
136
+ entries.push({ kind: 'user', round: round.round_no, text: prompt });
137
+ continue;
138
+ }
139
+ if (!line.startsWith('{"role"')) continue;
140
+ let obj;
141
+ try {
142
+ obj = JSON.parse(line);
143
+ } catch {
144
+ continue; // Python:ValueError → continue
145
+ }
146
+ const role = pyGet(obj, 'role', null);
147
+ if (role === 'meta') {
148
+ if (pyGet(obj, 'type', null) === 'session.resume_hint') {
149
+ entries.push({
150
+ kind: 'meta', round: round.round_no, text: pyGet(obj, 'session_id', ''),
151
+ });
152
+ }
153
+ continue;
154
+ }
155
+ if (role === 'assistant') {
156
+ const toolCalls = [];
157
+ const rawCalls = pyOr(pyGet(obj, 'tool_calls', null), []);
158
+ if (Array.isArray(rawCalls)) { // 非数组是坏行:Python 会 AttributeError,这里降级
159
+ for (const tc of rawCalls) {
160
+ const fn = pyOr(pyGet(tc === null || typeof tc !== 'object' ? {} : tc, 'function', {}), {});
161
+ toolCalls.push({
162
+ name: pyGet(fn, 'name', ''),
163
+ args: pyGet(fn, 'arguments', ''),
164
+ });
165
+ }
166
+ }
167
+ entries.push({
168
+ kind: 'assistant',
169
+ round: round.round_no,
170
+ text: pyOr(pyGet(obj, 'content', ''), ''),
171
+ tool_calls: toolCalls,
172
+ });
173
+ continue;
174
+ }
175
+ if (role === 'tool') {
176
+ const content = pyOr(pyGet(obj, 'content', ''), '');
177
+ const { length, sliced } = pySlice(content, DIALOGUE_TEXT_LIMIT);
178
+ entries.push({
179
+ kind: 'tool_result',
180
+ round: round.round_no,
181
+ tool_call_id: pyGet(obj, 'tool_call_id', ''),
182
+ text: sliced,
183
+ truncated: length > DIALOGUE_TEXT_LIMIT,
184
+ tool_calls: [],
185
+ });
186
+ }
187
+ }
188
+ }
189
+ return entries;
190
+ }
191
+
192
+ /**
193
+ * `runner.read_log_tail(path, limit)`:读日志末尾 `limit` 行文本(编码容错)。
194
+ *
195
+ * 注意这是「读全文再取末尾 N 行」(Python 就是 `f.readlines()[-limit:]`),不是 seek 式 tail。
196
+ * `limit` 的切片语义见文件头(0 = 全文、负数 = 丢开头)。
197
+ * @param {string} filePath
198
+ * @param {number} [limit]
199
+ * @returns {string}
200
+ */
201
+ export function readLogTail(filePath, limit = 200) {
202
+ if (!filePath || !isFile(filePath)) return '';
203
+ const text = readText(filePath); // OSError → ''(Python 同)
204
+ // readlines() 口径见 pyReadLines;`slice(-limit)` 与 Python `[-limit:]` 同语义
205
+ // (limit=0 ⇒ 全文;limit<0 ⇒ 丢掉开头 |limit| 行),两者都已用探针实测确认
206
+ return pyReadLines(text).slice(-limit).join('');
207
+ }
208
+
209
+ /**
210
+ * `_task_json` 的 `new_cases`:库内用例总数 − 创建时的基数(负数归零)。
211
+ *
212
+ * 依赖注入 `stateCache`(= `server.py:state_cache`,C 族的 `lib/casescan.js:StateCache`):
213
+ * 传进来的实例负责 1s TTL 与全量扫描。任何异常(含未注入/项目读不到)都折成 0 ——
214
+ * 与 Python `_task_json` 里那圈 `try/except Exception: new_cases = 0` 逐点同形。
215
+ * @param {object} row 任务行
216
+ * @param {{db?: object, stateCache?: object}} [deps]
217
+ * @returns {number}
218
+ */
219
+ function newCases(row, { db, stateCache } = {}) {
220
+ try {
221
+ const project = getProject(pyGet(row, 'project_id', null), { db });
222
+ if (!project) return 0;
223
+ const state = stateCache.get(project.id, project.cases_root, project.bug_dir, project.work_dir);
224
+ const total = state && state.library && state.library.stats ? state.library.stats.total : 0;
225
+ // Python `int(row["cases_base"] or 0)`:非数字会抛 ValueError ⇒ 走 except(new_cases=0);
226
+ // `total` 不是数字时 `total - int(base)` 抛 TypeError ⇒ 同样归 0。JS 不抛,故显式判。
227
+ const base = Number(pyOr(pyGet(row, 'cases_base', 0), 0));
228
+ if (!Number.isFinite(base) || typeof total !== 'number' || !Number.isFinite(total)) return 0;
229
+ return Math.max(0, Math.trunc(total) - Math.trunc(base));
230
+ } catch {
231
+ return 0;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * `_session_title(row)`:任务会话标题(dsh 会话读宿主标题事件;无会话/非 dsh 族/读失败 → '')。
237
+ * @param {object} row 任务行
238
+ * @param {{db?: object}} [deps]
239
+ * @returns {string}
240
+ */
241
+ export function sessionTitle(row, { db } = {}) {
242
+ try {
243
+ const sid = pyOr(pyGet(row, 'session_id', ''), '');
244
+ if (!sid) return '';
245
+ const project = getProject(pyGet(row, 'project_id', null), { db });
246
+ if (!project) return '';
247
+ const family = agentFamily(project.agent_path);
248
+ if (sessFamily(family) === 'dsh') return dshTitle(sid);
249
+ } catch {
250
+ return ''; // Python:except Exception → ''
251
+ }
252
+ return '';
253
+ }
254
+
255
+ /**
256
+ * `_task_json(row)`:任务 JSON(**字段与顺序逐字对齐 server.py:2533**,因为响应体走
257
+ * `pyJsonDumps`,顺序不同就是字节差异;对拍里 `kind:'text'` 用例会直接抓出来)。
258
+ *
259
+ * 三个派生字段:`new_cases`(案例库扫描)、`model`(effectiveModel:显式 > 首轮日志 -m > dsh 默认)、
260
+ * `session_title`(dsh 会话标题)。
261
+ *
262
+ * 缺列老行:Python 对「行里没这一列」会 IndexError(请求 500),但 `migrate()` 保证迁移后列必在
263
+ * —— 这里对 `or "" / or 0` 那几列按空值降级、其余按 null 输出,属**有意的更稳行为**(单测覆盖)。
264
+ * @param {object} row 任务行(`db.get_task` / `db.list_tasks`)
265
+ * @param {{db?: object, stateCache?: object}} [deps]
266
+ * @returns {object}
267
+ */
268
+ export function taskJson(row, { db, stateCache } = {}) {
269
+ const raw = (key) => (row[key] === undefined ? null : row[key]);
270
+ const str0 = (key) => pyOr(row[key] === undefined ? null : row[key], '');
271
+ return {
272
+ id: raw('id'),
273
+ project_id: raw('project_id'),
274
+ name: raw('name'),
275
+ auto_fix: Boolean(row.auto_fix),
276
+ retest: raw('retest'),
277
+ stop_type: raw('stop_type'),
278
+ stop_value: raw('stop_value'),
279
+ task_type: raw('task_type'),
280
+ payload: raw('payload'),
281
+ start_stage: str0('start_stage'),
282
+ end_stage: str0('end_stage'),
283
+ date_from: str0('date_from'),
284
+ date_to: str0('date_to'),
285
+ extra: raw('extra'),
286
+ model: effectiveModel(row.id, row.model, { db }),
287
+ permission: raw('permission'),
288
+ auto_commit: Boolean(row.auto_commit),
289
+ auto_deploy: Boolean(row.auto_deploy),
290
+ auto_retest: Boolean(row.auto_retest),
291
+ new_cases: newCases(row, { db, stateCache }),
292
+ status: raw('status'),
293
+ session_id: raw('session_id'),
294
+ session_title: sessionTitle(row, { db }),
295
+ current_round: raw('current_round'),
296
+ new_bugs: raw('new_bugs'),
297
+ created_at: raw('created_at'),
298
+ started_at: raw('started_at'),
299
+ ended_at: raw('ended_at'),
300
+ error: raw('error'),
301
+ };
302
+ }