@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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.
- package/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +426 -0
- package/backend/lib/proc.js +361 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由族:任务 / 轮次 / 对话 / 轮次日志(M3 读路径)+ 任务族写端点(M4.5)
|
|
3
|
+
*
|
|
4
|
+
* 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头;这里与 `server.py` 的
|
|
5
|
+
* `_route_get` / `do_POST` / `do_PATCH` / `do_DELETE` if/elif 链同序:
|
|
6
|
+
* `/api/projects/<id>/tasks` 在 `/api/tasks/<id>` 之前):
|
|
7
|
+
* - `GET /api/tasks` → 字面桩:`{"error":"task id required"}`
|
|
8
|
+
* - `GET /api/projects/<id>/tasks` → `_api_list_tasks`
|
|
9
|
+
* - `GET /api/tasks/<id>` → `_api_get_task`(含 `_task_json` 派生字段)
|
|
10
|
+
* - `GET /api/tasks/<id>/rounds` → `_api_list_rounds`
|
|
11
|
+
* - `GET /api/tasks/<id>/dialogue` → `_api_task_dialogue`
|
|
12
|
+
* - `GET /api/tasks/<id>/log` → `_api_task_log`(轮次日志分段/全文)
|
|
13
|
+
* - `POST /api/projects/<id>/tasks` → `_api_create_task`(2592)
|
|
14
|
+
* - `POST /api/tasks/<id>/(stop|restart)` → `_api_task_action`(4089)
|
|
15
|
+
* - `POST /api/tasks/<id>/continue` → `_api_continue_task`(4109)
|
|
16
|
+
* - `PATCH /api/tasks/<id>` → `_api_update_task`(4170)
|
|
17
|
+
* - `DELETE /api/tasks/<id>` → `_api_delete_task`(4155)
|
|
18
|
+
*
|
|
19
|
+
* 注意:`/api/tasks/<id>/session/*` 归 `./sessions.js`;`/load/*` 属 M6。
|
|
20
|
+
* 压测读端点(load/case、load/metrics、load/runs、load_report、load/stream…)**不在 M3**。
|
|
21
|
+
*
|
|
22
|
+
* ── 三个必须解释清楚的口径(都是 Python 侧「照抄才不出错」的地方) ──────────────────
|
|
23
|
+
* ① **`/api/tasks` 的响应体是硬编码紧凑 JSON**(Python `b'{"error":"task id required"}'`),
|
|
24
|
+
* 不是 `json.dumps`(那会带 `": "` 空格)。故这里走 `ctx.text()` 原样发,保证字节一致。
|
|
25
|
+
* ② **`tail` 解析失败 = 连接被掐断**:Python `int(parsed["tail"][0])` 抛 ValueError,从 `do_GET`
|
|
26
|
+
* 一路冒到 socketserver ⇒ **不回任何响应、直接关连接**(探针实测:status=0/fetch failed)。
|
|
27
|
+
* Node 侧复刻这个可观察结果(`res.destroy()`),但**绝不抛**——抛出去会变成 unhandledRejection,
|
|
28
|
+
* 默认直接把后端进程干掉,比 Python 严重一个量级。
|
|
29
|
+
* ③ **`round` 只认「全 ASCII 数字」**(`str.isdigit()` 的 ASCII 子集,复用 `lib.js:pyIsDigits`);
|
|
30
|
+
* 其余一律按 0 查(→ 404 `log not found`)。Python 的 `isdigit()` 还认 Unicode 数字
|
|
31
|
+
* (`int('١٢')==12`)、`int('1_0')==10`,这两类**已知差异**记在文件尾注释里,正常前端不产生。
|
|
32
|
+
* ④ 归属校验一律走 `ctx.ownedTask` / `ctx.ownedProject`(多用户隔离红线;两种失败都回
|
|
33
|
+
* `task not found` / `project not found`,与 Python `_api_get_task` 等的文案一致)。
|
|
34
|
+
* ⑤ **写端点的三条硬口径**(M4.5,逐条都在下面注释到具体行):
|
|
35
|
+
* - **读体的位置**:Python 在 `do_POST:1048` 统一读体 ⇒ 本文件每个 POST 处理器开头
|
|
36
|
+
* `await ctx.readBody()`;`PATCH` 在 Python `do_PATCH:1238` 读体,同样读;
|
|
37
|
+
* **`DELETE` 不读**(`do_DELETE` 全程没有 `_read_json()`)——不许图省事统一读。
|
|
38
|
+
* - **错误体的两种形态**:这 5 条端点的错误全部是硬编码字节串(
|
|
39
|
+
* `'{"error":"..."}'.encode("utf-8")`)⇒ 一律 `ctx.error`(紧凑);成功体
|
|
40
|
+
* `b'{"ok":true}'` 是**紧凑字面量**(不是 `json.dumps`)⇒ `ctx.text`,而
|
|
41
|
+
* `_api_create_task` 的成功体走 `json.dumps` ⇒ `ctx.json`(带空格)。写错一个空格
|
|
42
|
+
* 对拍就红,**不许"统一一下更整齐"**。
|
|
43
|
+
* - **Python 的 `str()` / 真值语义**:`str(body.get(...) or "")` 这类表达式在 Python 里
|
|
44
|
+
* 是「真值判定 → str → strip」三步,JS 的 `String()`/`||` 在 `[]`/`{}`/`null` 上与它
|
|
45
|
+
* 分叉,故本文件自带一组私有助手(`pyStr`/`pyTruthy`/`pyStripStr`/`pyStripBare`/
|
|
46
|
+
* `pyIntStrict`/`pyGet`/`pyHas`),口径与 `sessparse.js` 的同名私有助手一致。
|
|
47
|
+
* 其中 `pyStripBare` 复刻「真值非字符串 ⇒ `.strip()` 抛 AttributeError」⇒ 由
|
|
48
|
+
* `dispatchSafely` 掐断该连接(不补响应),与 Python 只断一条连接同形。
|
|
49
|
+
*/
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
53
|
+
|
|
54
|
+
import * as chat from '../lib/chat.js';
|
|
55
|
+
import { deleteTask, getLogRound, getProject, getTask, insertTask, listRounds, listTasks,
|
|
56
|
+
nowStr, updateTask } from '../lib/db.js';
|
|
57
|
+
import { jsonBody } from '../lib/http.js';
|
|
58
|
+
import * as loadcase from '../lib/loadcase.js';
|
|
59
|
+
import { py_field_str as pyFieldStr } from '../lib/loadgen.js';
|
|
60
|
+
import {
|
|
61
|
+
bugCases, bugCasesScripted, isDir, isFile, pyIntOr, pyIsDigits, pyJoin, pyStrip, readText,
|
|
62
|
+
} from '../lib/lib.js';
|
|
63
|
+
import { pyJsonDumps } from '../lib/pyjson.js';
|
|
64
|
+
import * as runner from '../lib/runner.js';
|
|
65
|
+
import { taskSessionChat } from './sessions.js';
|
|
66
|
+
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
// 写口调用口径(M4.5 复核结论,**已知偏差**)
|
|
69
|
+
// 路由层给写口传 `{ db: ctx.db }`(与读口一致),代价是:`lib/db.js:withWriteDb` 在 `{db}`
|
|
70
|
+
// 在场时只 `fn(db)`、**不 BEGIN/COMMIT**(事务边界归调用方)⇒ 多语句写口(`db.delete_task`
|
|
71
|
+
// 的删轮次+删任务、`waitq.msg_enqueue` 的消息行+等待项行)在**进程崩溃的那一瞬**可能半提交;
|
|
72
|
+
// Python 侧每个写口都是 `with connect()` 一事务(db.py:1117-1119 / waitq.py:1195-1209)。
|
|
73
|
+
// 终态在正常路径下完全相同(M4.5 实例级对拍逐 op 比过落库 dump),差异只在崩溃窗口。
|
|
74
|
+
//
|
|
75
|
+
// **为什么现在不修**:`{db}` 在本库内部是「加入调用方事务」的既有惯用法(例:`waitq.cancelAllActive`
|
|
76
|
+
// 与 `_finish_all_ext` 先开一个事务、再循环调用 `cancel(..., {db: handle})`),把 `{db}` 改成
|
|
77
|
+
// 无条件 `BEGIN` 会立刻撞「cannot start a transaction within a transaction」。
|
|
78
|
+
// 正确修法是给 withWriteDb 加一个**独立**选项(如 `{db, tx: true}` = 在本句柄上开事务)、
|
|
79
|
+
// 逐个调用点判定语义,属 M5/M6 的重构项,已登记在 M4.5 实施记录 §六。
|
|
80
|
+
// --------------------------------------------------------------------------
|
|
81
|
+
import { effectiveModel } from '../lib/servercaps.js';
|
|
82
|
+
import { readDialogue, readLogTail, taskJson } from '../lib/taskjson.js';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Python `int(text)` 的严格版(**读路径的 `tail` 用**):解析失败返回 `null`(调用方据此
|
|
86
|
+
* 复刻「掐断连接」)。只认「两侧 Python 空白 + 可选正负号 + ASCII 数字」,见文件头 ③。
|
|
87
|
+
* 写路径(`continue` 的 `int()`,含 `int("1_0")==10`)用下面另一个 `pyIntStrict`。
|
|
88
|
+
* @param {string} value
|
|
89
|
+
* @returns {number|null}
|
|
90
|
+
*/
|
|
91
|
+
function pyIntOrNull(value) {
|
|
92
|
+
const text = pyStrip(value);
|
|
93
|
+
return /^[+-]?[0-9]+$/.test(text) ? Number(text) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 复刻 Python「异常冒到 socketserver ⇒ 关连接、不回响应」的可观察行为。
|
|
98
|
+
* 见文件头 ②:不用 throw(那会变成未捕获拒绝并杀进程 —— 实测:`tail=abc` 会让整个后端退出)。
|
|
99
|
+
* @param {object} ctx
|
|
100
|
+
*/
|
|
101
|
+
function abortLikePython(ctx) {
|
|
102
|
+
ctx.res.destroy();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ==========================================================================
|
|
106
|
+
// 写路径(M4.5):任务族 5 条端点
|
|
107
|
+
// POST /api/projects/<id>/tasks → _api_create_task(server.py:2592)
|
|
108
|
+
// POST /api/tasks/<id>/(stop|restart) → _api_task_action(4089)
|
|
109
|
+
// POST /api/tasks/<id>/continue → _api_continue_task(4109)
|
|
110
|
+
// PATCH /api/tasks/<id> → _api_update_task(4170)
|
|
111
|
+
// DELETE /api/tasks/<id> → _api_delete_task(4155)
|
|
112
|
+
// 常量/助手全部照 server.py 逐行移植;下面每个函数头都标了 Python 基准行号。
|
|
113
|
+
// ==========================================================================
|
|
114
|
+
|
|
115
|
+
/** 任务类型合法值(`server.py:TASK_TYPES`(498))。`pipeline` 也在集合里,随后单独拒手工创建 */
|
|
116
|
+
const TASK_TYPES = new Set([
|
|
117
|
+
'normal', 'fix', 'retest_bug', 'reject', 'script_retest', 'stress', 'regression', 'pipeline',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/** 生命周期阶段 key(有序,index 即先后;`server.py:STAGES`(504)) */
|
|
121
|
+
const STAGES = ['gen_case', 'execute', 'report', 'analyze', 'fix', 'deploy', 'retest'];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 类型 × 阶段矩阵(`server.py:STAGE_MATRIX`(512)):start=固定起点,default=终点缺省,
|
|
125
|
+
* ends=可选终点集合(空=不涉及终点选择)。Python 用元组,这里用数组(`includes` 等价 `in`)。
|
|
126
|
+
*/
|
|
127
|
+
const STAGE_MATRIX = {
|
|
128
|
+
normal: {
|
|
129
|
+
start: 'gen_case',
|
|
130
|
+
default: 'report',
|
|
131
|
+
ends: ['gen_case', 'execute', 'report', 'analyze', 'fix', 'deploy', 'retest'],
|
|
132
|
+
},
|
|
133
|
+
regression: {
|
|
134
|
+
start: 'execute',
|
|
135
|
+
default: 'report',
|
|
136
|
+
ends: ['execute', 'report', 'analyze', 'fix', 'deploy', 'retest'],
|
|
137
|
+
},
|
|
138
|
+
stress: { start: '', default: '', ends: [] },
|
|
139
|
+
fix: { start: 'analyze', default: 'fix', ends: ['analyze', 'fix', 'deploy', 'retest'] },
|
|
140
|
+
retest_bug: { start: '', default: '', ends: [] },
|
|
141
|
+
reject: { start: '', default: '', ends: [] },
|
|
142
|
+
script_retest: { start: '', default: '', ends: [] },
|
|
143
|
+
pipeline: { start: 'analyze', default: '', ends: [] },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** 复测范围三选一 → `[start_stage, end_stage]`(`server.py:RETEST_SCOPES`(528)) */
|
|
147
|
+
const RETEST_SCOPES = {
|
|
148
|
+
retest_only: ['retest', 'retest'],
|
|
149
|
+
deploy_retest: ['deploy', 'retest'],
|
|
150
|
+
deploy_only: ['deploy', 'deploy'],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 提交日期范围格式:`YYYY-MM-DD`(`server.py:DATE_RE`)。
|
|
155
|
+
*
|
|
156
|
+
* **`\d` 必须是 Unicode 面**(`\p{Nd}`):Python 的 `re` 默认 Unicode,阿拉伯-印度数字写的
|
|
157
|
+
* 日期也能过这道 regex,只有到了双侧比较的 `time.strptime` 才失败(见 `normDateRange`)。
|
|
158
|
+
* 用 `[0-9]` 会把「单侧 Unicode 日期」从 200 变成 400(真 Python 站点对拍实测抓到的差异)。
|
|
159
|
+
*/
|
|
160
|
+
const DATE_RE = /^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* bug 报告目录名时间戳前缀(`server.py:BUG_DIR_RE`(120)):`20260822_2027_FS_描述`。
|
|
164
|
+
* `\d` 同样取 Unicode 面(与 `DATE_RE` 同理由:Python 的 `re` 默认 Unicode)。
|
|
165
|
+
*/
|
|
166
|
+
const BUG_DIR_RE = /^\p{Nd}{8}_\p{Nd}{4}_(.+)$/u;
|
|
167
|
+
|
|
168
|
+
/** 停止条件类型(Python 三处都是元组成员判定) */
|
|
169
|
+
const STOP_TYPES = ['rounds', 'bugs', 'deadline', 'duration'];
|
|
170
|
+
|
|
171
|
+
// ---------- Python 语义助手(本文件私有;口径与 sessparse.js 的同名私有助手一致) ----------
|
|
172
|
+
|
|
173
|
+
/** Python 真值:`""`/`0`/`None`/`False`/空容器为假(JS 的 `[]`/`{}` 是 truthy,必须显式收口) */
|
|
174
|
+
function pyTruthy(value) {
|
|
175
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
176
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
|
|
177
|
+
return Boolean(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Python `str()`(只覆盖 JSON 里会出现的类型):str→原样;bool→`True`/`False`;
|
|
182
|
+
* null→`None`;数字→十进制文本;容器→JSON 文本兜底。
|
|
183
|
+
*
|
|
184
|
+
* 容器的兜底与 Python 的 `repr` 形态(`['a']` / `{'k': 1}`)**不逐字一致**——真请求里这些
|
|
185
|
+
* 位置都是字符串,容器只可能来自手写调用/对拍构造;已知差异记在文件尾。
|
|
186
|
+
* `str(None)=='None'` 这条**必须**对(`stop_value: null` 会真的把 `'None'` 落进库,
|
|
187
|
+
* 见下面 `_api_create_task` 的 stop_value 一行)。
|
|
188
|
+
*/
|
|
189
|
+
function pyStr(value) {
|
|
190
|
+
if (typeof value === 'string') return value;
|
|
191
|
+
if (value === null || value === undefined) return 'None';
|
|
192
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
193
|
+
if (typeof value === 'number') return String(value);
|
|
194
|
+
return JSON.stringify(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Python `dict.get(key, dflt)`:键存在但值为 null 时**不**回落 dflt(与 Python 同) */
|
|
198
|
+
function pyGet(obj, key, dflt = '') {
|
|
199
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : dflt;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Python `key in dict`:只看自有键(避免 `'toString' in body` 这种 JS 原型链陷阱) */
|
|
203
|
+
function pyHas(obj, key) {
|
|
204
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Python `str(x or "")`(x 取自请求体;无 strip) */
|
|
208
|
+
function pyStrOrEmpty(value) {
|
|
209
|
+
return pyStr(pyTruthy(value) ? value : '');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Python `str(x or "").strip()`(`str()` 永不抛) */
|
|
213
|
+
function pyStripStr(value) {
|
|
214
|
+
return pyStrip(pyStrOrEmpty(value));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Python `(x or "").strip()` —— **没有** `str()` 包裹的那种写法。
|
|
219
|
+
*
|
|
220
|
+
* 真值但非字符串时 Python 抛 `AttributeError`(`5 .strip()`),一路冒到 socketserver
|
|
221
|
+
* ⇒ **该连接不回响应、直接断**。这里同形抛错,由 `dispatchSafely` 收口成 `res.destroy()`
|
|
222
|
+
* (与文件头 ② 的 `abortLikePython` 同一可观察结果)。受影响的三个位置:
|
|
223
|
+
* `create_task` 的 `bug_dir` / `reason` / `name`(`server.py:2642/2653/2666`)。
|
|
224
|
+
*/
|
|
225
|
+
function pyStripBare(value) {
|
|
226
|
+
if (!pyTruthy(value)) return '';
|
|
227
|
+
if (typeof value !== 'string') {
|
|
228
|
+
throw new TypeError(`AttributeError: '${Array.isArray(value) ? 'list' : typeof value}'`
|
|
229
|
+
+ " object has no attribute 'strip'");
|
|
230
|
+
}
|
|
231
|
+
return pyStrip(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Python `str.strip("/")`:剥掉两端的全部 `/`(`create_task` 的 bug_dir 归一) */
|
|
235
|
+
function pyStripSlashes(value) {
|
|
236
|
+
return value.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Python `int(x)` 字符串分支的字符面(ASCII 数字 + 单个下划线分隔;见 `pyIntStrict`) */
|
|
240
|
+
const PY_INT_STR_RE = /^[+-]?[0-9](?:_?[0-9])*$/;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Python `int(x)` 的严格版(**写路径用**):非法值抛错(调用方按 ValueError 收口)。
|
|
244
|
+
*
|
|
245
|
+
* 为什么不能用 `Number.parseInt`:`parseInt('12px')` 给 12(Python 抛 ValueError);
|
|
246
|
+
* 也不能用 `Number()`:`Number('')` 给 0。接受面逐条对齐 CPython:两侧 Python 空白、
|
|
247
|
+
* 可选正负号、数字间可有**单个**下划线(`int("1_0")==10`、`int("1__0")` 抛)。
|
|
248
|
+
* 已知残余差异:Unicode 数字(`int("١٢")==12`)本实现不认(全仓口径,见文件尾)。
|
|
249
|
+
* @param {unknown} value
|
|
250
|
+
* @returns {number}
|
|
251
|
+
*/
|
|
252
|
+
function pyIntStrict(value) {
|
|
253
|
+
if (typeof value === 'number') {
|
|
254
|
+
if (!Number.isFinite(value)) throw new TypeError(`int() 不接受 ${value}`);
|
|
255
|
+
return Math.trunc(value);
|
|
256
|
+
}
|
|
257
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
258
|
+
const text = pyStrip(value);
|
|
259
|
+
if (!PY_INT_STR_RE.test(text)) {
|
|
260
|
+
throw new TypeError(`invalid literal for int() with base 10: ${JSON.stringify(text)}`);
|
|
261
|
+
}
|
|
262
|
+
return Number(text.replaceAll('_', ''));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Python 集合成员判定的可哈希性:dict/list 不可哈希(`x in <set>` 在那里会抛 TypeError) */
|
|
266
|
+
function isHashable(value) {
|
|
267
|
+
return !(value !== null && typeof value === 'object');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* `time.strptime(text, "%Y-%m-%d")` 的可比较值(`YYYYMMDD` 整数);失败返回 `null`
|
|
272
|
+
* (= Python 抛 ValueError)。
|
|
273
|
+
*
|
|
274
|
+
* 为什么不能只比字符串字典序:`2026-13-01` / `2026-02-30` / `0000-01-01` 都能过格式正则,
|
|
275
|
+
* 但 `_strptime` 会做**日历校验**(年 1..9999、月 1..12、日不超当月长度、闰年 2 月 29)
|
|
276
|
+
* 并抛 ValueError ⇒ 端点回 400。只比字典序会把这些全放行(真 Python 站点对拍实测抓到)。
|
|
277
|
+
* 非 ASCII 数字同理(`_strptime` 吃不下 Nd 数字,实测 `٢٠٢٦-٠١-٠١` 抛 ValueError)。
|
|
278
|
+
*/
|
|
279
|
+
function pyStrtimeDate(text) {
|
|
280
|
+
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(text);
|
|
281
|
+
if (m === null) return null;
|
|
282
|
+
const year = Number(m[1]);
|
|
283
|
+
const month = Number(m[2]);
|
|
284
|
+
const day = Number(m[3]);
|
|
285
|
+
if (year < 1 || year > 9999 || month < 1 || month > 12) return null;
|
|
286
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
287
|
+
const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
288
|
+
if (day < 1 || day > monthDays[month - 1]) return null;
|
|
289
|
+
return year * 10000 + month * 100 + day;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `server.py:_norm_date_range`(536):校验日期范围,返回 `[from, to, err]`。
|
|
294
|
+
*
|
|
295
|
+
* 三个 err 文案(bad date_from / bad date_to / from > to / bad date)不出响应体
|
|
296
|
+
* (调用方统一回「日期范围非法」),故这里只保留「有没有错」的语义。
|
|
297
|
+
*
|
|
298
|
+
* 两条容易写错的语义(都照 Python 写):
|
|
299
|
+
* - **strptime 只在双侧都有值时执行**:单侧的 `2026-13-01`(越界月)Python 是放行的,
|
|
300
|
+
* 原样落库;双侧才做日历校验;
|
|
301
|
+
* - 比较在双侧 ASCII 且日历合法后进行(`pyStrtimeDate` 已把日期折成可比整数)。
|
|
302
|
+
*/
|
|
303
|
+
function normDateRange(dateFrom, dateTo) {
|
|
304
|
+
if (dateFrom && !DATE_RE.test(dateFrom)) return ['', '', 'bad date_from'];
|
|
305
|
+
if (dateTo && !DATE_RE.test(dateTo)) return ['', '', 'bad date_to'];
|
|
306
|
+
if (dateFrom && dateTo) {
|
|
307
|
+
const fromValue = pyStrtimeDate(dateFrom);
|
|
308
|
+
const toValue = pyStrtimeDate(dateTo);
|
|
309
|
+
if (fromValue === null || toValue === null) return ['', '', 'bad date'];
|
|
310
|
+
if (fromValue > toValue) return ['', '', 'from > to'];
|
|
311
|
+
}
|
|
312
|
+
return [dateFrom, dateTo, ''];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `server.py:_resolve_stages`(553):按类型解析 `[startStage, endStage, err]`。
|
|
317
|
+
* `ends` 为空(stress/reject/script_retest/pipeline/retest_bug)⇒ `['', '', null]`。
|
|
318
|
+
*/
|
|
319
|
+
function resolveStages(taskType, body) {
|
|
320
|
+
const spec = STAGE_MATRIX[taskType] || {};
|
|
321
|
+
if (!spec.ends || spec.ends.length === 0) return ['', '', null];
|
|
322
|
+
const end = pyStripStr(pyGet(body, 'end_stage', undefined)) || spec.default;
|
|
323
|
+
if (!spec.ends.includes(end)) return ['', '', 'end_stage 非法'];
|
|
324
|
+
return [spec.start, end, null];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* `_api_create_task`(server.py:2592):创建任务(含 pipeline 连带创建)。
|
|
329
|
+
*
|
|
330
|
+
* 逐点照抄清单(顺序**就是**语义,别重排):
|
|
331
|
+
* 1. 归属 → 404;task_type 合法性(`pipeline` 此时仍算合法);
|
|
332
|
+
* 2. 日期范围 → retest_scope(仅 retest_bug)/ STAGE_MATRIX 终点(其余类型);
|
|
333
|
+
* 3. 拒手工创建 pipeline → regression 必填 extra;
|
|
334
|
+
* 4. fix/retest_bug/reject/script_retest 绑定 bug 报告(retest_bug 带日期范围可不绑定;
|
|
335
|
+
* reject 还必填 reason;retest_only 且用例全固化 ⇒ 自动改走 script_retest);
|
|
336
|
+
* 5. 名称推导(前缀 + bug 目录描述 / 日期范围 / 类型名 + now_str);
|
|
337
|
+
* 6. stop_type 校验 → stress 覆写 payload/停止条件;
|
|
338
|
+
* 7. auto_* 与 fix 的终点推导 → cases_base(`state_cache.get` 包 try/except → 0);
|
|
339
|
+
* 8. insert_task → submit →(终点在 report 之后时)连带 pipeline 行 + 第二次 submit。
|
|
340
|
+
*
|
|
341
|
+
* `runner.INSTANCE` 为 null 时**不静默 no-op**:直接让它抛(= Python 的 AttributeError),
|
|
342
|
+
* 由 `dispatchSafely` 掐断这一条连接;此时任务行已落库(与 Python 的写序逐点同)。
|
|
343
|
+
* @param {object} ctx
|
|
344
|
+
* @param {number} projectId
|
|
345
|
+
*/
|
|
346
|
+
async function apiCreateTask(ctx, projectId) {
|
|
347
|
+
const body = await ctx.readBody(); // Python do_POST:1048 统一读体
|
|
348
|
+
const project = ctx.ownedProject(projectId);
|
|
349
|
+
if (project === null) return; // 已回 404 project not found
|
|
350
|
+
|
|
351
|
+
// task_type 合法性:Python `task_type not in TASK_TYPES` 是**集合**成员判定,不可哈希值
|
|
352
|
+
// (list/dict)在那里抛 TypeError ⇒ 断连接;这里复刻同一可观察结果。
|
|
353
|
+
const taskTypeRaw = pyGet(body, 'task_type', 'normal');
|
|
354
|
+
if (!TASK_TYPES.has(taskTypeRaw)) {
|
|
355
|
+
if (!isHashable(taskTypeRaw)) {
|
|
356
|
+
ctx.res.destroy();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
ctx.error(400, 'task_type 非法');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
let taskType = taskTypeRaw;
|
|
363
|
+
|
|
364
|
+
// 提交日期范围(复测/探索可选):YYYY-MM-DD;首轮 prompt 注入该范围内 git 提交与变更文件
|
|
365
|
+
const dateFrom0 = pyStripStr(pyGet(body, 'date_from', ''));
|
|
366
|
+
const dateTo0 = pyStripStr(pyGet(body, 'date_to', ''));
|
|
367
|
+
const [dateFrom, dateTo, dateErr] = normDateRange(dateFrom0, dateTo0);
|
|
368
|
+
if (dateErr) {
|
|
369
|
+
ctx.error(400, '日期范围非法');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 阶段范围解析:复测来自 retest_scope 三选一(缺省 retest_only),其余按 STAGE_MATRIX 校验终点
|
|
374
|
+
let startStage = '';
|
|
375
|
+
let endStage = '';
|
|
376
|
+
let scope = '';
|
|
377
|
+
if (taskType === 'retest_bug') {
|
|
378
|
+
const scopeRaw = pyGet(body, 'retest_scope', null); // 无缺省 ⇒ None,再 `or "retest_only"`
|
|
379
|
+
scope = pyStrip(pyStr(pyTruthy(scopeRaw) ? scopeRaw : 'retest_only'));
|
|
380
|
+
if (!Object.prototype.hasOwnProperty.call(RETEST_SCOPES, scope)) {
|
|
381
|
+
ctx.error(400, 'retest_scope 非法');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
[startStage, endStage] = RETEST_SCOPES[scope];
|
|
385
|
+
} else {
|
|
386
|
+
const [start, end, err] = resolveStages(taskType, body);
|
|
387
|
+
if (err) {
|
|
388
|
+
ctx.error(400, 'end_stage 非法');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
startStage = start;
|
|
392
|
+
endStage = end;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// pipeline 为平台自动创建的后段任务,不接受外部直接创建
|
|
396
|
+
if (taskType === 'pipeline') {
|
|
397
|
+
ctx.error(400, 'pipeline 为平台自动创建的后段任务,不支持手动创建');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 回归任务:重跑指令(extra)必填 —— agent 按指令决定重跑哪些用例
|
|
402
|
+
if (taskType === 'regression' && !pyStripStr(pyGet(body, 'extra', ''))) {
|
|
403
|
+
ctx.error(400, '请填写重跑指令');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// fix / retest_bug / reject / script_retest 绑定一枚 bug 报告(目录名),固定只跑一轮;
|
|
408
|
+
// retest_bug 带日期范围时不绑定报告(无报告复测,LLM 判影响面)
|
|
409
|
+
let payload = '';
|
|
410
|
+
let bugDir = '';
|
|
411
|
+
if (['fix', 'retest_bug', 'reject', 'script_retest'].includes(taskType)) {
|
|
412
|
+
bugDir = pyStripSlashes(pyStripBare(pyGet(body, 'bug_dir', undefined)));
|
|
413
|
+
if (!bugDir && taskType === 'retest_bug' && (dateFrom || dateTo)) {
|
|
414
|
+
// 日期范围复测:无需 bug 报告
|
|
415
|
+
} else if (!bugDir || !isDir(pyJoin(project.bug_dir, bugDir))) {
|
|
416
|
+
ctx.error(400, 'bug 报告目录不存在');
|
|
417
|
+
return;
|
|
418
|
+
} else {
|
|
419
|
+
const payloadObj = { bug_dir: bugDir };
|
|
420
|
+
if (taskType === 'reject') {
|
|
421
|
+
// 拒绝理由随任务固化:修例 prompt 的依据,重启重跑仍携带
|
|
422
|
+
const reason = pyStripBare(pyGet(body, 'reason', undefined));
|
|
423
|
+
if (!reason) {
|
|
424
|
+
ctx.error(400, '请填写拒绝理由');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
payloadObj.reason = reason;
|
|
428
|
+
}
|
|
429
|
+
payload = pyJsonDumps(payloadObj); // = json.dumps(..., ensure_ascii=False)
|
|
430
|
+
// 复测自动选型:仅「仅复测」范围且关联用例全部已固化(各有 verify.py)时改走脚本复测
|
|
431
|
+
if (taskType === 'retest_bug' && scope === 'retest_only') {
|
|
432
|
+
const { ids } = bugCases(pyJoin(project.bug_dir, bugDir));
|
|
433
|
+
if (bugCasesScripted(project.cases_root, ids)) taskType = 'script_retest';
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let name = pyStripBare(pyGet(body, 'name', undefined));
|
|
439
|
+
if (!name && ['fix', 'retest_bug', 'reject', 'script_retest'].includes(taskType)) {
|
|
440
|
+
const prefix = {
|
|
441
|
+
fix: '修复 ', retest_bug: '复测 ', reject: '修例 ', script_retest: '脚本复测 ',
|
|
442
|
+
}[taskType];
|
|
443
|
+
if (bugDir) {
|
|
444
|
+
const m = BUG_DIR_RE.exec(bugDir);
|
|
445
|
+
name = prefix + (m ? m[1] : bugDir);
|
|
446
|
+
} else {
|
|
447
|
+
name = `${prefix}${dateFrom || '最早'}..${dateTo || '现在'}`;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!name && taskType === 'stress') name = `压测 ${nowStr()}`;
|
|
451
|
+
if (!name && taskType === 'regression') name = `回归 ${nowStr()}`;
|
|
452
|
+
if (!name) name = `任务 ${nowStr()}`;
|
|
453
|
+
|
|
454
|
+
let stopType = pyGet(body, 'stop_type', 'rounds');
|
|
455
|
+
let stopValue = pyStr(pyGet(body, 'stop_value', '1')); // 注意:没有 `or ""`,null ⇒ 'None'
|
|
456
|
+
if (!STOP_TYPES.includes(stopType)) {
|
|
457
|
+
ctx.error(400, 'stop_type 非法');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// 压测任务:压测说明入 payload;轮次条件固定 1 轮 agent(发压轮由平台直接追加)
|
|
461
|
+
if (taskType === 'stress') {
|
|
462
|
+
payload = pyJsonDumps({ brief: pyStripStr(pyGet(body, 'brief', '')) });
|
|
463
|
+
stopType = 'rounds';
|
|
464
|
+
stopValue = '1';
|
|
465
|
+
}
|
|
466
|
+
const autoFix = pyTruthy(pyGet(body, 'auto_fix', false));
|
|
467
|
+
const retest = pyGet(body, 'retest', '不复测');
|
|
468
|
+
const extra = pyStrOrEmpty(pyGet(body, 'extra', ''));
|
|
469
|
+
// 模型回落链:任务显式指定 > 项目级模型 > ''(CLI 默认模型);创建这一刻固化进任务行
|
|
470
|
+
const model = pyStripStr(pyGet(body, 'model', '')) || pyStripStr(project.model);
|
|
471
|
+
const permission = pyStripStr(pyGet(body, 'permission', ''));
|
|
472
|
+
const autoCommit = pyTruthy(pyGet(body, 'auto_commit', false));
|
|
473
|
+
let autoDeploy = pyTruthy(pyGet(body, 'auto_deploy', false));
|
|
474
|
+
let autoRetest = pyTruthy(pyGet(body, 'auto_retest', false));
|
|
475
|
+
if (taskType === 'fix') {
|
|
476
|
+
// 修复任务:部署勾选由终点推导(终点含部署/复测即需要部署);auto_retest 恒 0
|
|
477
|
+
autoDeploy = endStage === 'deploy' || endStage === 'retest';
|
|
478
|
+
autoRetest = 0;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 记录创建时的案例库总数,作为该任务新增案例数的基线。**try/except 必须保留**:
|
|
482
|
+
// Python 把 `state_cache.get(...)` 整段包在 `except Exception` 里 —— 案例库扫描的任何
|
|
483
|
+
// 异常(权限、坏目录、缓存实现抛错)都不该让「建任务」这步失败,宁可以 0 为基线。
|
|
484
|
+
let casesBase = 0;
|
|
485
|
+
try {
|
|
486
|
+
const state = ctx.stateCache.get(project.id, project.cases_root, project.bug_dir,
|
|
487
|
+
project.work_dir);
|
|
488
|
+
casesBase = state.library.stats.total ?? 0; // Python `.get("total", 0)`
|
|
489
|
+
} catch {
|
|
490
|
+
casesBase = 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const taskId = insertTask(projectId, {
|
|
494
|
+
name,
|
|
495
|
+
autoFix,
|
|
496
|
+
retest,
|
|
497
|
+
stopType,
|
|
498
|
+
stopValue,
|
|
499
|
+
taskType,
|
|
500
|
+
payload,
|
|
501
|
+
extra,
|
|
502
|
+
model,
|
|
503
|
+
permission,
|
|
504
|
+
autoCommit,
|
|
505
|
+
autoDeploy,
|
|
506
|
+
autoRetest,
|
|
507
|
+
casesBase,
|
|
508
|
+
startStage,
|
|
509
|
+
endStage,
|
|
510
|
+
dateFrom,
|
|
511
|
+
dateTo,
|
|
512
|
+
}, { db: ctx.db });
|
|
513
|
+
runner.INSTANCE.submit(taskId);
|
|
514
|
+
|
|
515
|
+
// 探索/回归终点在「生成报告」之后:连带创建后段任务(单轮,报告分析→终点),
|
|
516
|
+
// 同项目 FIFO 保证其排在前段之后执行。注意 `STAGES.index(end_stage)` 只在类型短路
|
|
517
|
+
// 通过后求值(stress 等 end_stage 为空,Python 若求值会 ValueError)。
|
|
518
|
+
let pipelineId = null;
|
|
519
|
+
if ((taskType === 'normal' || taskType === 'regression')
|
|
520
|
+
&& STAGES.indexOf(endStage) > STAGES.indexOf('report')) {
|
|
521
|
+
pipelineId = insertTask(projectId, {
|
|
522
|
+
name: `${name}·后段`,
|
|
523
|
+
autoFix: 0,
|
|
524
|
+
retest: '不复测',
|
|
525
|
+
stopType: 'rounds',
|
|
526
|
+
stopValue: '1',
|
|
527
|
+
taskType: 'pipeline',
|
|
528
|
+
payload: pyJsonDumps({ parent_task_id: taskId }),
|
|
529
|
+
startStage: 'analyze',
|
|
530
|
+
endStage,
|
|
531
|
+
}, { db: ctx.db });
|
|
532
|
+
runner.INSTANCE.submit(pipelineId);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 成功体走 json.dumps(带空格),与 Python 逐字节同
|
|
536
|
+
ctx.json(200, { id: taskId, pipeline_id: pipelineId });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* `_api_task_action`(server.py:4089):stop / restart。
|
|
541
|
+
*
|
|
542
|
+
* `stop` 无状态门禁(运行中/排队中都允许);`restart` 在 running/queued 时 400
|
|
543
|
+
* (running 行整程 starting、queued 且无活跃行=永不拾取的幽灵,重启只会更乱)。
|
|
544
|
+
*/
|
|
545
|
+
async function apiTaskAction(ctx, taskId, action) {
|
|
546
|
+
await ctx.readBody(); // 本端点不用 body,但 Python 已把体读掉
|
|
547
|
+
const row = ctx.ownedTask(taskId);
|
|
548
|
+
if (row === null) return; // 已回 404 task not found
|
|
549
|
+
if (action === 'stop') {
|
|
550
|
+
// Node 的 stop 是 async(要 await 驱动的远端 cancel),漏 await 会让「停」变成竞态
|
|
551
|
+
await runner.INSTANCE.stop(taskId);
|
|
552
|
+
} else if (row.status === 'running' || row.status === 'queued') {
|
|
553
|
+
ctx.error(400, '任务运行中或排队中,请先停止');
|
|
554
|
+
return;
|
|
555
|
+
} else {
|
|
556
|
+
runner.INSTANCE.restart(taskId);
|
|
557
|
+
}
|
|
558
|
+
// 成功体是硬编码紧凑字节串(不是 json.dumps)⇒ ctx.text,不能用 ctx.json
|
|
559
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* `_api_continue_task`(server.py:4109):继续任务(改参数后接着当前 session 续跑)。
|
|
564
|
+
*
|
|
565
|
+
* 与 restart 的区别:保留 session_id / current_round / 轮次记录,下一轮用完整首轮
|
|
566
|
+
* 提示词(fresh_prompt=1)。停止条件的轮数/bug 数按「再跑 N 轮 / 再增 V 个」理解,
|
|
567
|
+
* **折算为从当前进度起算的绝对值**;deadline/duration 本身即绝对语义,直接透传。
|
|
568
|
+
*/
|
|
569
|
+
async function apiContinueTask(ctx, taskId) {
|
|
570
|
+
const body = await ctx.readBody(); // Python do_POST:1048 统一读体
|
|
571
|
+
const row = ctx.ownedTask(taskId);
|
|
572
|
+
if (row === null) return;
|
|
573
|
+
const rowType = row.task_type || 'normal';
|
|
574
|
+
if (rowType === 'stress' || rowType === 'pipeline') {
|
|
575
|
+
ctx.error(400, '该任务类型不支持继续(重启可从第一轮重跑)');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (row.status === 'running' || row.status === 'queued') {
|
|
579
|
+
ctx.error(400, '任务运行中或排队中,请先停止');
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const stopType = pyGet(body, 'stop_type', row.stop_type);
|
|
583
|
+
if (!STOP_TYPES.includes(stopType)) {
|
|
584
|
+
ctx.error(400, 'stop_type 非法');
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// `str(body.get("stop_value", row["stop_value"]) or "").strip()`:键存在但 null ⇒ ''
|
|
588
|
+
let stopValue = pyStrip(pyStrOrEmpty(pyGet(body, 'stop_value', row.stop_value)));
|
|
589
|
+
try {
|
|
590
|
+
if (stopType === 'rounds') {
|
|
591
|
+
stopValue = String(pyIntStrict(row.current_round || 0) + pyIntStrict(stopValue || '1'));
|
|
592
|
+
} else if (stopType === 'bugs') {
|
|
593
|
+
stopValue = String(pyIntStrict(row.new_bugs || 0) + pyIntStrict(stopValue || '1'));
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
// Python `except ValueError`(int() 解析失败)⇒ 400;TypeError 一并收(同一可观察)
|
|
597
|
+
ctx.error(400, 'stop_value 非法');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const name = pyStripStr(pyGet(body, 'name', row.name)) || row.name;
|
|
601
|
+
const retestRaw = pyGet(body, 'retest', row.retest);
|
|
602
|
+
const extraRaw = pyGet(body, 'extra', row.extra);
|
|
603
|
+
// 字段顺序 = Python kwargs 顺序(dict 保序 ⇒ SQL 列序可比),且全部在 update_task 白名单内
|
|
604
|
+
updateTask(taskId, {
|
|
605
|
+
name,
|
|
606
|
+
auto_fix: pyTruthy(pyGet(body, 'auto_fix', row.auto_fix)) ? 1 : 0,
|
|
607
|
+
retest: pyStr(pyTruthy(retestRaw) ? retestRaw : row.retest),
|
|
608
|
+
stop_type: stopType,
|
|
609
|
+
stop_value: stopValue,
|
|
610
|
+
extra: pyStr(pyTruthy(extraRaw) ? extraRaw : ''),
|
|
611
|
+
fresh_prompt: 1,
|
|
612
|
+
}, { db: ctx.db });
|
|
613
|
+
runner.INSTANCE.resume(taskId);
|
|
614
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* `_api_update_task`(server.py:4170):更新任务运行参数(改动需重启任务生效)。
|
|
619
|
+
*
|
|
620
|
+
* 逐字段 `if "x" in body` 判定 —— **不是**「取到就用」:缺省的键一律不碰,
|
|
621
|
+
* 空 body ⇒ 一条 UPDATE 都不发(Python `if fields:` 同)。八个字段的写入顺序照抄 Python。
|
|
622
|
+
*/
|
|
623
|
+
async function apiUpdateTask(ctx, taskId) {
|
|
624
|
+
const body = await ctx.readBody(); // Python do_PATCH:1238 读体
|
|
625
|
+
const row = ctx.ownedTask(taskId);
|
|
626
|
+
if (row === null) return;
|
|
627
|
+
const fields = {};
|
|
628
|
+
if (pyHas(body, 'model')) fields.model = pyStripStr(pyGet(body, 'model', ''));
|
|
629
|
+
if (pyHas(body, 'permission')) fields.permission = pyStripStr(pyGet(body, 'permission', ''));
|
|
630
|
+
if (pyHas(body, 'auto_commit')) fields.auto_commit = pyTruthy(body.auto_commit) ? 1 : 0;
|
|
631
|
+
if (pyHas(body, 'auto_deploy')) fields.auto_deploy = pyTruthy(body.auto_deploy) ? 1 : 0;
|
|
632
|
+
if (pyHas(body, 'auto_retest')) fields.auto_retest = pyTruthy(body.auto_retest) ? 1 : 0;
|
|
633
|
+
if (pyHas(body, 'extra')) fields.extra = pyStrOrEmpty(pyGet(body, 'extra', ''));
|
|
634
|
+
if (pyHas(body, 'start_stage')) {
|
|
635
|
+
const val = pyStripStr(pyGet(body, 'start_stage', ''));
|
|
636
|
+
if (val && !STAGES.includes(val)) {
|
|
637
|
+
ctx.error(400, 'start_stage 非法');
|
|
638
|
+
return; // 已解析的 fields 一并丢弃(Python 同)
|
|
639
|
+
}
|
|
640
|
+
fields.start_stage = val;
|
|
641
|
+
}
|
|
642
|
+
if (pyHas(body, 'end_stage')) {
|
|
643
|
+
const val = pyStripStr(pyGet(body, 'end_stage', ''));
|
|
644
|
+
if (val && !STAGES.includes(val)) {
|
|
645
|
+
ctx.error(400, 'end_stage 非法');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
fields.end_stage = val;
|
|
649
|
+
}
|
|
650
|
+
if (Object.keys(fields).length > 0) updateTask(taskId, fields, { db: ctx.db });
|
|
651
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* `_api_delete_task`(server.py:4155):删除任务(运行中拒绝;排队中先从队列移除)。
|
|
656
|
+
*
|
|
657
|
+
* **不读体**:Python `do_DELETE` 全程没有 `_read_json()`(与 POST/PATCH 的差别是硬口径)。
|
|
658
|
+
* 顺序也是语义:先 `runner.remove`(取消等待项)再 `db.delete_task`(删行 + 轮次行)。
|
|
659
|
+
*/
|
|
660
|
+
async function apiDeleteTask(ctx, taskId) {
|
|
661
|
+
const row = ctx.ownedTask(taskId);
|
|
662
|
+
if (row === null) return;
|
|
663
|
+
if (row.status === 'running') {
|
|
664
|
+
ctx.error(400, '任务运行中,请先停止再删除');
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
runner.INSTANCE.remove(taskId);
|
|
668
|
+
deleteTask(taskId, { db: ctx.db });
|
|
669
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ==========================================================================
|
|
673
|
+
// 压测(load)族 9 条端点(M6-6):server.py 4256-4740
|
|
674
|
+
// GET /api/tasks/<id>/load_series → `_api_load_metrics` 的旧路径别名(1442,无 UI 调用)
|
|
675
|
+
// GET /api/tasks/<id>/load/case → `_api_load_case`(4418)
|
|
676
|
+
// GET /api/tasks/<id>/load/metrics → `_api_load_metrics`(4373)
|
|
677
|
+
// GET /api/tasks/<id>/load/runs → `_api_load_runs`(4521)
|
|
678
|
+
// GET /api/tasks/<id>/load/custom_html → `_api_load_custom_html`(4441)
|
|
679
|
+
// GET /api/tasks/<id>/load_report → `_api_load_report`(4470)
|
|
680
|
+
// GET /api/tasks/<id>/load/stream → `_api_load_stream`(4670)
|
|
681
|
+
// POST /api/tasks/<id>/load/rerun → `_api_load_rerun`(4592)
|
|
682
|
+
// POST /api/tasks/<id>/load/diagnose → `_api_load_diagnose`(4616)(复用 session/chat 的组装体)
|
|
683
|
+
//
|
|
684
|
+
// 三处**同一族里两种字节形态混排**(照 oracle,不许"统一一下更整齐"):
|
|
685
|
+
// ① 失败体一律硬编码紧凑:`b'{"error":"task not found"}'`(4377/4428/4599…)、
|
|
686
|
+
// `b'{"error":"run not found"}'`(4514)、`b'{"error":"custom view not found"}'`(4450)、
|
|
687
|
+
// `{"error":"仅压测任务支持复压"}`(4603) ⇒ `ctx.error` / `ctx.text`;
|
|
688
|
+
// ② `load_report` 的 `b'{"found":false}'`(4518) 与 `rerun` 的**成功体** `b'{"ok":true}'`(4613)
|
|
689
|
+
// 都是硬编码紧凑 ⇒ `ctx.text`;
|
|
690
|
+
// ③ 其余成功体走 `json.dumps(..., ensure_ascii=False)`(4414/4438/4508/4556/4700)⇒ `ctx.json`。
|
|
691
|
+
//
|
|
692
|
+
// `load/stream` **不是** `ctx.sse` 的命名帧形态:头部只有 Content-Type + Cache-Control
|
|
693
|
+
// (无 `X-Accel-Buffering`,见 lib/http.js 的 `accelBuffering` 缺省 false)、帧是裸
|
|
694
|
+
// `id: <行号>\ndata: <样本>\n\n`、结束补 `data: {"done":true}\n\n`,并读请求头
|
|
695
|
+
// `Last-Event-ID` 做断线续传(首连只回放最后 20000 行)。故这里用 `ctx.sse({}).raw()` 直写。
|
|
696
|
+
// ==========================================================================
|
|
697
|
+
|
|
698
|
+
/** `LOAD_STREAM_REPLAY_MAX`(server.py:492):首连只回放最后 20000 行 */
|
|
699
|
+
const LOAD_STREAM_REPLAY_MAX = 20000;
|
|
700
|
+
|
|
701
|
+
/** `pyJsonLoads` 的哨兵串(与 `prompts.js` / `db.js` 同一算法:只换字符串之外的字面量) */
|
|
702
|
+
const PY_JSON_NAN = '\u0000py-json:NaN\u0000';
|
|
703
|
+
const PY_JSON_INF = '\u0000py-json:Infinity\u0000';
|
|
704
|
+
const PY_JSON_NEG_INF = '\u0000py-json:-Infinity\u0000';
|
|
705
|
+
|
|
706
|
+
/** 把源码里**字符串之外**的 `NaN`/`Infinity`/`-Infinity` 换成哨兵串;一处都没换返回 null */
|
|
707
|
+
function patchPyJsonSpecials(src) {
|
|
708
|
+
let out = '';
|
|
709
|
+
let changed = false;
|
|
710
|
+
let inString = false;
|
|
711
|
+
for (let i = 0; i < src.length;) {
|
|
712
|
+
const ch = src[i];
|
|
713
|
+
if (inString) {
|
|
714
|
+
out += ch;
|
|
715
|
+
if (ch === '\\') { out += src[i + 1] ?? ''; i += 2; continue; }
|
|
716
|
+
if (ch === '"') inString = false;
|
|
717
|
+
i += 1;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (ch === '"') { inString = true; out += ch; i += 1; continue; }
|
|
721
|
+
if (src.startsWith('-Infinity', i)) {
|
|
722
|
+
out += JSON.stringify(PY_JSON_NEG_INF); i += 9; changed = true; continue;
|
|
723
|
+
}
|
|
724
|
+
if (src.startsWith('Infinity', i)) {
|
|
725
|
+
out += JSON.stringify(PY_JSON_INF); i += 8; changed = true; continue;
|
|
726
|
+
}
|
|
727
|
+
if (src.startsWith('NaN', i)) {
|
|
728
|
+
out += JSON.stringify(PY_JSON_NAN); i += 3; changed = true; continue;
|
|
729
|
+
}
|
|
730
|
+
out += ch;
|
|
731
|
+
i += 1;
|
|
732
|
+
}
|
|
733
|
+
return changed ? out : null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* CPython `json.loads()` 的读侧对应物(压测指标行/报告 JSON 用)。
|
|
738
|
+
*
|
|
739
|
+
* 与 `JSON.parse` 的两处可观察差异:① Python 认裸 `NaN`/`Infinity`/`-Infinity`
|
|
740
|
+
* (`json.loads` 的 `parse_constant` 缺省放行),JS 的 `JSON.parse` 直接抛;
|
|
741
|
+
* ② 失败时都当 ValueError 处理(调用方按「坏行跳过」/「坏文件返回 {}」收口)。
|
|
742
|
+
* 严格解析失败且源码里真的含这三个字面量时才走哨兵替换(与 `prompts.js` 同法)。
|
|
743
|
+
*/
|
|
744
|
+
function pyJsonLoads(src) {
|
|
745
|
+
try {
|
|
746
|
+
return JSON.parse(src);
|
|
747
|
+
} catch (strictError) {
|
|
748
|
+
const patched = patchPyJsonSpecials(src);
|
|
749
|
+
if (patched === null) throw strictError;
|
|
750
|
+
return JSON.parse(patched, (key, value) => {
|
|
751
|
+
if (value === PY_JSON_NAN) return NaN;
|
|
752
|
+
if (value === PY_JSON_INF) return Infinity;
|
|
753
|
+
if (value === PY_JSON_NEG_INF) return -Infinity;
|
|
754
|
+
return value;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* `JSON.parse` 的 reviver 带 `context.source`(Node ≥21 的 JSON.parse source text access,
|
|
761
|
+
* 本机 24 实测可用)⇒ 能区分「JSON 里写的是 `1190` 还是 `1190.0`」,即 Python 的
|
|
762
|
+
* **int 与 float**。诊断文案的 `f"{k}={v}"` 两者渲染不同(`1190` vs `0.0`),
|
|
763
|
+
* 而 JS 的 number 分不出——故这里把两者分别包成 `PyInt` / `PyFloat` 盒子,
|
|
764
|
+
* 只给诊断文案的渲染器看(不参与任何算术)。
|
|
765
|
+
*/
|
|
766
|
+
class PyInt {
|
|
767
|
+
constructor(value) { this.value = value; }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** 同 `PyInt`:JSON 原文带 `.`/`e` 的数字在 Python 里是 float(整数值也渲染成 `x.0`) */
|
|
771
|
+
class PyFloat {
|
|
772
|
+
constructor(value) { this.value = value; }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** 该 reviver 的第二参(按 JSON 原文分 int/float;其余原样) */
|
|
776
|
+
function pyTypedReviver(key, value, context) {
|
|
777
|
+
if (typeof value !== 'number') return value;
|
|
778
|
+
const src = context && typeof context.source === 'string' ? context.source : '';
|
|
779
|
+
if (/^[+-]?\d+$/.test(src)) return new PyInt(value);
|
|
780
|
+
return new PyFloat(value);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** 与 `pyJsonLoads` 同,但保留 int/float 之别(诊断文案专用;哨兵路径不保留 int 之别) */
|
|
784
|
+
function pyJsonLoadsTyped(src) {
|
|
785
|
+
try {
|
|
786
|
+
return JSON.parse(src, pyTypedReviver);
|
|
787
|
+
} catch (strictError) {
|
|
788
|
+
const patched = patchPyJsonSpecials(src);
|
|
789
|
+
if (patched === null) throw strictError;
|
|
790
|
+
return JSON.parse(patched);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Python `open(path, encoding="utf-8", errors="replace")` 的**行迭代**对应物:
|
|
796
|
+
* 通用换行(`\r\n`/`\r` → `\n`),且**末尾换行不产生额外空行**。
|
|
797
|
+
* (`String.split("\n")` 会多出最后那个空元素——指标行号会因此整体偏移。)
|
|
798
|
+
*/
|
|
799
|
+
function pyReadLines(filePath) {
|
|
800
|
+
const text = fs.readFileSync(String(filePath)).toString('utf8');
|
|
801
|
+
const norm = text.replace(/\r\n|\r/g, '\n');
|
|
802
|
+
if (norm === '') return [];
|
|
803
|
+
const lines = norm.split('\n');
|
|
804
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
805
|
+
return lines;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** `_load_run_row`(4268):显式运行键 → 正在跑的 load 行 → 最新一条 load 行;无则 null */
|
|
809
|
+
function loadRunRow(row, run, ctx) {
|
|
810
|
+
const rows = listRounds(row.id, { db: ctx.db }).filter((r) => r.kind === 'load');
|
|
811
|
+
if (run) {
|
|
812
|
+
for (const r of rows) if (r.run_key === run) return r;
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
for (const r of rows) if (r.status === 'running') return r;
|
|
816
|
+
return rows.length ? rows[rows.length - 1] : null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** `_load_run_paths`(4288):按运行行推导(日志, 指标, 报告主名)+ 两级回落 */
|
|
820
|
+
function loadRunPaths(row, runRow, ctx) {
|
|
821
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
822
|
+
const taskId = row.id;
|
|
823
|
+
const workDir = project.work_dir;
|
|
824
|
+
const repDir = loadcase.report_dir(project.cases_root);
|
|
825
|
+
const runKey = runRow !== null && runRow !== undefined ? runRow.run_key : '';
|
|
826
|
+
const logPath = (runRow !== null && runRow !== undefined && runRow.log_path)
|
|
827
|
+
? runRow.log_path
|
|
828
|
+
: loadcase.load_log_path(workDir, taskId, runKey);
|
|
829
|
+
let metrics = loadcase.metrics_path(workDir, taskId, runKey);
|
|
830
|
+
if (runKey && !isFile(metrics)) {
|
|
831
|
+
const legacy = loadcase.metrics_path(workDir, taskId);
|
|
832
|
+
if (isFile(legacy)) metrics = legacy;
|
|
833
|
+
}
|
|
834
|
+
let stem;
|
|
835
|
+
if (runKey) {
|
|
836
|
+
stem = loadcase.report_stem(repDir, taskId, runKey);
|
|
837
|
+
} else {
|
|
838
|
+
let newest = null;
|
|
839
|
+
for (const rep of loadcase.list_reports(project.cases_root, taskId)) {
|
|
840
|
+
newest = rep.json_path.slice(0, -5);
|
|
841
|
+
break; // list_reports 已按运行键倒序
|
|
842
|
+
}
|
|
843
|
+
stem = newest || loadcase.report_stem(repDir, taskId, '');
|
|
844
|
+
}
|
|
845
|
+
return { log_path: logPath, metrics, stem };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** `_load_metrics_source`(4316):指标读侧数据源 `(路径, 是否旧快照)` */
|
|
849
|
+
function loadMetricsSource(row, run, ctx) {
|
|
850
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
851
|
+
const runRow = loadRunRow(row, run, ctx);
|
|
852
|
+
if (run && runRow === null) {
|
|
853
|
+
// 孤儿/未知运行键:按该键推导路径(不回落到「最新一次运行」)
|
|
854
|
+
return [loadcase.metrics_path(project.work_dir, row.id, run), false];
|
|
855
|
+
}
|
|
856
|
+
const { metrics } = loadRunPaths(row, runRow, ctx);
|
|
857
|
+
if (isFile(metrics)) return [metrics, false];
|
|
858
|
+
const legacy = loadcase.legacy_snapshot_path(project.work_dir, row.id);
|
|
859
|
+
if (isFile(legacy)) return [legacy, true];
|
|
860
|
+
return [metrics, false];
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** `_normalize_raw`(4338):指标文件一行(bytes)→ 规范样本列表(坏行返回空列表) */
|
|
864
|
+
function normalizeRaw(raw) {
|
|
865
|
+
let obj;
|
|
866
|
+
try {
|
|
867
|
+
obj = pyJsonLoads(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw));
|
|
868
|
+
} catch {
|
|
869
|
+
return [];
|
|
870
|
+
}
|
|
871
|
+
return loadcase.normalize_line(obj);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* `_scan_metrics`(4347):定位回放起点 `[起始字节偏移, 起始 0-based 行号]`。
|
|
876
|
+
* `after` 优先,`tail` 只保留最后 tail 行的起点。二进制按 `\n` 分行(末行无换行也算一行)。
|
|
877
|
+
*/
|
|
878
|
+
function scanMetrics(filePath, after = 0, tail = 0) {
|
|
879
|
+
if (!filePath || (after <= 0 && tail <= 0)) return [0, 0];
|
|
880
|
+
const starts = tail ? [] : null;
|
|
881
|
+
let offset = 0;
|
|
882
|
+
let no = 0;
|
|
883
|
+
let buf;
|
|
884
|
+
try {
|
|
885
|
+
buf = fs.readFileSync(String(filePath));
|
|
886
|
+
} catch {
|
|
887
|
+
return [0, 0]; // 照 Python:OSError → (0, 0)
|
|
888
|
+
}
|
|
889
|
+
let idx = 0;
|
|
890
|
+
while (idx < buf.length) {
|
|
891
|
+
if (after && no === after) return [offset, no];
|
|
892
|
+
if (starts !== null) {
|
|
893
|
+
starts.push([offset, no]);
|
|
894
|
+
if (starts.length > tail) starts.shift(); // collections.deque(maxlen=tail)
|
|
895
|
+
}
|
|
896
|
+
const nl = buf.indexOf(0x0a, idx);
|
|
897
|
+
const end = nl === -1 ? buf.length : nl + 1;
|
|
898
|
+
offset += end - idx;
|
|
899
|
+
no += 1;
|
|
900
|
+
idx = end;
|
|
901
|
+
}
|
|
902
|
+
if (starts !== null && starts.length) return starts[0];
|
|
903
|
+
return [offset, no];
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** `_report_kpi`(4558):报告 JSON 的轻量 KPI(前 6 项);坏文件返回 {} */
|
|
907
|
+
function reportKpi(jsonPath) {
|
|
908
|
+
let obj;
|
|
909
|
+
try {
|
|
910
|
+
obj = pyJsonLoads(readText(jsonPath) || '{}');
|
|
911
|
+
} catch {
|
|
912
|
+
return {};
|
|
913
|
+
}
|
|
914
|
+
const summary = (obj !== null && typeof obj === 'object' && !Array.isArray(obj))
|
|
915
|
+
? obj.summary : null;
|
|
916
|
+
if (!(summary !== null && typeof summary === 'object' && !Array.isArray(summary))) return {};
|
|
917
|
+
return Object.fromEntries(Object.entries(summary).slice(0, 6));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** `_run_item`(4569):一条已登记运行 → 运行列表项(键序即 Python dict 字面量序) */
|
|
921
|
+
function runItem(row, project, roundRow) {
|
|
922
|
+
const runKey = roundRow.run_key;
|
|
923
|
+
const repDir = loadcase.report_dir(project.cases_root);
|
|
924
|
+
const stem = loadcase.report_stem(repDir, row.id, runKey);
|
|
925
|
+
return {
|
|
926
|
+
run_key: runKey,
|
|
927
|
+
round_no: round_row_no(roundRow),
|
|
928
|
+
status: roundRow.status,
|
|
929
|
+
started_at: roundRow.started_at || '',
|
|
930
|
+
ended_at: roundRow.ended_at || '',
|
|
931
|
+
exit_code: roundRow.exit_code === undefined ? null : roundRow.exit_code,
|
|
932
|
+
summary_text: roundRow.summary || '',
|
|
933
|
+
kpi: reportKpi(`${stem}.json`),
|
|
934
|
+
has_report: isFile(`${stem}.json`),
|
|
935
|
+
has_metrics: isFile(loadcase.metrics_path(project.work_dir, row.id, runKey)),
|
|
936
|
+
registered: true,
|
|
937
|
+
legacy: false,
|
|
938
|
+
mtime: 0.0,
|
|
939
|
+
is_current: roundRow.status === 'running',
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** sqlite 行的 `round_no`(列 NOT NULL;null 也照原样回) */
|
|
944
|
+
function round_row_no(roundRow) {
|
|
945
|
+
return roundRow.round_no === undefined ? null : roundRow.round_no;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `_api_load_metrics`(4373):`?after=N` 增量 + `?limit=` 限条;`?run=` 指定历史运行 */
|
|
949
|
+
function apiLoadMetrics(ctx, taskId) {
|
|
950
|
+
const row = ctx.ownedTask(taskId);
|
|
951
|
+
if (row === null) return; // 已回 404 task not found
|
|
952
|
+
let after = pyIntOrNull(ctx.q('after', '0'));
|
|
953
|
+
if (after === null) after = 0; // int() 抛 ValueError ⇒ 0
|
|
954
|
+
after = Math.max(0, after);
|
|
955
|
+
let limit = pyIntOrNull(ctx.q('limit', '20000'));
|
|
956
|
+
if (limit === null) limit = 20000;
|
|
957
|
+
limit = Math.min(Math.max(1, limit), 50000);
|
|
958
|
+
const [metricsPath] = loadMetricsSource(row, ctx.q('run', ''), ctx);
|
|
959
|
+
const samples = [];
|
|
960
|
+
let lastNo = after - 1;
|
|
961
|
+
let lines = null;
|
|
962
|
+
try {
|
|
963
|
+
lines = pyReadLines(metricsPath);
|
|
964
|
+
} catch {
|
|
965
|
+
lines = null; // 文件尚不存在 ⇒ 空列表
|
|
966
|
+
}
|
|
967
|
+
if (lines !== null) {
|
|
968
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
969
|
+
if (i < after) continue;
|
|
970
|
+
const line = pyStrip(lines[i]);
|
|
971
|
+
if (line) {
|
|
972
|
+
let obj = null;
|
|
973
|
+
try {
|
|
974
|
+
obj = pyJsonLoads(line);
|
|
975
|
+
} catch {
|
|
976
|
+
obj = null; // 半行/坏行:跳过但行号照常推进
|
|
977
|
+
}
|
|
978
|
+
if (obj !== null) for (const s of loadcase.normalize_line(obj)) samples.push(s);
|
|
979
|
+
}
|
|
980
|
+
lastNo = i;
|
|
981
|
+
if (samples.length >= limit) break;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
ctx.json(200, { samples, next: lastNo + 1 });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** `_api_load_case`(4418):方案包资产(`?meta=1` 只回轻量标记) */
|
|
988
|
+
function apiLoadCase(ctx, taskId) {
|
|
989
|
+
const row = ctx.ownedTask(taskId);
|
|
990
|
+
if (row === null) return;
|
|
991
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
992
|
+
const assets = loadcase.read_assets(project.cases_root, taskId);
|
|
993
|
+
delete assets.case_dir;
|
|
994
|
+
delete assets.html_path;
|
|
995
|
+
const charts = pyTruthy(assets.charts) ? assets.charts : {};
|
|
996
|
+
assets.panels = (pyTruthy(charts.panels) ? charts.panels : []).length;
|
|
997
|
+
if (pyTruthy(ctx.q('meta', ''))) {
|
|
998
|
+
delete assets.plan_md;
|
|
999
|
+
delete assets.script_text;
|
|
1000
|
+
}
|
|
1001
|
+
ctx.json(200, assets);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** `_api_load_custom_html`(4441):逃生舱 panel.html(CSP sandbox + nosniff 两头) */
|
|
1005
|
+
function apiLoadCustomHtml(ctx, taskId) {
|
|
1006
|
+
const row = ctx.ownedTask(taskId);
|
|
1007
|
+
if (row === null) return;
|
|
1008
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
1009
|
+
const target = loadcase.resolve(project.cases_root, taskId).html_path;
|
|
1010
|
+
if (!target) {
|
|
1011
|
+
ctx.error(404, 'custom view not found'); // 硬编码紧凑
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
let body;
|
|
1015
|
+
try {
|
|
1016
|
+
body = fs.readFileSync(target);
|
|
1017
|
+
} catch {
|
|
1018
|
+
ctx.error(404, 'custom view unreadable'); // 硬编码紧凑
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
ctx.text(200, body, 'text/html; charset=utf-8', {
|
|
1022
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1023
|
+
'Content-Security-Policy': 'sandbox allow-scripts',
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** `_api_load_report`(4470):报告 JSON + Markdown 全文(`?run=` 指定历史运行) */
|
|
1028
|
+
function apiLoadReport(ctx, taskId) {
|
|
1029
|
+
const row = ctx.ownedTask(taskId);
|
|
1030
|
+
if (row === null) return;
|
|
1031
|
+
const run = ctx.q('run', '');
|
|
1032
|
+
const runRow = loadRunRow(row, run, ctx);
|
|
1033
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
1034
|
+
const repDir = loadcase.report_dir(project.cases_root);
|
|
1035
|
+
let stem;
|
|
1036
|
+
if (run && runRow === null) {
|
|
1037
|
+
// 孤儿报告:按运行键直接找文件,找不到才 404(registered=false)
|
|
1038
|
+
stem = loadcase.report_stem(repDir, taskId, run);
|
|
1039
|
+
} else {
|
|
1040
|
+
stem = loadRunPaths(row, runRow, ctx).stem;
|
|
1041
|
+
}
|
|
1042
|
+
let found = `${stem}.json`;
|
|
1043
|
+
if (!isFile(found) && !run) {
|
|
1044
|
+
// 存量任务:报告可能仍是分钟级命名,取目录里最新一份
|
|
1045
|
+
try {
|
|
1046
|
+
const cands = fs.readdirSync(repDir)
|
|
1047
|
+
.filter((n) => n.startsWith(`task${taskId}_`) && n.endsWith('.json'))
|
|
1048
|
+
.map((n) => path.join(repDir, n));
|
|
1049
|
+
let best = '';
|
|
1050
|
+
let bestM = -Infinity;
|
|
1051
|
+
for (const cand of cands) {
|
|
1052
|
+
const m = fs.statSync(cand).mtimeMs;
|
|
1053
|
+
if (m > bestM) { bestM = m; best = cand; } // Python max():并列取先出现者
|
|
1054
|
+
}
|
|
1055
|
+
found = best;
|
|
1056
|
+
} catch {
|
|
1057
|
+
found = '';
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (run && !isFile(found)) {
|
|
1061
|
+
ctx.error(404, 'run not found'); // 硬编码紧凑
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (!found || !isFile(found)) {
|
|
1065
|
+
ctx.text(200, '{"found":false}', 'application/json; charset=utf-8');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
let report;
|
|
1069
|
+
try {
|
|
1070
|
+
report = pyJsonLoads(readText(found) || '{}');
|
|
1071
|
+
} catch {
|
|
1072
|
+
report = {};
|
|
1073
|
+
}
|
|
1074
|
+
const md = readText(`${found.slice(0, -5)}.md`);
|
|
1075
|
+
ctx.json(200, {
|
|
1076
|
+
found: true,
|
|
1077
|
+
report,
|
|
1078
|
+
md,
|
|
1079
|
+
run_key: runRow !== null ? runRow.run_key : run,
|
|
1080
|
+
file: path.basename(found),
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** `_api_load_runs`(4521):运行列表(rounds 里 kind='load' 的行 + 报告目录里的孤儿报告) */
|
|
1085
|
+
function apiLoadRuns(ctx, taskId) {
|
|
1086
|
+
const row = ctx.ownedTask(taskId);
|
|
1087
|
+
if (row === null) return;
|
|
1088
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
1089
|
+
const workDir = project.work_dir;
|
|
1090
|
+
const runs = [];
|
|
1091
|
+
const seen = new Set();
|
|
1092
|
+
for (const r of listRounds(taskId, { db: ctx.db })) {
|
|
1093
|
+
if (r.kind !== 'load') continue;
|
|
1094
|
+
seen.add(r.run_key);
|
|
1095
|
+
runs.push(runItem(row, project, r));
|
|
1096
|
+
}
|
|
1097
|
+
for (const rep of loadcase.list_reports(project.cases_root, taskId)) {
|
|
1098
|
+
if (seen.has(rep.run_key)) continue;
|
|
1099
|
+
runs.push({
|
|
1100
|
+
run_key: rep.run_key,
|
|
1101
|
+
round_no: null,
|
|
1102
|
+
status: 'unknown',
|
|
1103
|
+
started_at: '',
|
|
1104
|
+
ended_at: '',
|
|
1105
|
+
exit_code: null,
|
|
1106
|
+
summary_text: '',
|
|
1107
|
+
kpi: reportKpi(rep.json_path),
|
|
1108
|
+
has_report: true,
|
|
1109
|
+
has_metrics: isFile(loadcase.metrics_path(workDir, taskId, rep.run_key)),
|
|
1110
|
+
registered: false,
|
|
1111
|
+
legacy: Boolean(rep.legacy),
|
|
1112
|
+
mtime: rep.mtime,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
// Python `runs.sort(key=..., reverse=True)`:稳定排序,同键保持原相对序
|
|
1116
|
+
runs.sort((a, b) => pyStrCompareDesc(a.run_key, b.run_key));
|
|
1117
|
+
ctx.json(200, { runs });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** 运行键倒序比较(ASCII 数字+下划线,直接按码点;相等返回 0 以保稳定) */
|
|
1121
|
+
function pyStrCompareDesc(a, b) {
|
|
1122
|
+
const x = String(a);
|
|
1123
|
+
const y = String(b);
|
|
1124
|
+
if (x === y) return 0;
|
|
1125
|
+
return x < y ? 1 : -1;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** `_api_load_rerun`(4592):复压(门禁同 restart;入队走统一队列) */
|
|
1129
|
+
async function apiLoadRerun(ctx, taskId) {
|
|
1130
|
+
await ctx.readBody(); // Python 在 do_POST:1048 统一读体
|
|
1131
|
+
const row = ctx.ownedTask(taskId);
|
|
1132
|
+
if (row === null) return;
|
|
1133
|
+
if ((row.task_type || 'normal') !== 'stress') {
|
|
1134
|
+
ctx.text(400, '{"error":"仅压测任务支持复压"}', 'application/json; charset=utf-8');
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (row.status === 'running' || row.status === 'queued') {
|
|
1138
|
+
ctx.text(400, '{"error":"任务运行中或排队中,请先停止"}', 'application/json; charset=utf-8');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
runner.INSTANCE.rerunLoad(taskId);
|
|
1142
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8'); // 成功体也是硬编码紧凑
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* `_read_summary_entries`:`loadcase.read_summary` 的**保留 int/float 之别**的兄弟。
|
|
1147
|
+
*
|
|
1148
|
+
* 为什么不能直接用 `loadcase.read_summary`:Python 的 `f"{k}={v}"` 对 int 与 float
|
|
1149
|
+
* 渲染不同(真实样本里 `受理委托=1190`、`错误率=0.0` 同时存在),而 JS 的 number 分不出
|
|
1150
|
+
* 二者 ⇒ 用带 `context.source` 的 reviver 读回数字 token 原文(见 `PyInt`)。
|
|
1151
|
+
* 其余语义逐条照 `read_summary`:流式扫描、取**最后一条** summary 行、异常整体吞掉。
|
|
1152
|
+
*/
|
|
1153
|
+
function readSummaryEntries(filePath) {
|
|
1154
|
+
let found = [];
|
|
1155
|
+
let fd;
|
|
1156
|
+
try {
|
|
1157
|
+
fd = fs.openSync(filePath, 'r');
|
|
1158
|
+
} catch {
|
|
1159
|
+
return [];
|
|
1160
|
+
}
|
|
1161
|
+
const decoder = new StringDecoder('utf8');
|
|
1162
|
+
const buf = Buffer.allocUnsafe(65536);
|
|
1163
|
+
let carry = '';
|
|
1164
|
+
const eat = (line) => {
|
|
1165
|
+
const raw = pyStrip(line);
|
|
1166
|
+
if (!raw || !raw.includes('"summary"')) return;
|
|
1167
|
+
let obj;
|
|
1168
|
+
try {
|
|
1169
|
+
obj = pyJsonLoadsTyped(raw);
|
|
1170
|
+
} catch {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj) && obj.kind === 'summary') {
|
|
1174
|
+
const vals = obj.values;
|
|
1175
|
+
if (vals !== null && typeof vals === 'object' && !Array.isArray(vals)) {
|
|
1176
|
+
found = Object.entries(vals);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
try {
|
|
1181
|
+
for (;;) {
|
|
1182
|
+
const n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
1183
|
+
if (!n) break;
|
|
1184
|
+
carry += decoder.write(buf.subarray(0, n));
|
|
1185
|
+
let idx;
|
|
1186
|
+
while ((idx = carry.indexOf('\n')) >= 0) {
|
|
1187
|
+
eat(carry.slice(0, idx));
|
|
1188
|
+
carry = carry.slice(idx + 1);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
carry += decoder.end();
|
|
1192
|
+
if (carry) eat(carry);
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
if (e && typeof e.code === 'string') return []; // 读中途 OSError:照 Python 整体吞掉
|
|
1195
|
+
throw e;
|
|
1196
|
+
} finally {
|
|
1197
|
+
try {
|
|
1198
|
+
fs.closeSync(fd);
|
|
1199
|
+
} catch { /* 已关 */ }
|
|
1200
|
+
}
|
|
1201
|
+
return found;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** Python f-string 的 `{value}`:int 走十进制、float 走 repr、容器走 pyStr 兜底 */
|
|
1205
|
+
function pyFstringValue(value) {
|
|
1206
|
+
if (value instanceof PyInt) return String(value.value);
|
|
1207
|
+
if (value instanceof PyFloat) return pyFieldStr(value.value);
|
|
1208
|
+
if (typeof value === 'number') return pyFieldStr(value);
|
|
1209
|
+
return pyStr(value);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Python f-string 里**已知是 int** 的列(`rounds.round_no` 是 INTEGER 列,不是 JSON 数字):
|
|
1214
|
+
* `None` → `"None"`,其余十进制。
|
|
1215
|
+
*
|
|
1216
|
+
* 为什么不能复用 `pyFstringValue`:那里按「float repr」渲染数值(整数值补 `.0`),
|
|
1217
|
+
* 对 JSON 里的浮点是**对的**,对 sqlite 的整数列会渲染成 `3.0`(M6-6 单测实测抓出)。
|
|
1218
|
+
*/
|
|
1219
|
+
function pyIntText(value) {
|
|
1220
|
+
return value === null || value === undefined ? 'None' : String(value);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** `_load_diagnose_text`(4632):组装诊断消息(截断日志尾;供 `_api_load_diagnose` 使用) */
|
|
1224
|
+
function loadDiagnoseText(row, ctx) {
|
|
1225
|
+
const project = getProject(row.project_id, { db: ctx.db });
|
|
1226
|
+
const taskId = row.id;
|
|
1227
|
+
const runRow = loadRunRow(row, '', ctx);
|
|
1228
|
+
const paths = loadRunPaths(row, runRow, ctx);
|
|
1229
|
+
const kase = loadcase.resolve(project.cases_root, taskId);
|
|
1230
|
+
const kpi = readSummaryEntries(paths.metrics);
|
|
1231
|
+
const kpiText = kpi.slice(0, 12).map(([k, v]) => `${k}=${pyFstringValue(v)}`).join('、')
|
|
1232
|
+
|| '(无收尾 KPI)';
|
|
1233
|
+
let head;
|
|
1234
|
+
if (runRow !== null) {
|
|
1235
|
+
const exitCode = runRow.exit_code === null || runRow.exit_code === undefined
|
|
1236
|
+
? '—' : String(runRow.exit_code);
|
|
1237
|
+
head = `【压测运行现场】任务 #${taskId}「${row.name}」\n`
|
|
1238
|
+
+ `- 最近一次运行:运行键 ${runRow.run_key}`
|
|
1239
|
+
+ `(轮次 ${pyIntText(runRow.round_no)})状态 ${runRow.status}`
|
|
1240
|
+
+ ` 退出码 ${exitCode}\n`
|
|
1241
|
+
+ `- 开始 ${runRow.started_at || '—'} · 结束 ${runRow.ended_at || '—'}\n`;
|
|
1242
|
+
} else {
|
|
1243
|
+
head = `【压测运行现场】任务 #${taskId}「${row.name}」\n`
|
|
1244
|
+
+ '- 该任务尚无运行记录(存量任务)\n';
|
|
1245
|
+
}
|
|
1246
|
+
const files = [];
|
|
1247
|
+
for (const [name, p] of [['run.py', kase.script_path], ['scenario.json', kase.scenario_path],
|
|
1248
|
+
['charts.json', kase.charts_path], ['panel.html', kase.html_path]]) {
|
|
1249
|
+
if (p && isFile(p)) files.push(`${name}(${fs.statSync(p).size}B)`);
|
|
1250
|
+
}
|
|
1251
|
+
const body = head
|
|
1252
|
+
+ `- 方案包:${kase.case_dir}(${files.join('、') || '空'})\n`
|
|
1253
|
+
+ `- 指标摘要:${kpiText}\n`
|
|
1254
|
+
+ `- 日志尾(${path.basename(paths.log_path)}):\n`
|
|
1255
|
+
+ '```\n'
|
|
1256
|
+
+ pySliceCodePoints(pyStrip(readLogTail(paths.log_path, 60) || '') || '(无日志)', 6000)
|
|
1257
|
+
+ '\n```\n'
|
|
1258
|
+
+ '请判断这次发压是否正常、方案包(run.py / charts.json / plan.md)'
|
|
1259
|
+
+ '是否需要修正;如需修正请直接改方案包文件,改完告诉我「已改好」,'
|
|
1260
|
+
+ '我会在压测面板点「复压」再跑一次。**不要自己执行压测脚本**'
|
|
1261
|
+
+ '(发压必须由平台托管,才能被停止/超时保护并留下运行记录)。';
|
|
1262
|
+
return pySliceCodePoints(body, chat.MESSAGE_MAX);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Python `text[:n]`(按**码点**切片,不是 UTF-16 码元 —— emoji 上会差一个字符) */
|
|
1266
|
+
function pySliceCodePoints(text, n) {
|
|
1267
|
+
const chars = Array.from(String(text));
|
|
1268
|
+
return chars.length <= n ? String(text) : chars.slice(0, n).join('');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* `_api_load_diagnose`(4616):把最近一次运行的执行现场发给该任务的 agent 会话。
|
|
1273
|
+
* 复用 `POST /api/tasks/<id>/session/chat` 的既有路径(进统一队列的 `m:` 单元),
|
|
1274
|
+
* 故这里只组装文本;调度/会话校验/错误文案全部沿用对话接口。
|
|
1275
|
+
*/
|
|
1276
|
+
async function apiLoadDiagnose(ctx, taskId) {
|
|
1277
|
+
await ctx.readBody(); // Python 在 do_POST:1048 统一读体
|
|
1278
|
+
const row = ctx.ownedTask(taskId);
|
|
1279
|
+
if (row === null) return;
|
|
1280
|
+
const text = loadDiagnoseText(row, ctx);
|
|
1281
|
+
// Python 传给 `_api_session_chat` 的是**合成 body**(`{"message": text}`),不是请求体
|
|
1282
|
+
await taskSessionChat(ctx, taskId, { message: text });
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** `_api_load_stream`(4670):压测指标 SSE(裸 id:/data: 帧 + Last-Event-ID 续传) */
|
|
1286
|
+
async function apiLoadStream(ctx, taskId) {
|
|
1287
|
+
const row = ctx.ownedTask(taskId);
|
|
1288
|
+
if (row === null) return;
|
|
1289
|
+
const run = ctx.q('run', '');
|
|
1290
|
+
const [metricsPath] = loadMetricsSource(row, run, ctx);
|
|
1291
|
+
const runRow = loadRunRow(row, run, ctx);
|
|
1292
|
+
const live = runRow !== null && runRow.status === 'running';
|
|
1293
|
+
const sse = ctx.sse({}); // 只有 Content-Type + Cache-Control
|
|
1294
|
+
let seen = 0;
|
|
1295
|
+
try {
|
|
1296
|
+
seen = pyIntStrict(String(ctx.req.headers['last-event-id'] ?? '') || '0');
|
|
1297
|
+
} catch {
|
|
1298
|
+
seen = 0; // int() 抛 ValueError ⇒ 0
|
|
1299
|
+
}
|
|
1300
|
+
// 断线续传:从 seen 行之后续推;首连:只回放最后一段
|
|
1301
|
+
let [offset, no] = scanMetrics(metricsPath, Math.max(0, seen),
|
|
1302
|
+
seen > 0 ? 0 : LOAD_STREAM_REPLAY_MAX);
|
|
1303
|
+
for (;;) {
|
|
1304
|
+
try {
|
|
1305
|
+
if (fs.statSync(metricsPath).size < offset) { offset = 0; no = 0; } // 轮转/清空
|
|
1306
|
+
} catch { /* 文件不存在:保持 */ }
|
|
1307
|
+
let chunk = null;
|
|
1308
|
+
try {
|
|
1309
|
+
chunk = fs.readFileSync(metricsPath);
|
|
1310
|
+
} catch {
|
|
1311
|
+
chunk = null;
|
|
1312
|
+
}
|
|
1313
|
+
if (chunk !== null) {
|
|
1314
|
+
const buf = offset > 0 ? chunk.subarray(offset) : chunk;
|
|
1315
|
+
const nl = buf.lastIndexOf(0x0a);
|
|
1316
|
+
if (nl >= 0) {
|
|
1317
|
+
const data = buf.subarray(0, nl); // 半行留在文件里下轮再读
|
|
1318
|
+
offset += data.length + 1;
|
|
1319
|
+
let start = 0;
|
|
1320
|
+
for (let i = 0; i <= data.length; i += 1) {
|
|
1321
|
+
if (i !== data.length && data[i] !== 0x0a) continue;
|
|
1322
|
+
const line = data.subarray(start, i);
|
|
1323
|
+
start = i + 1;
|
|
1324
|
+
if (!ASCII_NONSPACE_RE.test(line.toString('latin1'))) continue; // bytes.strip() 空行
|
|
1325
|
+
no += 1;
|
|
1326
|
+
for (const sample of normalizeRaw(line)) {
|
|
1327
|
+
sse.raw(`id: ${no}\ndata: ${jsonBody(sample)}\n\n`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const task = getTask(taskId, { db: ctx.db });
|
|
1333
|
+
if (task === null || !live || task.status !== 'running') {
|
|
1334
|
+
sse.raw('data: {"done":true}\n\n');
|
|
1335
|
+
sse.end();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// Python 靠写已断开的 socket 抛 BrokenPipe 退出本轮循环;Node 的 res.write 不抛
|
|
1339
|
+
// ⇒ 显式看这两个标志(否则客户端断开后循环会一直空转到任务结束)。
|
|
1340
|
+
if (ctx.res.writableEnded || ctx.res.destroyed) return;
|
|
1341
|
+
await sleepMs(1000);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** bytes.strip() 的「非空白」判据(ASCII 空白集;**不含** U+00A0 等 Unicode 空白) */
|
|
1346
|
+
const ASCII_NONSPACE_RE = /[^\t\n\v\f\r ]/;
|
|
1347
|
+
|
|
1348
|
+
/** 毫秒睡眠(`time.sleep(1.0)` 的异步对应物) */
|
|
1349
|
+
function sleepMs(ms) {
|
|
1350
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
export const routes = [
|
|
1354
|
+
{
|
|
1355
|
+
method: 'GET',
|
|
1356
|
+
path: '/api/tasks',
|
|
1357
|
+
handler: (ctx) => {
|
|
1358
|
+
// ② 硬编码紧凑 JSON(Python 原文),见文件头 ①
|
|
1359
|
+
ctx.text(200, '{"error":"task id required"}', 'application/json; charset=utf-8');
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
method: 'GET',
|
|
1364
|
+
re: /^\/api\/projects\/(\d+)\/tasks$/,
|
|
1365
|
+
handler: (ctx, m) => {
|
|
1366
|
+
const projectId = Number(m[1]);
|
|
1367
|
+
if (ctx.ownedProject(projectId) === null) return; // 已回 404 project not found
|
|
1368
|
+
const rows = listTasks(projectId, { db: ctx.db });
|
|
1369
|
+
ctx.json(200, rows.map((row) => taskJson(row, { db: ctx.db, stateCache: ctx.stateCache })));
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
method: 'GET',
|
|
1374
|
+
re: /^\/api\/tasks\/(\d+)$/,
|
|
1375
|
+
handler: (ctx, m) => {
|
|
1376
|
+
const row = ctx.ownedTask(Number(m[1]));
|
|
1377
|
+
if (row === null) return; // 已回 404 task not found
|
|
1378
|
+
ctx.json(200, taskJson(row, { db: ctx.db, stateCache: ctx.stateCache }));
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
method: 'GET',
|
|
1383
|
+
re: /^\/api\/tasks\/(\d+)\/rounds$/,
|
|
1384
|
+
handler: (ctx, m) => {
|
|
1385
|
+
const taskId = Number(m[1]);
|
|
1386
|
+
if (ctx.ownedTask(taskId) === null) return;
|
|
1387
|
+
ctx.json(200, listRounds(taskId, { db: ctx.db }));
|
|
1388
|
+
},
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
method: 'GET',
|
|
1392
|
+
re: /^\/api\/tasks\/(\d+)\/dialogue$/,
|
|
1393
|
+
handler: (ctx, m) => {
|
|
1394
|
+
const taskId = Number(m[1]);
|
|
1395
|
+
const row = ctx.ownedTask(taskId);
|
|
1396
|
+
if (row === null) return;
|
|
1397
|
+
const entries = readDialogue(listRounds(taskId, { db: ctx.db }));
|
|
1398
|
+
ctx.json(200, {
|
|
1399
|
+
task_id: taskId,
|
|
1400
|
+
model: effectiveModel(taskId, row.model, { db: ctx.db }),
|
|
1401
|
+
permission: row.permission === undefined ? null : row.permission,
|
|
1402
|
+
entries,
|
|
1403
|
+
});
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
method: 'GET',
|
|
1408
|
+
re: /^\/api\/tasks\/(\d+)\/log$/,
|
|
1409
|
+
handler: (ctx, m) => {
|
|
1410
|
+
const taskId = Number(m[1]);
|
|
1411
|
+
if (ctx.ownedTask(taskId) === null) return;
|
|
1412
|
+
const roundRaw = ctx.q('round', '0');
|
|
1413
|
+
const tailRaw = ctx.q('tail', '300');
|
|
1414
|
+
const tailNum = pyIntOrNull(tailRaw);
|
|
1415
|
+
if (tailNum === null) {
|
|
1416
|
+
// Python int() 抛 ValueError ⇒ 连接被关(见文件头 ②)
|
|
1417
|
+
abortLikePython(ctx);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const tail = Math.min(tailNum, 5000); // Python min(int, 5000)
|
|
1421
|
+
const roundNo = pyIsDigits(roundRaw) ? pyIntOr(roundRaw, 0) : 0;
|
|
1422
|
+
const row = getLogRound(taskId, roundNo, { db: ctx.db });
|
|
1423
|
+
if (row === null) {
|
|
1424
|
+
ctx.error(404, 'log not found');
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const content = row.log_path && isFile(row.log_path)
|
|
1428
|
+
? readLogTail(row.log_path, tail)
|
|
1429
|
+
: '';
|
|
1430
|
+
// round 原样回显查询串(Python 回的就是字符串,不是 int)
|
|
1431
|
+
ctx.json(200, { round: roundRaw, log: content });
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
// ---------- 压测(load)族(M6-6;顺序照 `_route_get` 1442-1466) ----------
|
|
1435
|
+
{
|
|
1436
|
+
method: 'GET',
|
|
1437
|
+
re: /^\/api\/tasks\/(\d+)\/load_series$/,
|
|
1438
|
+
handler: (ctx, m) => apiLoadMetrics(ctx, Number(m[1])), // 旧路径别名(无 UI 调用)
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
method: 'GET',
|
|
1442
|
+
re: /^\/api\/tasks\/(\d+)\/load\/case$/,
|
|
1443
|
+
handler: (ctx, m) => apiLoadCase(ctx, Number(m[1])),
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
method: 'GET',
|
|
1447
|
+
re: /^\/api\/tasks\/(\d+)\/load\/metrics$/,
|
|
1448
|
+
handler: (ctx, m) => apiLoadMetrics(ctx, Number(m[1])),
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
method: 'GET',
|
|
1452
|
+
re: /^\/api\/tasks\/(\d+)\/load\/runs$/,
|
|
1453
|
+
handler: (ctx, m) => apiLoadRuns(ctx, Number(m[1])),
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
method: 'GET',
|
|
1457
|
+
re: /^\/api\/tasks\/(\d+)\/load\/custom_html$/,
|
|
1458
|
+
handler: (ctx, m) => apiLoadCustomHtml(ctx, Number(m[1])),
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
method: 'GET',
|
|
1462
|
+
re: /^\/api\/tasks\/(\d+)\/load_report$/,
|
|
1463
|
+
handler: (ctx, m) => apiLoadReport(ctx, Number(m[1])),
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
method: 'GET',
|
|
1467
|
+
re: /^\/api\/tasks\/(\d+)\/load\/stream$/,
|
|
1468
|
+
handler: (ctx, m) => apiLoadStream(ctx, Number(m[1])),
|
|
1469
|
+
},
|
|
1470
|
+
// ---------- 写路径(顺序照 Python do_POST / do_PATCH / do_DELETE 里的先后) ----------
|
|
1471
|
+
{
|
|
1472
|
+
method: 'POST',
|
|
1473
|
+
re: /^\/api\/projects\/(\d+)\/tasks$/,
|
|
1474
|
+
handler: async (ctx, m) => apiCreateTask(ctx, Number(m[1])),
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
method: 'POST',
|
|
1478
|
+
re: /^\/api\/tasks\/(\d+)\/load\/rerun$/,
|
|
1479
|
+
handler: (ctx, m) => apiLoadRerun(ctx, Number(m[1])),
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
method: 'POST',
|
|
1483
|
+
re: /^\/api\/tasks\/(\d+)\/load\/diagnose$/,
|
|
1484
|
+
handler: (ctx, m) => apiLoadDiagnose(ctx, Number(m[1])),
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
method: 'POST',
|
|
1488
|
+
re: /^\/api\/tasks\/(\d+)\/(stop|restart)$/,
|
|
1489
|
+
handler: async (ctx, m) => apiTaskAction(ctx, Number(m[1]), m[2]),
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
method: 'POST',
|
|
1493
|
+
re: /^\/api\/tasks\/(\d+)\/continue$/,
|
|
1494
|
+
handler: async (ctx, m) => apiContinueTask(ctx, Number(m[1])),
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
method: 'PATCH',
|
|
1498
|
+
re: /^\/api\/tasks\/(\d+)$/,
|
|
1499
|
+
handler: async (ctx, m) => apiUpdateTask(ctx, Number(m[1])),
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
method: 'DELETE',
|
|
1503
|
+
re: /^\/api\/tasks\/(\d+)$/,
|
|
1504
|
+
handler: async (ctx, m) => apiDeleteTask(ctx, Number(m[1])),
|
|
1505
|
+
},
|
|
1506
|
+
];
|
|
1507
|
+
|
|
1508
|
+
// 已知差异(正常前端不产生,探针实测记录在案):
|
|
1509
|
+
// - 读路径三条(不会由前端产生——`api/index.js` 传的是 `Number`;`lib.js:pyIsDigits` 已把
|
|
1510
|
+
// 「ASCII 子集」定为全仓口径(C 族文件头 ③ 同款声明),不一致要么全仓一起改,要么都不改):
|
|
1511
|
+
// · `tail=1_0`:Python `int()` 认下划线(=10);这里 `pyIntOrNull` 不认 ⇒ 掐断连接(Python 200)。
|
|
1512
|
+
// · `round=١٢`(Unicode Nd 数字):Python `int()` = 12 去查第 12 轮;这里按 0 查 ⇒ 404。
|
|
1513
|
+
// · `round=²`(isdigit() 为真但 int() 抛):Python 掐断连接;这里按 0 查 ⇒ 404。
|
|
1514
|
+
// - 写路径两条(只影响手写调用/对拍构造,前端一律传字符串):
|
|
1515
|
+
// · **`int()` 的 Unicode 数字**:Python `int("١٢")==12`,这里 `pyIntStrict` 只认 ASCII
|
|
1516
|
+
// ⇒ `continue` 的 `stop_value` 会从「折算成功」变成 400「stop_value 非法」;
|
|
1517
|
+
// (`date_from`/`date_to` 的 Unicode 面已对齐:格式正则用 `\p{Nd}`,双侧比较按
|
|
1518
|
+
// `time.strptime` 的日历校验复刻,见 `normDateRange`/`pyStrtimeDate`)
|
|
1519
|
+
// · **非字符串容器的 `str()`**:Python 是 repr 形态(`['a']` / `{'k': 1}`),这里按
|
|
1520
|
+
// JSON 文本兜底(`["a"]` / `{"k":1}`,见 `pyStr`)。`str(None)=='None'`、`str(True)=='True'`、
|
|
1521
|
+
// `str(1.5)=='1.5'` 这类**标量**形态已逐字对齐(`stop_value: null` ⇒ 落库 `'None'`)。
|
|
1522
|
+
// - 压测族(M6-6)三条,口径与上面同源(都属「Python `int()` 比全仓 ASCII 口径宽」):
|
|
1523
|
+
// · `load/metrics?after=1_0`:Python `int("1_0")==10`(跳过前 10 行);这里 `pyIntOrNull`
|
|
1524
|
+
// 不认下划线 ⇒ 按 0 处理(回放全部)——与 `tail=1_0` 同一条已知差异;
|
|
1525
|
+
// · `load/metrics?after=١٢`(Unicode Nd):Python 认(=12),这里按 0;
|
|
1526
|
+
// · `load/stream` 的 `Last-Event-ID: 1_0`:Python `int()` 认(=10),这里 `pyIntStrict`
|
|
1527
|
+
// 认下划线(**这一处已对齐**);Unicode 数字两者都落到 `ValueError`/异常 ⇒ 都按首连。
|
|
1528
|
+
// - `load/metrics` 的行迭代按**通用换行**(`\r\n`/`\r` → `\n`)复刻 Python 文本模式;
|
|
1529
|
+
// `load/stream` 则按**字节**切 `\n`(Python 侧开的也是 `"rb"`)——两处的换行口径**不同**,
|
|
1530
|
+
// 这是 oracle 自己的形态,别统一。
|