@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.
Files changed (116) 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 +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -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 +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -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 +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -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 +426 -0
  33. package/backend/lib/proc.js +361 -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 +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,2260 @@
1
+ /**
2
+ * Node 后端数据层(`db.py` 的对应物,M1 地基):schema 契约装载、老库收敛、
3
+ * 声明式迁移执行、admin 种子与最小访问口。
4
+ *
5
+ * 设计口径(方案 §3.4「同一份 schema 契约」):
6
+ * - **真源在 `db.py`**,Node 侧只读 `backend/schema/*` 三个导出件(由 tools/export_schema.py
7
+ * 实测导出,禁止手改):`schema.sql`(目标 DDL,整段幂等)、`schema.json`(结构化目标,
8
+ * 收敛器按它补列/建索引)、`migrations.json`(数据级/结构重建清单,两版执行同一份)。
9
+ * - **启动序与 Python 一致**:`exec schema.sql` → 收敛(= Python `migrate()` 的 ALTER 补列)
10
+ * → `seedAdmin()` → 声明式迁移 steps。顺序是不变量:清单里所有步骤都只依赖已补齐的列,
11
+ * 而 `seed_admin()` 又要在「项目归属回填」之前把 admin id 落库。
12
+ * - **收敛只补缺、不改定义**:已存在的列/索引一律跳过(哪怕定义与目标不同)。改定义是
13
+ * 迁移清单的职责(如 ancient 夹具的 idx_wait_active 由清单重建)——收敛器顺手「修正」
14
+ * 会让两版的 executed 列表分叉,那正是对拍要抓的东西。
15
+ * - **未知 handler 必须抛错退出**(绝不静默跳过:静默跳过会让两侧库悄悄分叉)。
16
+ * - 时间字符串口径同 Python `db.now_str()`:`YYYY-MM-DD HH:MM:SS`(**本地时间**)。
17
+ *
18
+ * 已知与 Python 的差异(有意为之,见各函数注释):
19
+ * - 迁移里的 handler 对「非对象 JSON」容错(Python 侧会 AttributeError 崩启动);
20
+ * - JSON 里的**浮点**值文本形态(清单见 pyjson.js 文件头)。除此之外 JSON 文本与 Python
21
+ * 逐字节一致:所有 JSON 写入点统一走 `pyjson.js` 的 `pyJsonDumps`
22
+ * (= CPython `json.dumps(..., ensure_ascii=False)` 的默认形态)。
23
+ *
24
+ * 零 npm 依赖:只用 node:sqlite / node:crypto / node:fs / node:path。
25
+ * 注意 `node:sqlite` 在 Node 22.x 每次启动会往 stderr 打 ExperimentalWarning,
26
+ * 起进程时用 `--disable-warning=ExperimentalWarning` 压制(测试 spawn 子进程时同)。
27
+ * 后端**不得使用 `Session` API**(Node 22.19 没有)。
28
+ */
29
+ import { randomBytes } from 'node:crypto';
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { DatabaseSync } from 'node:sqlite';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ import { hashPassword, md5Password } from './auth.js';
37
+ import { boardTopic, publish } from './localbus.js';
38
+ import { pyJsonDumps } from './pyjson.js';
39
+
40
+ /** 仓库根 = 本文件上溯三级(<根>/backend/lib/db.js),与 lib/args.js 同口径 */
41
+ export const REPO_ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
42
+
43
+ /** schema 契约目录(`schema.sql` / `schema.json` / `migrations.json`) */
44
+ export const SCHEMA_DIR = path.join(REPO_ROOT, 'backend', 'schema');
45
+
46
+ /** 案例库子目录名(对齐 db.py:CASES_SUBDIR,工作目录化 handler 用它判「旧值是不是案例库根」) */
47
+ export const CASES_SUBDIR = 'free_style';
48
+
49
+ /** 契约缓存(三个文件一次读入,进程内不变;测试改文件不受影响,属有意行为) */
50
+ let _contract = null;
51
+
52
+ /**
53
+ * 装载 schema 契约(进程内缓存)。
54
+ * @returns {{schemaSql: string, schema: object, migrations: object}}
55
+ */
56
+ export function loadContract() {
57
+ if (_contract) return _contract;
58
+ _contract = {
59
+ schemaSql: fs.readFileSync(path.join(SCHEMA_DIR, 'schema.sql'), 'utf8'),
60
+ schema: JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, 'schema.json'), 'utf8')),
61
+ migrations: JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, 'migrations.json'), 'utf8')),
62
+ };
63
+ return _contract;
64
+ }
65
+
66
+ /** schema 版本号(来自 schema.json;两侧对拍前先断言相等,对齐 db.py:SCHEMA_VERSION) */
67
+ export const SCHEMA_VERSION = loadContract().schema.schemaVersion;
68
+
69
+ // --------------------------------------------------------------------------
70
+ // 通用小工具
71
+ // --------------------------------------------------------------------------
72
+
73
+ /**
74
+ * SQL 标识符加双引号(对齐 tools/export_schema.py:_quote_ident)。
75
+ * PRAGMA 与 ALTER 语句里的表名/索引名一律经它,避免保留字与特殊字符。
76
+ * @param {string} name
77
+ * @returns {string}
78
+ */
79
+ function quoteIdent(name) {
80
+ return '"' + String(name).replace(/"/g, '""') + '"';
81
+ }
82
+
83
+ /** Python `str.isspace()` 的字符集合(JS `\s` 多含 U+FEFF、少含 U+001C-U+001F/U+0085) */
84
+ const PY_SPACE = ' \t\n\v\f\r\x1c-\x1f\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
85
+ const PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g');
86
+ const PY_RSTRIP_RE = new RegExp(`[${PY_SPACE}]+$`);
87
+
88
+ /**
89
+ * Python `str.strip()` 等价(迁移 handler 的判空口径,与 db.py 逐字对齐)。
90
+ * @param {unknown} text
91
+ * @returns {string}
92
+ */
93
+ function pyStrip(text) {
94
+ return String(text == null ? '' : text).replace(PY_STRIP_RE, '');
95
+ }
96
+
97
+ /**
98
+ * Python `str.rstrip()` 等价(guide_text 合并时的右侧空白处理)。
99
+ * @param {unknown} text
100
+ * @returns {string}
101
+ */
102
+ function pyRstrip(text) {
103
+ return String(text == null ? '' : text).replace(PY_RSTRIP_RE, '');
104
+ }
105
+
106
+ /**
107
+ * 把 node:sqlite 的行对象转成普通对象(它的行是 null 原型,deepStrictEqual 与展开行为都反直觉)。
108
+ * 未命中(undefined)归一为 null,与 Python 的 `fetchone() is None` 同判据。
109
+ * @param {object|undefined} row
110
+ * @returns {object|null}
111
+ */
112
+ function plainRow(row) {
113
+ return row === undefined ? null : { ...row };
114
+ }
115
+
116
+ /**
117
+ * 同 plainRow 的批量版。
118
+ * @param {object[]} rows
119
+ * @returns {object[]}
120
+ */
121
+ function plainRows(rows) {
122
+ return rows.map((row) => ({ ...row }));
123
+ }
124
+
125
+ /**
126
+ * 参数绑定前把 undefined 归一为 null(node:sqlite 拒绑 undefined,而 Python 侧等价于绑 None)。
127
+ * @param {unknown} value
128
+ * @returns {unknown}
129
+ */
130
+ function bindable(value) {
131
+ return value === undefined ? null : value;
132
+ }
133
+
134
+ /**
135
+ * 当前时间字符串(本地时间,`YYYY-MM-DD HH:MM:SS`)。对齐 `db.now_str()`。
136
+ * 不用 toISOString():那是 UTC,口径会整体差时区偏移。
137
+ * @returns {string}
138
+ */
139
+ export function nowStr() {
140
+ const now = new Date();
141
+ const pad = (n) => String(n).padStart(2, '0');
142
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
143
+ + ` ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
144
+ }
145
+
146
+ /**
147
+ * 展开 `~` 前缀(对齐 Python `os.path.expanduser`,只处理最常见的 `~` 与 `~/`)。
148
+ * @param {string} p
149
+ * @returns {string}
150
+ */
151
+ function expandUser(p) {
152
+ if (p === '~') return os.homedir();
153
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
154
+ return p;
155
+ }
156
+
157
+ /**
158
+ * 数据库文件缺省路径:`TOUCHSTONE_DB` > `~/.touchstone/touchstone.db`(对齐 db.py:DB_OVERRIDE)。
159
+ *
160
+ * **测试进程禁落缺省路径**(2026-10-13 第三起误写生产库事故的结构性防线):
161
+ * `node --test` 会给每个测试文件子进程设 `NODE_TEST_CONTEXT`。此时若 `TOUCHSTONE_DB`
162
+ * 没设,说明调用方**漏传了 `{db}`/`{dbPath}`**——那正是历次事故的形态(缝形参错位 ⇒
163
+ * `options` 落空 ⇒ 静默读写成产库)。这里直接抛错,把「静默写生产库」变成「当场红」。
164
+ * 生产/独立形态不受影响(无 `NODE_TEST_CONTEXT`);对拍量具的 Node worker 是普通进程,
165
+ * 且它们显式设了 `TOUCHSTONE_DB`,同样不受影响。
166
+ *
167
+ * @param {{env?: NodeJS.ProcessEnv}} [options]
168
+ * @returns {string}
169
+ */
170
+ export function defaultDbPath({ env = process.env } = {}) {
171
+ if (env.TOUCHSTONE_DB) return expandUser(env.TOUCHSTONE_DB);
172
+ if (env.NODE_TEST_CONTEXT) {
173
+ throw new Error('测试进程禁止回落缺省库路径(= 生产库 ~/.touchstone/touchstone.db):'
174
+ + '请传 {db}/{dbPath},或在夹具里设 TOUCHSTONE_DB(见 backend/test/m3fixture.mjs)');
175
+ }
176
+ return path.join(os.homedir(), '.touchstone', 'touchstone.db');
177
+ }
178
+
179
+ /**
180
+ * 确保数据库文件的父目录存在(对齐 Python `connect()` 里的 os.makedirs)。
181
+ * `:memory:` 之类的特殊路径直接跳过。
182
+ * @param {string} dbPath
183
+ */
184
+ export function ensureParentDir(dbPath) {
185
+ if (!dbPath || dbPath === ':memory:' || dbPath.startsWith('file:')) return;
186
+ const dir = path.dirname(path.resolve(dbPath));
187
+ if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
188
+ }
189
+
190
+ /**
191
+ * 打开数据库连接(对齐 Python `db.connect()`:建目录 + 10s 忙等 + 外键打开)。
192
+ * 返回的 DatabaseSync 由调用方负责 close()(Python 是每调用短连接;Node 侧由调用方决定生命周期)。
193
+ * @param {string} dbPath 数据库文件路径(或 ':memory:')
194
+ * @param {{timeoutMs?: number}} [options] 忙等超时毫秒(对齐 sqlite3.connect(timeout=10))
195
+ * @returns {import('node:sqlite').DatabaseSync}
196
+ */
197
+ export function openDb(dbPath, { timeoutMs = 10000 } = {}) {
198
+ ensureParentDir(dbPath);
199
+ const wait = Number.isFinite(timeoutMs) ? Math.max(0, Math.trunc(timeoutMs)) : 10000;
200
+ const db = new DatabaseSync(dbPath, { timeout: wait });
201
+ // 显式再设一次 busy_timeout:老版本 22.x 的构造选项覆盖不到时的兜底,语义与 Python timeout 完全一致
202
+ db.exec(`PRAGMA busy_timeout = ${wait}`);
203
+ db.exec('PRAGMA foreign_keys=ON');
204
+ return db;
205
+ }
206
+
207
+ // --------------------------------------------------------------------------
208
+ // 结构读取(收敛与测试共用)
209
+ // --------------------------------------------------------------------------
210
+
211
+ /**
212
+ * 表是否存在。
213
+ * @param {import('node:sqlite').DatabaseSync} db
214
+ * @param {string} name
215
+ * @returns {boolean}
216
+ */
217
+ function tableExists(db, name) {
218
+ const row = db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type='table' AND name=?")
219
+ .get(name);
220
+ return row !== undefined;
221
+ }
222
+
223
+ /**
224
+ * 表的列元数据(PRAGMA table_info,按 name → 行)。
225
+ * @param {import('node:sqlite').DatabaseSync} db
226
+ * @param {string} table
227
+ * @returns {Map<string, object>}
228
+ */
229
+ function tableColumns(db, table) {
230
+ const map = new Map();
231
+ for (const row of db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all()) {
232
+ map.set(row.name, row);
233
+ }
234
+ return map;
235
+ }
236
+
237
+ /**
238
+ * 表的索引清单(PRAGMA index_list)。
239
+ * @param {import('node:sqlite').DatabaseSync} db
240
+ * @param {string} table
241
+ * @returns {object[]}
242
+ */
243
+ function indexList(db, table) {
244
+ return plainRows(db.prepare(`PRAGMA index_list(${quoteIdent(table)})`).all());
245
+ }
246
+
247
+ /**
248
+ * DEFAULT 子句的 SQL 字面量:schema.json 里的 `dflt` 是 SQLite 原文(`''` / `0` /
249
+ * `datetime('now', 'localtime')`)。字面量与数字直接拼;**表达式必须加括号**
250
+ * (ALTER TABLE ADD COLUMN 的 DEFAULT 只接受常量或括号表达式)。
251
+ * 判据照抄 tools/export_schema.py:_ddl_from_columns。
252
+ * @param {string} dflt
253
+ * @returns {string}
254
+ */
255
+ function defaultLiteral(dflt) {
256
+ const text = String(dflt);
257
+ if (text.startsWith("'")) return text;
258
+ const upper = text.toUpperCase();
259
+ if (upper === 'NULL' || upper === 'TRUE' || upper === 'FALSE') return text;
260
+ if (text.trim() !== '' && !Number.isNaN(Number(text))) return text;
261
+ return `(${text})`;
262
+ }
263
+
264
+ /**
265
+ * 一列的 DDL 片段(列名 + 类型 + NOT NULL + DEFAULT)。
266
+ * @param {object} column schema.json 的列元数据
267
+ * @returns {string}
268
+ */
269
+ function columnDdl(column) {
270
+ let sql = `${quoteIdent(column.name)} ${column.type || 'TEXT'}`;
271
+ if (column.notnull) sql += ' NOT NULL';
272
+ if (column.dflt !== null && column.dflt !== undefined) {
273
+ sql += ` DEFAULT ${defaultLiteral(column.dflt)}`;
274
+ }
275
+ return sql;
276
+ }
277
+
278
+ /**
279
+ * 给 CREATE 语句补 `IF NOT EXISTS`(schema.json 的 ddl 原文没有;口径照抄
280
+ * tools/export_schema.py:_with_if_not_exists)。
281
+ * @param {string} sql
282
+ * @returns {string}
283
+ */
284
+ function withIfNotExists(sql) {
285
+ if (sql.includes('IF NOT EXISTS')) return sql;
286
+ for (const head of ['CREATE UNIQUE INDEX ', 'CREATE INDEX ', 'CREATE TABLE ']) {
287
+ if (sql.startsWith(head)) return head + 'IF NOT EXISTS ' + sql.slice(head.length);
288
+ }
289
+ throw new Error(`无法补 IF NOT EXISTS 的语句: ${sql.slice(0, 60)}`);
290
+ }
291
+
292
+ // --------------------------------------------------------------------------
293
+ // 收敛:把老库补齐到 schema.json 的目标结构
294
+ // --------------------------------------------------------------------------
295
+
296
+ /**
297
+ * 按 schema.json 把库**补齐**(幂等):缺表建表、缺列 ALTER 补列、缺索引建索引。
298
+ *
299
+ * 只补缺、不改定义(见文件头):已存在的列/索引一律跳过。补列顺序照 schema.json 的
300
+ * 列序(列顺序不入契约,仅影响老库 ALTER 后的物理列序)。主键列不可能后补
301
+ * (SQLite 不允许 ADD COLUMN 带 PRIMARY KEY),老库里必然已有,直接跳过。
302
+ * @param {import('node:sqlite').DatabaseSync} db
303
+ * @returns {{addedColumns: string[], addedIndexes: string[]}} 本次实际补的东西(供日志/测试)
304
+ */
305
+ export function converge(db) {
306
+ const { schema } = loadContract();
307
+ const addedColumns = [];
308
+ const addedIndexes = [];
309
+ for (const table of schema.tables) {
310
+ if (!tableExists(db, table.name)) {
311
+ // schema.sql 已整段执行,正常不会走到这里;保留分支是为了「只喂 schema.json」也能收敛
312
+ db.exec(withIfNotExists(table.ddl));
313
+ }
314
+ const columns = tableColumns(db, table.name);
315
+ for (const column of table.columns) {
316
+ if (columns.has(column.name) || column.pk) continue;
317
+ db.exec(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${columnDdl(column)}`);
318
+ addedColumns.push(`${table.name}.${column.name}`);
319
+ }
320
+ const indexes = new Set(indexList(db, table.name).map((row) => row.name));
321
+ for (const index of table.indexes) {
322
+ if (!index.sql || indexes.has(index.name)) continue; // sql=null 是自动索引,建不了也不用建
323
+ db.exec(index.sql);
324
+ addedIndexes.push(index.name);
325
+ }
326
+ }
327
+ return { addedColumns, addedIndexes };
328
+ }
329
+
330
+ // --------------------------------------------------------------------------
331
+ // 声明式迁移(与 Python 共用 backend/schema/migrations.json)
332
+ // --------------------------------------------------------------------------
333
+
334
+ /**
335
+ * 判定一条迁移的运行条件是否成立(when 为空 = 无条件执行)。
336
+ * 守卫 SQL 只读、方言中立;kind 只有 `rows`(有行即跑)与 `index_origin_unique`
337
+ * (表上存在 UNIQUE 约束索引即跑)两种,未知 kind 直接抛错。
338
+ * @param {import('node:sqlite').DatabaseSync} db
339
+ * @param {object|null|undefined} when
340
+ * @returns {boolean}
341
+ */
342
+ export function migrationWhenHit(db, when) {
343
+ if (!when) return true;
344
+ if (when.kind === 'rows') {
345
+ return db.prepare(when.sql).get() !== undefined;
346
+ }
347
+ if (when.kind === 'index_origin_unique') {
348
+ return indexList(db, when.table).some((row) => row.origin === 'u');
349
+ }
350
+ throw new Error(`未知迁移守卫 kind: ${JSON.stringify(when.kind)}`);
351
+ }
352
+
353
+ /**
354
+ * handler:废弃列 commit_spec/deploy_spec 并入 guide_text 后清空(幂等)。
355
+ * 与 `db._mh_projects_guide_text_merge` 逐字等价:
356
+ * 段 = 「git commit message 规范:<commit_spec.strip()>」(非空时)+
357
+ * 「部署说明:<deploy_spec.strip()>」(非空时);
358
+ * merged = guide_text.rstrip(),非空则 `merged + "\n\n" + 段.join("\n")`,否则就是段 join。
359
+ * @param {import('node:sqlite').DatabaseSync} db
360
+ */
361
+ function projectsGuideTextMerge(db) {
362
+ const rows = db.prepare('SELECT id, guide_text, commit_spec, deploy_spec FROM projects').all();
363
+ const update = db.prepare(
364
+ "UPDATE projects SET guide_text=?, commit_spec='', deploy_spec='' WHERE id=?");
365
+ for (const row of rows) {
366
+ const extra = [];
367
+ const commitSpec = pyStrip(row.commit_spec);
368
+ const deploySpec = pyStrip(row.deploy_spec);
369
+ if (commitSpec) extra.push('git commit message 规范:' + commitSpec);
370
+ if (deploySpec) extra.push('部署说明:' + deploySpec);
371
+ if (!extra.length) continue;
372
+ const merged = pyRstrip(row.guide_text);
373
+ const text = merged ? merged + '\n\n' + extra.join('\n') : extra.join('\n');
374
+ update.run(text, row.id);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * handler:旧 work_dir(案例库根)改存工作目录、bug_dir 列废弃置空。
380
+ * 与 `db._mh_projects_work_dir_from_legacy_cases_root` 逐字等价:
381
+ * 旧值 strip + 去尾随 `/`;以 `/free_style` 结尾 → 取父目录(path.dirname),
382
+ * 否则回落 `path.join(project_dir || '~', '.touchstone')`;磁盘文件一律不动。
383
+ * 幂等标记 = bug_dir 非空(WHERE 子句)。
384
+ * @param {import('node:sqlite').DatabaseSync} db
385
+ */
386
+ function projectsWorkDirFromLegacyCasesRoot(db) {
387
+ const rows = db.prepare(
388
+ "SELECT id, project_dir, work_dir FROM projects WHERE bug_dir != ''").all();
389
+ const update = db.prepare("UPDATE projects SET work_dir=?, bug_dir='' WHERE id=?");
390
+ for (const row of rows) {
391
+ const old = pyRstrip(pyStrip(row.work_dir)).replace(/\/+$/, '');
392
+ // 注意:判定用字面 '/'、不用 os.sep(对齐 Python 侧硬编码的 "/" + CASES_SUBDIR)
393
+ const next = old.endsWith('/' + CASES_SUBDIR)
394
+ ? path.dirname(old)
395
+ : path.join(row.project_dir || '~', '.touchstone');
396
+ update.run(next, row.id);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * handler:旧全局飞书配置 app_settings['feishu'] → admin 的用户级配置。
402
+ * 与 `db._mh_feishu_appsettings_bridge` 等价:无行直接返回;坏 JSON 视为 `{}`;
403
+ * legacy 真值且任一凭据字段真值 → admin 存在且 feishu_user_cfgs 无该 user_id 行才 INSERT。
404
+ * 只 INSERT、不删 app_settings 行,故本步长期可重入(有用户级配置后恒为 no-op)。
405
+ * JSON 文本走 `pyJsonDumps`(CPython `json.dumps(ensure_ascii=False)` 默认形态),与 Python 逐字节一致。
406
+ * @param {import('node:sqlite').DatabaseSync} db
407
+ */
408
+ function feishuAppSettingsBridge(db) {
409
+ const row = db.prepare("SELECT json FROM app_settings WHERE key='feishu'").get();
410
+ if (row === undefined) return;
411
+ let legacy;
412
+ try {
413
+ legacy = JSON.parse(row.json);
414
+ } catch {
415
+ legacy = {}; // 对齐 Python 的 json.loads ValueError 分支
416
+ }
417
+ // Python:`(legacy and legacy.get("app_id")) or legacy.get("default_webhook") or ...`
418
+ // legacy 真值时与 `legacy && (a || b || ...)` 等价;legacy 假值(null/{}/""/0)两侧同为不迁移
419
+ // (Python 对 legacy=null 会 AttributeError 崩启动,这里按「不迁移」处理,属有意的更稳行为)
420
+ if (legacy && typeof legacy === 'object'
421
+ && (legacy.app_id || legacy.default_webhook || legacy.base_url
422
+ || legacy.app_secret || legacy.default_secret)) {
423
+ const admin = db.prepare("SELECT id FROM users WHERE username='admin'").get();
424
+ if (admin !== undefined
425
+ && db.prepare('SELECT 1 AS hit FROM feishu_user_cfgs WHERE user_id=?').get(admin.id)
426
+ === undefined) {
427
+ db.prepare('INSERT INTO feishu_user_cfgs(user_id, json) VALUES(?,?)')
428
+ .run(admin.id, pyJsonDumps(legacy));
429
+ }
430
+ }
431
+ }
432
+
433
+ /** 步骤名 → 宿主语言实现(与 db.py:MIGRATION_HANDLERS 同名同语义) */
434
+ export const HANDLERS = {
435
+ projects_guide_text_merge: projectsGuideTextMerge,
436
+ projects_work_dir_from_legacy_cases_root: projectsWorkDirFromLegacyCasesRoot,
437
+ feishu_appsettings_bridge: feishuAppSettingsBridge,
438
+ };
439
+
440
+ /**
441
+ * 执行一组迁移步骤(每条一步一个事务;返回**实际执行**的 step id 列表)。
442
+ * 未知 handler 抛错(绝不静默跳过);步骤内任一语句/handler 抛错 → 回滚该步并向上抛。
443
+ * @param {import('node:sqlite').DatabaseSync} db
444
+ * @param {object[]} steps migrations.json 的 steps(可直接注入自造步骤用于测试)
445
+ * @returns {string[]} executed step id
446
+ */
447
+ export function runMigrationSteps(db, steps) {
448
+ const executed = [];
449
+ for (const step of steps) {
450
+ if (!migrationWhenHit(db, step.when)) continue;
451
+ db.exec('BEGIN');
452
+ try {
453
+ for (const statement of step.sql || []) db.exec(statement);
454
+ if (step.handler) {
455
+ const handler = HANDLERS[step.handler];
456
+ if (!handler) {
457
+ throw new Error(`未知迁移 handler: ${step.handler}(步骤 ${step.id})`);
458
+ }
459
+ handler(db);
460
+ }
461
+ db.exec('COMMIT');
462
+ } catch (error) {
463
+ try {
464
+ db.exec('ROLLBACK');
465
+ } catch {
466
+ // 语句自身失败时事务可能已自动回滚,ROLLBACK 再失败不覆盖原始错误
467
+ }
468
+ throw error;
469
+ }
470
+ executed.push(step.id);
471
+ }
472
+ return executed;
473
+ }
474
+
475
+ /**
476
+ * 执行 migrations.json 的完整清单(幂等、可重复跑)。
477
+ * @param {import('node:sqlite').DatabaseSync} db
478
+ * @returns {string[]} 本次实际执行的 step id 列表
479
+ */
480
+ export function runDeclaredMigrations(db) {
481
+ return runMigrationSteps(db, loadContract().migrations.steps);
482
+ }
483
+
484
+ // --------------------------------------------------------------------------
485
+ // admin 种子
486
+ // --------------------------------------------------------------------------
487
+
488
+ /**
489
+ * 确保某张契约表的列齐全(seedAdmin 单独调用时的兜底;initDb 里已由 converge 覆盖)。
490
+ * @param {import('node:sqlite').DatabaseSync} db
491
+ * @param {string} tableName
492
+ */
493
+ function ensureContractColumns(db, tableName) {
494
+ const { schema } = loadContract();
495
+ const table = schema.tables.find((t) => t.name === tableName);
496
+ if (!table) throw new Error(`schema.json 里没有表: ${tableName}`);
497
+ const columns = tableColumns(db, tableName);
498
+ for (const column of table.columns) {
499
+ if (columns.has(column.name) || column.pk) continue;
500
+ db.exec(`ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${columnDdl(column)}`);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * 无任何用户时插入 admin(幂等:有用户就返回 seeded=false)。
506
+ *
507
+ * 口令来源(优先级与 db.py:seed_admin 逐条一致):
508
+ * ① `TS_ADMIN_PASSWORD` 非空 → 用它(PBKDF2 + 随机盐,must_change_pw=0,不强制改密);
509
+ * ② `TS_ADMIN_PASSWORDLESS=1` → 空口令(MD5 哈希、盐空串,must_change_pw=0;插件形态);
510
+ * ③ 都没有 → `randomBytes(12).toString('base64url')` 一次性口令(对齐
511
+ * `secrets.token_urlsafe(12)`,16 位),PBKDF2 + 随机盐,must_change_pw=1,
512
+ * 口令**只经返回值交出一次**(Python 侧是 take_seed_password() 读后即清)。
513
+ *
514
+ * 「users 表已存在但零用户」的边界:插入前先确保 `must_change_pw` 列在(老库的 users
515
+ * 表不会被 schema.sql 的 CREATE IF NOT EXISTS 改动)。
516
+ * @param {import('node:sqlite').DatabaseSync} db
517
+ * @param {{env?: NodeJS.ProcessEnv}} [options]
518
+ * @returns {{seeded: boolean, seedPassword: string}} seedPassword 仅「随机生成且必须改密」时非空
519
+ */
520
+ export function seedAdmin(db, { env = process.env } = {}) {
521
+ const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
522
+ if (count > 0) return { seeded: false, seedPassword: '' };
523
+ ensureContractColumns(db, 'users');
524
+
525
+ let password;
526
+ let mustChange;
527
+ let seedHash = null;
528
+ if (env.TS_ADMIN_PASSWORD) {
529
+ password = env.TS_ADMIN_PASSWORD;
530
+ mustChange = 0;
531
+ } else if (env.TS_ADMIN_PASSWORDLESS === '1') {
532
+ password = '';
533
+ mustChange = 0;
534
+ seedHash = md5Password('');
535
+ } else {
536
+ password = randomBytes(12).toString('base64url');
537
+ mustChange = 1;
538
+ }
539
+ let pwHash;
540
+ let salt;
541
+ if (seedHash !== null) {
542
+ pwHash = seedHash;
543
+ salt = '';
544
+ } else {
545
+ ({ hash: pwHash, salt } = hashPassword(password));
546
+ }
547
+ db.prepare('INSERT INTO users(username, pass_hash, salt, must_change_pw, created_at)'
548
+ + ' VALUES(?,?,?,?,?)')
549
+ .run('admin', pwHash, salt, mustChange, nowStr());
550
+ return { seeded: true, seedPassword: mustChange ? password : '' };
551
+ }
552
+
553
+ // --------------------------------------------------------------------------
554
+ // 启动序与版本
555
+ // --------------------------------------------------------------------------
556
+
557
+ /**
558
+ * 完整启动序(对齐 `db.init_db()`):
559
+ * `exec schema.sql` → 收敛(补列/建索引)→ seedAdmin → 声明式迁移 steps。
560
+ * @param {{dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
561
+ * dbPath 省略时回落 `defaultDbPath()`(TOUCHSTONE_DB > ~/.touchstone/touchstone.db)
562
+ * @returns {{seeded: boolean, seedPassword: string, executed: string[]}}
563
+ */
564
+ export function initDb({ dbPath, env = process.env } = {}) {
565
+ const target = dbPath || defaultDbPath({ env });
566
+ const db = openDb(target);
567
+ try {
568
+ db.exec(loadContract().schemaSql);
569
+ converge(db);
570
+ const { seeded, seedPassword } = seedAdmin(db, { env });
571
+ const executed = runDeclaredMigrations(db);
572
+ return { seeded, seedPassword, executed };
573
+ } finally {
574
+ db.close();
575
+ }
576
+ }
577
+
578
+ /**
579
+ * 读迁移清单最后一步写入的 schema 版本号(app_settings['schema_version'])。
580
+ * 未写入或不是 JSON 数字 → null(对拍前置断言:两侧必须相等)。
581
+ * @param {import('node:sqlite').DatabaseSync} db
582
+ * @returns {number|null}
583
+ */
584
+ export function schemaVersion(db) {
585
+ const row = db.prepare("SELECT json FROM app_settings WHERE key='schema_version'").get();
586
+ if (row === undefined) return null;
587
+ try {
588
+ const value = JSON.parse(row.json);
589
+ return typeof value === 'number' ? value : null;
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ // --------------------------------------------------------------------------
596
+ // 最小访问口(对齐 db.py 同名函数语义;Node 侧由调用方持有连接)
597
+ // --------------------------------------------------------------------------
598
+
599
+ /**
600
+ * 按用户名查用户行(未命中 null)。
601
+ * @param {import('node:sqlite').DatabaseSync} db
602
+ * @param {string} username
603
+ * @returns {object|null}
604
+ */
605
+ export function getUserByName(db, username) {
606
+ return plainRow(db.prepare('SELECT * FROM users WHERE username=?').get(bindable(username)));
607
+ }
608
+
609
+ /**
610
+ * 按 id 查用户行(未命中 null)。
611
+ * @param {import('node:sqlite').DatabaseSync} db
612
+ * @param {number} id
613
+ * @returns {object|null}
614
+ */
615
+ export function getUserById(db, id) {
616
+ return plainRow(db.prepare('SELECT * FROM users WHERE id=?').get(bindable(id)));
617
+ }
618
+
619
+ /**
620
+ * 新建会话行(created_at 取当前本地时间,与 `db.create_session` 同)。
621
+ * @param {import('node:sqlite').DatabaseSync} db
622
+ * @param {number} userId
623
+ * @param {string} token
624
+ * @param {string} expiresAt `YYYY-MM-DD HH:MM:SS`
625
+ */
626
+ export function createSession(db, userId, token, expiresAt) {
627
+ db.prepare('INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES(?,?,?,?)')
628
+ .run(bindable(token), bindable(userId), nowStr(), bindable(expiresAt));
629
+ }
630
+
631
+ /**
632
+ * 取未过期会话行(空 token / 未命中 / 已过期 → null)。语义对齐 `db.get_session`:
633
+ * 过期判据是字符串比较 `expires_at < now_str()`(`YYYY-MM-DD HH:MM:SS` 定长可直接比)。
634
+ * @param {import('node:sqlite').DatabaseSync} db
635
+ * @param {string} token
636
+ * @returns {object|null}
637
+ */
638
+ export function getSession(db, token) {
639
+ if (!token) return null;
640
+ const row = plainRow(db.prepare('SELECT * FROM sessions WHERE token=?').get(token));
641
+ if (!row) return null;
642
+ if (row.expires_at < nowStr()) return null;
643
+ return row;
644
+ }
645
+
646
+ /**
647
+ * 删除指定 token 的会话行。
648
+ * @param {import('node:sqlite').DatabaseSync} db
649
+ * @param {string} token
650
+ */
651
+ export function deleteSession(db, token) {
652
+ db.prepare('DELETE FROM sessions WHERE token=?').run(bindable(token));
653
+ }
654
+
655
+ /** 清理所有已过期会话行。 */
656
+ export function purgeExpiredSessions(db) {
657
+ db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(nowStr());
658
+ }
659
+
660
+ // --------------------------------------------------------------------------
661
+ // 读路径(M3):`db.py` 读函数对应物
662
+ // --------------------------------------------------------------------------
663
+ //
664
+ // 形态与口径(与 db.py 同名函数逐点等价——差异即对拍红灯):
665
+ // - **一次调用一个短连接**(对齐 Python 的 `with connect() as conn`;注意 Python 的
666
+ // `with conn` 只管事务提交/回滚、连接靠 GC 回收,Node 侧显式 close 更干净):
667
+ // 缺省落 `defaultDbPath()`(TOUCHSTONE_DB > ~/.touchstone/touchstone.db,与
668
+ // `lib/lock.js:resolveDbPath` 同源),可用 `options.dbPath` 覆盖;`options.db`
669
+ // 传已打开句柄时**复用它且不关**(端点内复用同一连接 / 单测 / 对拍用),句柄
670
+ // 直接当 options 传也认(见 isReadHandle)。
671
+ // - 行一律经 `plainRow`/`plainRows` 转普通对象:`row["col"]` 取值语义与 sqlite3.Row
672
+ // 一致;**不支持** `row[0]` 位置下标(那是 sqlite3.Row 独有的,M3 调用方全走列名)。
673
+ // - NULL → `null`(= Python None 的 JSON 形态);只有 `rowOpt` 按 Python 语义把
674
+ // None 折成 default,其余一律不许把 None 折成空串。
675
+ // - SQL 与 db.py **逐字同序**(列集合、WHERE、GROUP BY、ORDER BY 全照抄):
676
+ // 「顺手优化」会让对拍红,而排序/过滤差异在业务上是静默错误。
677
+
678
+ /** bug 报告子目录名(对齐 db.py:BUG_SUBDIR;`bug_dir` 列已废弃,一律由 work_dir 派生) */
679
+ export const BUG_SUBDIR = 'bug_report';
680
+
681
+ /**
682
+ * 第一个参数是不是一个连接句柄(DatabaseSync 独有的 `prepare` + `exec` 两个方法)。
683
+ *
684
+ * 为什么要有这条容错:M1 的访问口是「句柄当第一个参数」(`getUserByName(db, name)`),
685
+ * 读路径改成了末位 options 袋,于是集成层两种写法都出现过(`listUsers(ctx.db)` 与
686
+ * `getProject(id, {db: ctx.db})`)。**两者都必须复用同一个句柄**——把句柄当 options 袋
687
+ * 静默解构的后果是回落 `TOUCHSTONE_DB` 去开另一个库(生产同库时看不出,隔离实例/单测
688
+ * 里就是读错库)。故在此收口,不指望调用方记住口径。
689
+ * @param {unknown} value
690
+ * @returns {boolean}
691
+ */
692
+ function isReadHandle(value) {
693
+ return !!value && typeof value === 'object'
694
+ && typeof value.prepare === 'function' && typeof value.exec === 'function';
695
+ }
696
+
697
+ /**
698
+ * 跑一次读查询:缺省自开短连接(返回前 close),给了句柄就复用调用方句柄(不关)。
699
+ * 句柄可以装在 `options.db` 里,也可以直接当 options 传(见 isReadHandle)。
700
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}|object} [options]
701
+ * @param {(handle: import('node:sqlite').DatabaseSync) => any} fn
702
+ * @returns {any}
703
+ */
704
+ export function withReadDb(options, fn) {
705
+ const opts = isReadHandle(options) ? { db: options } : (options || {});
706
+ const { db = null, dbPath = null, env = process.env } = opts;
707
+ if (db) return fn(db);
708
+ const handle = openDb(dbPath || defaultDbPath({ env }));
709
+ try {
710
+ return fn(handle);
711
+ } finally {
712
+ handle.close();
713
+ }
714
+ }
715
+
716
+ /**
717
+ * 跑一次写事务:缺省自开短连接并**包一层 `BEGIN`…`COMMIT`**(异常 `ROLLBACK` + 重抛),
718
+ * 给了句柄就复用调用方句柄(**不代为开事务、也不提交**——外层负责事务边界)。
719
+ *
720
+ * 与 Python 的对应关系(`waitq.py`/`chat.py`/`runner.py` 的写口几乎全是
721
+ * `with db.connect() as conn:`):
722
+ * - Python 的 `with conn:` 是**事务上下文**(成功 commit、异常 rollback),但**不 close**;
723
+ * 连接靠引用计数回收。本函数把「事务 + 归还连接」合成一步,语义等价且不留悬挂句柄。
724
+ * - 差异只有**加锁时刻**:Python 的隐式事务在**第一条 DML** 才 BEGIN(块内纯 SELECT 不开事务),
725
+ * 本函数在块首就放一个 `BEGIN`(SQLite 的 `BEGIN` 是 DEFERRED,纯 SELECT 只取 SHARED 锁)。
726
+ * 为什么可以接受:Node 侧 `node:sqlite` 是**同步 API + 单事件循环**,进程内所有库访问天然串行;
727
+ * 跨进程并发由**同库单实例锁**(`lifecycle`/`lock.js`)挡死——两版永不并发写同一个库。
728
+ * ⚠ 例外:将来把重活挪进 `worker_threads`(M3 方案 R2)时,线程各有连接,这条前提失效,
729
+ * 届时要重新审「块内 SELECT → DML」两段式加锁(rollback journal 下双方各持 SHARED 抢 RESERVED
730
+ * 会直接 SQLITE_BUSY 而不是等忙)。
731
+ * - **不许嵌套**:内层若再调 `withWriteDb` 会撞 `cannot start a transaction within a transaction`。
732
+ * 块内复用连接的函数一律写成 `xxxOn(handle, …)` 形态(同 `waitq.js` 读侧的既有做法)。
733
+ *
734
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv, tx?: boolean}|object} [options]
735
+ * @param {(handle: import('node:sqlite').DatabaseSync) => any} fn
736
+ * @returns {any}
737
+ */
738
+ export function withWriteDb(options, fn) {
739
+ const opts = isReadHandle(options) ? { db: options } : (options || {});
740
+ const { db = null, dbPath = null, env = process.env, tx = true } = opts;
741
+ if (db) return fn(db); // 复用调用方句柄:事务边界归调用方
742
+ const handle = openDb(dbPath || defaultDbPath({ env }));
743
+ try {
744
+ if (tx) handle.exec('BEGIN');
745
+ const result = fn(handle);
746
+ if (result && typeof result.then === 'function') {
747
+ // 异步体在这里是**必然的错误**:COMMIT/finally-close 会在 await 之前跑完,
748
+ // 事务边界与调用方的意图脱节(等待期间连接已关)。宁可炸得响,不许静默半提交。
749
+ throw new Error('withWriteDb 的 fn 必须是同步的(node:sqlite 同步 API);'
750
+ + '需要 await 的部分请放在 withWriteDb 之外');
751
+ }
752
+ if (tx) handle.exec('COMMIT');
753
+ return result;
754
+ } catch (err) {
755
+ if (tx) {
756
+ try { handle.exec('ROLLBACK'); } catch { /* 事务可能已因错误自动回滚,吞掉 */ }
757
+ }
758
+ throw err;
759
+ } finally {
760
+ handle.close();
761
+ }
762
+ }
763
+
764
+ /** `pyJsonLoads` 的哨兵串:只在这三个字面量出现处替换,反序列化后再还原成三个特殊浮点 */
765
+ const PY_JSON_NAN = '\u0000py-json:NaN\u0000';
766
+ const PY_JSON_INF = '\u0000py-json:Infinity\u0000';
767
+ const PY_JSON_NEG_INF = '\u0000py-json:-Infinity\u0000';
768
+
769
+ /**
770
+ * 把源码里**字符串之外**的 `NaN` / `Infinity` / `-Infinity` 换成哨兵串(只在严格解析
771
+ * 失败后才走这里,见 pyJsonLoads);一处都没换返回 null(没必要重试)。
772
+ * 逐字符扫描是为了不误伤字符串里的同名文本(`{"a": "NaN"}` 是合法 JSON)。
773
+ * @param {string} src
774
+ * @returns {string|null}
775
+ */
776
+ function patchPyJsonSpecials(src) {
777
+ let out = '';
778
+ let changed = false;
779
+ let inString = false;
780
+ for (let i = 0; i < src.length;) {
781
+ const ch = src[i];
782
+ if (inString) {
783
+ out += ch;
784
+ if (ch === '\\') { // 转义对整体跳过,免得把 \" 当成字符串结束
785
+ out += src[i + 1] ?? '';
786
+ i += 2;
787
+ continue;
788
+ }
789
+ if (ch === '"') inString = false;
790
+ i += 1;
791
+ continue;
792
+ }
793
+ if (ch === '"') {
794
+ inString = true;
795
+ out += ch;
796
+ i += 1;
797
+ continue;
798
+ }
799
+ if (src.startsWith('-Infinity', i)) {
800
+ out += JSON.stringify(PY_JSON_NEG_INF);
801
+ i += 9;
802
+ changed = true;
803
+ continue;
804
+ }
805
+ if (src.startsWith('Infinity', i)) {
806
+ out += JSON.stringify(PY_JSON_INF);
807
+ i += 8;
808
+ changed = true;
809
+ continue;
810
+ }
811
+ if (src.startsWith('NaN', i)) {
812
+ out += JSON.stringify(PY_JSON_NAN);
813
+ i += 3;
814
+ changed = true;
815
+ continue;
816
+ }
817
+ out += ch;
818
+ i += 1;
819
+ }
820
+ return changed ? out : null;
821
+ }
822
+
823
+ /**
824
+ * CPython `json.loads()` 的读侧对应物(只补齐读路径可观察到的差异)。
825
+ *
826
+ * 为什么要自己写而不是直接用 `JSON.parse`:Python 默认 `allow_nan=True`,
827
+ * `json.loads('{"a": NaN}')` **是合法的**(返回 float('nan')),而 `JSON.parse` 直接抛。
828
+ * 平台自己的写点全走 `pyJsonDumps`(非有限浮点会写成 `NaN`/`Infinity` 字面量),
829
+ * 所以这种值确实可能是平台写下的,不该被当成「坏 JSON」降级掉。故:先严格解析;
830
+ * 失败且源码里有这三种字面量才替换重试;仍失败一律抛 `SyntaxError`(= Python 的
831
+ * `ValueError` 分支,调用方据此降级)。
832
+ *
833
+ * 入参口径照抄 Python:只接受 str(Node 里另加 BLOB 的 Uint8Array,Python 的
834
+ * `json.loads` 也收 bytes);其它类型抛 `TypeError`——`getUserPrefs` 这类调用点
835
+ * 只 catch ValueError,与 Python 一样让 TypeError 冒出去。
836
+ *
837
+ * 已知不覆盖:超出 2^53 的整数字面量(Python 任意精度 int,JS 只能折成浮点)。
838
+ * 库里没有这种值(schema_version 等都是小整数);真出现时两侧行为差异会在对拍里现形。
839
+ * @param {string|Uint8Array} text
840
+ * @returns {any}
841
+ */
842
+ function pyJsonLoads(text) {
843
+ let src;
844
+ if (typeof text === 'string') src = text;
845
+ else if (text instanceof Uint8Array) src = new TextDecoder('utf-8').decode(text);
846
+ else {
847
+ throw new TypeError(
848
+ `the JSON object must be str, bytes or bytearray, not ${text === null ? 'NoneType' : typeof text}`);
849
+ }
850
+ try {
851
+ return JSON.parse(src);
852
+ } catch (strictError) {
853
+ const patched = patchPyJsonSpecials(src);
854
+ if (patched === null) throw strictError;
855
+ return JSON.parse(patched, (key, value) => {
856
+ if (value === PY_JSON_NAN) return NaN;
857
+ if (value === PY_JSON_INF) return Infinity;
858
+ if (value === PY_JSON_NEG_INF) return -Infinity;
859
+ return value;
860
+ });
861
+ }
862
+ }
863
+
864
+ /**
865
+ * 案例库根 = `<工作目录>/free_style`(对齐 `db.cases_root_of`)。
866
+ * @param {string} workDir
867
+ * @returns {string}
868
+ */
869
+ export function casesRootOf(workDir) {
870
+ return path.join(workDir, CASES_SUBDIR);
871
+ }
872
+
873
+ /**
874
+ * bug 报告目录 = `<工作目录>/bug_report`(对齐 `db.bug_root_of`)。
875
+ * @param {string} workDir
876
+ * @returns {string}
877
+ */
878
+ export function bugRootOf(workDir) {
879
+ return path.join(workDir, BUG_SUBDIR);
880
+ }
881
+
882
+ /**
883
+ * 行取值容错:列在则取值,缺列/None 回落 default(对齐 `db.row_opt`)。
884
+ *
885
+ * 为什么需要:起会话路径要读**新加列**(项目 reasoning_effort / permission_mode),
886
+ * 而单测里手工构造的行对象可能缺这些键——读一个可选默认值不该让起会话抛错。
887
+ * 与 Python 的差异只有一处:Python 的 sqlite3.Row 支持 `row[0]` 位置下标(IndexError
888
+ * 分支),普通对象没有位置语义,传数字键只会回落 default(M3 调用方全走列名)。
889
+ * @param {object|null|undefined} row
890
+ * @param {string} key
891
+ * @param {any} [defaultValue] Python 侧形参名是保留字 `default`,故此处改名(位置传参不变)
892
+ * @returns {any}
893
+ */
894
+ export function rowOpt(row, key, defaultValue = '') {
895
+ if (row === null || row === undefined) return defaultValue; // 对齐 row[key] 的 TypeError 分支
896
+ const value = row[key];
897
+ return value === undefined || value === null ? defaultValue : value;
898
+ }
899
+
900
+ /**
901
+ * projects 行 → 普通对象,注入派生路径键(读取层统一派生,下游直接取用):
902
+ * - `cases_root`:案例库根 = `<工作目录>/free_style`
903
+ * - `bug_dir`:bug 报告目录 = `<工作目录>/bug_report`(**覆盖**已废弃的同名存量列值)
904
+ *
905
+ * 为什么在读取层做:`bug_dir` 列已废弃(迁移后恒为空串),但存量调用方仍按
906
+ * `row["bug_dir"]` 取报告目录;在这里统一注入,下游与业务层零改动。
907
+ * 键序说明:JS 与 Python 一样,给**已存在**的键赋新值只改值、不动位置(`bug_dir`
908
+ * 在原列位,`cases_root` 追加在末尾),故两侧键序也一致。
909
+ * @param {object} row
910
+ * @returns {object}
911
+ */
912
+ export function projectView(row) {
913
+ const view = { ...row };
914
+ view.cases_root = casesRootOf(view.work_dir);
915
+ view.bug_dir = bugRootOf(view.work_dir);
916
+ return view;
917
+ }
918
+
919
+ /**
920
+ * 按用户列出项目(多用户隔离),仅返回归属该用户的项目(对齐 `db.list_projects`)。
921
+ * @param {number} userId
922
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
923
+ * @returns {object[]}
924
+ */
925
+ export function listProjects(userId, options = {}) {
926
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
927
+ 'SELECT * FROM projects WHERE user_id=? ORDER BY id').all(bindable(userId))))
928
+ .map(projectView);
929
+ }
930
+
931
+ /**
932
+ * 全部项目(跨用户;目录重复检测等场景用,对齐 `db.list_projects_all`)。
933
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
934
+ * @returns {object[]}
935
+ */
936
+ export function listProjectsAll(options = {}) {
937
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
938
+ 'SELECT * FROM projects ORDER BY id').all())).map(projectView);
939
+ }
940
+
941
+ /**
942
+ * 按 id 取项目(不校验归属;归属校验在 server 层按当前用户进行,对齐 `db.get_project`)。
943
+ * @param {number} projectId
944
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
945
+ * @returns {object|null}
946
+ */
947
+ export function getProject(projectId, options = {}) {
948
+ const row = withReadDb(options, (handle) => plainRow(handle.prepare(
949
+ 'SELECT * FROM projects WHERE id=?').get(bindable(projectId))));
950
+ return row === null ? null : projectView(row);
951
+ }
952
+
953
+ /**
954
+ * 全部用户(后台管理用),按 id 升序;**只取三列**(不含口令哈希与盐,对齐 `db.list_users`)。
955
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
956
+ * @returns {object[]}
957
+ */
958
+ export function listUsers(options = {}) {
959
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
960
+ 'SELECT id, username, created_at FROM users ORDER BY id').all()));
961
+ }
962
+
963
+ /**
964
+ * 按 id 取任务(未命中 null,对齐 `db.get_task`)。
965
+ * @param {number} taskId
966
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
967
+ * @returns {object|null}
968
+ */
969
+ export function getTask(taskId, options = {}) {
970
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
971
+ 'SELECT * FROM tasks WHERE id=?').get(bindable(taskId))));
972
+ }
973
+
974
+ /**
975
+ * 项目下任务,**新任务在前**(`ORDER BY id DESC`;任务列表 UI 的口径,对齐 `db.list_tasks`)。
976
+ * @param {number} projectId
977
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
978
+ * @returns {object[]}
979
+ */
980
+ export function listTasks(projectId, options = {}) {
981
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
982
+ 'SELECT * FROM tasks WHERE project_id=? ORDER BY id DESC').all(bindable(projectId))));
983
+ }
984
+
985
+ /**
986
+ * 全部任务(服务启动恢复时用),按 id 升序(对齐 `db.list_all_tasks`)。
987
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
988
+ * @returns {object[]}
989
+ */
990
+ export function listAllTasks(options = {}) {
991
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
992
+ 'SELECT * FROM tasks ORDER BY id').all()));
993
+ }
994
+
995
+ /**
996
+ * 任务的轮次,按 `round_no` 升序(**不是 id**:压测的运行键与 agent 轮次共表,
997
+ * 轮次号才是叙述顺序,对齐 `db.list_rounds`)。
998
+ * @param {number} taskId
999
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1000
+ * @returns {object[]}
1001
+ */
1002
+ export function listRounds(taskId, options = {}) {
1003
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
1004
+ 'SELECT * FROM rounds WHERE task_id=? ORDER BY round_no').all(bindable(taskId))));
1005
+ }
1006
+
1007
+ /**
1008
+ * 按 id 取轮次行(未命中 null,对齐 `db.get_round`)。
1009
+ * @param {number} roundId
1010
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1011
+ * @returns {object|null}
1012
+ */
1013
+ export function getRound(roundId, options = {}) {
1014
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
1015
+ 'SELECT * FROM rounds WHERE id=?').get(bindable(roundId))));
1016
+ }
1017
+
1018
+ /**
1019
+ * 按 任务+轮次号 取轮次行(日志查询用,对齐 `db.get_log_round`):
1020
+ * 不用 id 是因为「任务第 N 轮」才是用户与 URL 的口径。
1021
+ * @param {number} taskId
1022
+ * @param {number} roundNo
1023
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1024
+ * @returns {object|null}
1025
+ */
1026
+ export function getLogRound(taskId, roundNo, options = {}) {
1027
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
1028
+ 'SELECT * FROM rounds WHERE task_id=? AND round_no=?').get(bindable(taskId), bindable(roundNo))));
1029
+ }
1030
+
1031
+ /**
1032
+ * 按 id 取卡片(不校验项目归属,归属由 server 层 `_owned_project` 保证,对齐 `db.get_board_card`)。
1033
+ * @param {number} cardId
1034
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1035
+ * @returns {object|null}
1036
+ */
1037
+ export function getBoardCard(cardId, options = {}) {
1038
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
1039
+ 'SELECT * FROM board_cards WHERE id=?').get(bindable(cardId))));
1040
+ }
1041
+
1042
+ /**
1043
+ * 项目全部**有效**卡片(回收站外;按列 + 列内序号 + id 排序,对齐 `db.list_board_cards`)。
1044
+ * `trashed=0` 是读板路径的统一过滤:软删卡只出现在回收站里。
1045
+ * @param {number} projectId
1046
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1047
+ * @returns {object[]}
1048
+ */
1049
+ export function listBoardCards(projectId, options = {}) {
1050
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
1051
+ "SELECT * FROM board_cards WHERE project_id=? AND trashed=0"
1052
+ + ' ORDER BY column_key, sort_order, id').all(bindable(projectId))));
1053
+ }
1054
+
1055
+ /**
1056
+ * 项目回收站卡片(**新删在前**:trashed_at DESC, id DESC,对齐 `db.list_trashed_cards`)。
1057
+ * @param {number} projectId
1058
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1059
+ * @returns {object[]}
1060
+ */
1061
+ export function listTrashedCards(projectId, options = {}) {
1062
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
1063
+ 'SELECT * FROM board_cards WHERE project_id=? AND trashed=1'
1064
+ + ' ORDER BY trashed_at DESC, id DESC').all(bindable(projectId))));
1065
+ }
1066
+
1067
+ /**
1068
+ * 批量统计各项目看板三列(doing/blocked/review)的卡片数量(对齐 `db.board_counts_many`)。
1069
+ *
1070
+ * 只统计回收站外卡片(trashed=0);排队占位卡(block_kind='queue')落在 doing 列一并计入。
1071
+ * 返回 `{project_id: {doing, blocked, review}}`:**入参里的每个项目都在**(无卡片记 0),
1072
+ * 项目列表端点用它一次 GROUP BY 算全量,避免逐项目查库;空入参直接返回 `{}`(**不开连接**)。
1073
+ * 键序说明:JS 对象的整数样键按升序列举(Python dict 是插入序),但调用方只按 id 取值、
1074
+ * 且序列化成 JSON 后键都是字符串,故不入判据(值集合与默认项完全一致)。
1075
+ * @param {number[]} projectIds
1076
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1077
+ * @returns {Record<string, {doing: number, blocked: number, review: number}>}
1078
+ */
1079
+ export function boardCountsMany(projectIds, options = {}) {
1080
+ const ids = projectIds.map((pid) => bindable(pid));
1081
+ const out = Object.fromEntries(ids.map((pid) => [pid, { doing: 0, blocked: 0, review: 0 }]));
1082
+ if (!ids.length) return out;
1083
+ const marks = ids.map(() => '?').join(', ');
1084
+ return withReadDb(options, (handle) => {
1085
+ const rows = handle.prepare(
1086
+ 'SELECT project_id, column_key, COUNT(*) FROM board_cards'
1087
+ + " WHERE trashed=0 AND column_key IN ('doing','blocked','review')"
1088
+ + ` AND project_id IN (${marks}) GROUP BY project_id, column_key`).all(...ids);
1089
+ // Python 是 `if pid in out`:int 1 与 str "1" 不是同一个键,用 Set 保住这条严格性
1090
+ const known = new Set(ids);
1091
+ for (const row of rows) {
1092
+ const [pid, col, n] = Object.values(row); // 列序即 SELECT 序(同 Python 的元组解包)
1093
+ if (known.has(pid)) out[pid][col] = n;
1094
+ }
1095
+ return out;
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * 全部项目的排队看板卡片(P4 单态:`block_kind='queue'` + `column_key='doing'`,
1101
+ * 按 `updated_at, id` 升序)——runner recover 重建队列用(对齐 `db.list_queued_board_cards`)。
1102
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1103
+ * @returns {object[]}
1104
+ */
1105
+ export function listQueuedBoardCards(options = {}) {
1106
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
1107
+ "SELECT * FROM board_cards WHERE block_kind='queue' AND trashed=0"
1108
+ + " AND column_key='doing' ORDER BY updated_at, id").all()));
1109
+ }
1110
+
1111
+ /**
1112
+ * 项目全部卡片的评论(JOIN 限定项目,按评论 id 升序;前端按 card_id 分组,对齐
1113
+ * `db.list_board_comments`)。
1114
+ *
1115
+ * 注意与读板路径的差别:这里**刻意不过滤 `trashed=0`**——回收站里的卡片也保留
1116
+ * 评论(还原后要能看见),Python 侧就没有这道过滤,照抄。
1117
+ * @param {number} projectId
1118
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1119
+ * @returns {object[]}
1120
+ */
1121
+ export function listBoardComments(projectId, options = {}) {
1122
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
1123
+ 'SELECT c.* FROM board_comments c'
1124
+ + ' JOIN board_cards k ON k.id=c.card_id'
1125
+ + ' WHERE k.project_id=? ORDER BY c.id').all(bindable(projectId))));
1126
+ }
1127
+
1128
+ /**
1129
+ * 看板设置(无记录 / 坏 JSON 都返回 `{}`,调用方再与默认值合并,对齐 `db.get_board_settings`)。
1130
+ * @param {number} projectId
1131
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1132
+ * @returns {object}
1133
+ */
1134
+ export function getBoardSettings(projectId, options = {}) {
1135
+ const row = withReadDb(options, (handle) => plainRow(handle.prepare(
1136
+ 'SELECT json FROM board_settings WHERE project_id=?').get(bindable(projectId))));
1137
+ if (row === null) return {};
1138
+ try {
1139
+ return pyJsonLoads(row.json);
1140
+ } catch (error) {
1141
+ if (!(error instanceof SyntaxError)) throw error; // 只吞 ValueError 分支(同 Python)
1142
+ return {};
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * 全局 KV 设置(app_settings 表,json 列):dict 或 `{}`(无记录/坏 JSON 降级,
1148
+ * 对齐 `db.get_app_setting`)。
1149
+ * @param {string} key
1150
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1151
+ * @returns {object}
1152
+ */
1153
+ export function getAppSetting(key, options = {}) {
1154
+ const row = withReadDb(options, (handle) => plainRow(handle.prepare(
1155
+ 'SELECT json FROM app_settings WHERE key=?').get(bindable(key))));
1156
+ if (row === null) return {};
1157
+ try {
1158
+ return pyJsonLoads(row.json);
1159
+ } catch (error) {
1160
+ if (!(error instanceof SyntaxError)) throw error; // 只吞 ValueError 分支(同 Python)
1161
+ return {};
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * 某用户在某项目下的全部 UI 偏好:`{key: value}`(对齐 `db.get_user_prefs`)。
1167
+ *
1168
+ * **坏 JSON 的键降级跳过**(`value` 列被手工改坏时不能炸掉整个设置页),逐条 try/catch
1169
+ * 与 Python 的 `continue` 同义;键序 = 行序(用 `Object.fromEntries` 还能安全容纳
1170
+ * `__proto__` 这种键名——直接赋值会去改原型,Python 侧却是普通键)。
1171
+ * @param {number} userId
1172
+ * @param {number} projectId
1173
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1174
+ * @returns {Record<string, any>}
1175
+ */
1176
+ export function getUserPrefs(userId, projectId, options = {}) {
1177
+ const rows = withReadDb(options, (handle) => plainRows(handle.prepare(
1178
+ 'SELECT key, value FROM user_prefs WHERE user_id=? AND project_id=?')
1179
+ .all(bindable(userId), bindable(projectId))));
1180
+ const entries = [];
1181
+ for (const row of rows) {
1182
+ try {
1183
+ entries.push([row.key, pyJsonLoads(row.value)]);
1184
+ } catch (error) {
1185
+ if (!(error instanceof SyntaxError)) throw error; // 只吞 ValueError 分支(同 Python)
1186
+ // 坏 JSON(手工改库等)视同无此偏好
1187
+ }
1188
+ }
1189
+ return Object.fromEntries(entries);
1190
+ }
1191
+
1192
+ // --------------------------------------------------------------------------
1193
+ // 写口(M4 起:调度核心要用的任务/轮次写;**白名单与 SQL 照 db.py 逐字**)
1194
+ // --------------------------------------------------------------------------
1195
+
1196
+ /**
1197
+ * Python `int(x)` 的最小实现(`insert_task` 的 `int(cases_base or 0)` 用)。
1198
+ *
1199
+ * 为什么要单独写:`Number()` 会把 `"abc"` 静默变成 `NaN` 落库,而 Python 的
1200
+ * `int("abc")` 抛 **ValueError** —— 在 HTTP 路径上那等于「这个请求无响应、连接被掐断」。
1201
+ * 这里同形抛错(TypeError 由 `dispatchSafely` 收口成断连),绝不静默写坏数据。
1202
+ * 接受面照 CPython:number 截断、字符串允许两端空白/正负号/下划线分隔(`"1_0"` = 10)。
1203
+ * @param {unknown} value
1204
+ * @returns {number}
1205
+ */
1206
+ function pyIntOf(value) {
1207
+ if (typeof value === 'number') {
1208
+ if (!Number.isFinite(value)) throw new TypeError(`int() 不接受 ${value}`);
1209
+ return Math.trunc(value);
1210
+ }
1211
+ const text = pyStrip(value);
1212
+ if (!/^[+-]?\d(?:_?\d)*$/.test(text)) {
1213
+ throw new TypeError(`invalid literal for int(): ${JSON.stringify(text)}`);
1214
+ }
1215
+ return Number(text.replaceAll('_', ''));
1216
+ }
1217
+
1218
+ /** Python 真值(`""`/`0`/`None`/`False`/空容器为假);JS 的 `[]`/`{}` 是 truthy,必须显式收口 */
1219
+ function pyTruthyOf(value) {
1220
+ if (Array.isArray(value)) return value.length > 0;
1221
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
1222
+ return Boolean(value);
1223
+ }
1224
+
1225
+ /** `tasks` 的 INSERT 列序(**逐字照抄** `db.py:insert_task` 的列清单与顺序) */
1226
+ const TASK_INSERT_COLUMNS = [
1227
+ 'project_id', 'name', 'auto_fix', 'retest', 'stop_type', 'stop_value',
1228
+ 'task_type', 'payload', 'extra', 'model', 'permission', 'auto_commit', 'auto_deploy',
1229
+ 'auto_retest', 'cases_base', 'start_stage', 'end_stage', 'date_from', 'date_to',
1230
+ 'created_at',
1231
+ ];
1232
+
1233
+ /**
1234
+ * 插入任务行,返回新 id(对齐 `db.py:insert_task`(1012))。
1235
+ *
1236
+ * 形态说明:Python 是 19 个位置参数(后 13 个带缺省);Node 侧按**字段对象**收
1237
+ * (与 `updateTask(taskId, fields)` 同风格 —— 19 个位置参数在调用点无法阅读)。
1238
+ * 列序由 `TASK_INSERT_COLUMNS` 固定,与 `db.py` 的 INSERT 逐字同序,故调用方键序无关。
1239
+ *
1240
+ * 两处 Python 的隐式转换照做(**别顺手换成 JS 语义**):
1241
+ * - `1 if auto_fix else 0`(`auto_fix`/`auto_commit`/`auto_deploy`/`auto_retest`):
1242
+ * 真值判定按 Python 口径(`[]`/`{}` 为假,JS 原生真值判定会给 1);
1243
+ * - `int(cases_base or 0)`:假值 → 0;真值走 `pyIntOf`(非法值抛错,见该函数)。
1244
+ * 缺省值只在**键缺省**时生效(`=== undefined`):显式传 `null` 与 Python 一样落 NULL、
1245
+ * 由 NOT NULL 约束拦下 —— 不静默折成空串。
1246
+ *
1247
+ * `created_at` 一律用 `nowStr()`(= `db.now_str()`),不接受调用方传入。
1248
+ *
1249
+ * @param {number} projectId
1250
+ * @param {{name: string, autoFix?: any, retest?: any, stopType?: any, stopValue?: any,
1251
+ * taskType?: string, payload?: string, extra?: any, model?: any, permission?: any,
1252
+ * autoCommit?: any, autoDeploy?: any, autoRetest?: any, casesBase?: any,
1253
+ * startStage?: any, endStage?: any, dateFrom?: any, dateTo?: any}} fields
1254
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1255
+ * @returns {number} 新任务 id(`lastrowid`)
1256
+ */
1257
+ export function insertTask(projectId, fields = {}, options = {}) {
1258
+ // Python 的「参数缺省」语义:只有**没给**才用缺省(`null` 是显式值,落 NULL 由约束拦)
1259
+ const orDefault = (value, dflt) => (value === undefined ? dflt : value);
1260
+ const values = [
1261
+ projectId,
1262
+ bindable(fields.name),
1263
+ pyTruthyOf(fields.autoFix) ? 1 : 0,
1264
+ bindable(fields.retest), // Python 无缺省:调用方必须给(retest 列 NOT NULL)
1265
+ bindable(fields.stopType),
1266
+ bindable(fields.stopValue),
1267
+ orDefault(fields.taskType, 'normal'),
1268
+ orDefault(fields.payload, ''),
1269
+ orDefault(fields.extra, ''),
1270
+ orDefault(fields.model, ''),
1271
+ orDefault(fields.permission, ''),
1272
+ pyTruthyOf(fields.autoCommit) ? 1 : 0,
1273
+ pyTruthyOf(fields.autoDeploy) ? 1 : 0,
1274
+ pyTruthyOf(fields.autoRetest) ? 1 : 0,
1275
+ pyTruthyOf(fields.casesBase) ? pyIntOf(fields.casesBase) : 0, // int(cases_base or 0)
1276
+ fields.startStage || '', // 后四列 Python 是 `x or ""`(假值→空串)
1277
+ fields.endStage || '',
1278
+ fields.dateFrom || '',
1279
+ fields.dateTo || '',
1280
+ nowStr(),
1281
+ ];
1282
+ const sql = `INSERT INTO tasks(${TASK_INSERT_COLUMNS.join(',')})`
1283
+ + ` VALUES(${TASK_INSERT_COLUMNS.map(() => '?').join(',')})`;
1284
+ return withWriteDb(options, (handle) => Number(handle.prepare(sql).run(...values).lastInsertRowid));
1285
+ }
1286
+
1287
+ /**
1288
+ * 任务可更新字段白名单(`db.py:update_task` 的 `allowed` 集合)。
1289
+ *
1290
+ * **不改这份清单**:它是注入防线(只有清单里的列能进 SQL),Python 侧同款;
1291
+ * 增删字段必须两侧一起改。
1292
+ */
1293
+ export const TASK_UPDATE_FIELDS = Object.freeze(new Set([
1294
+ 'name', 'auto_fix', 'retest', 'stop_type', 'stop_value', 'status',
1295
+ 'session_id', 'current_round', 'new_bugs', 'started_at', 'ended_at',
1296
+ 'error', 'task_type', 'payload', 'extra', 'model', 'permission',
1297
+ 'auto_commit', 'auto_deploy', 'auto_retest', 'cases_base',
1298
+ 'fresh_prompt', 'start_stage', 'end_stage', 'date_from', 'date_to',
1299
+ 'load_rerun',
1300
+ ]));
1301
+
1302
+ /**
1303
+ * 按字段名更新任务(白名单字段防注入,对齐 `db.update_task(task_id, **fields)`)。
1304
+ *
1305
+ * 三个逐点保留的细节:
1306
+ * - **白名单外的字段被静默丢弃**(Python 同:`cols` 空则整条 no-op,不报错);
1307
+ * - 列序 = 调用方给的 `fields` **键序**(Python dict 保序,SQL 文本因此可比);
1308
+ * - `null` 原样写(`ended_at=None` 是「清空」的正式形态,不许折成空串/0)。
1309
+ *
1310
+ * @param {number} taskId
1311
+ * @param {Record<string, any>} fields
1312
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1313
+ */
1314
+ export function updateTask(taskId, fields, options = {}) {
1315
+ const cols = Object.keys(fields || {}).filter((k) => TASK_UPDATE_FIELDS.has(k));
1316
+ if (!cols.length) return;
1317
+ const sql = `UPDATE tasks SET ${cols.map((c) => `${c}=?`).join(',')} WHERE id=?`;
1318
+ withWriteDb(options, (handle) => {
1319
+ handle.prepare(sql).run(...cols.map((c) => bindable(fields[c])), bindable(taskId));
1320
+ });
1321
+ }
1322
+
1323
+ /**
1324
+ * 开一轮:落一行 `running` 轮次(对齐 `db.insert_round`)。
1325
+ * `kind='load'` 时 `runKey` 是该次发压运行的唯一标识(开始时刻 `YYYYmmdd_HHMMSS`)。
1326
+ * @returns {number} 新轮次行 id
1327
+ */
1328
+ export function insertRound(taskId, roundNo, logPath, kind = 'agent', runKey = '', options = {}) {
1329
+ return withWriteDb(options, (handle) => Number(handle.prepare(
1330
+ 'INSERT INTO rounds(task_id, round_no, status, log_path, started_at, kind, run_key)'
1331
+ + ' VALUES(?,?,?,?,?,?,?)',
1332
+ ).run(bindable(taskId), bindable(roundNo), 'running', bindable(logPath),
1333
+ nowStr(), bindable(kind), bindable(runKey)).lastInsertRowid));
1334
+ }
1335
+
1336
+ /**
1337
+ * 结束一轮:状态/退出码/摘要;`logPath` 非空时先补写日志路径(进程离线场景)。
1338
+ * 两次 UPDATE 在**同一事务**内(Python 的 `with connect()` 同款)。
1339
+ */
1340
+ export function finishRound(roundId, status, exitCode, summary, logPath = null, options = {}) {
1341
+ withWriteDb(options, (handle) => {
1342
+ if (logPath) {
1343
+ handle.prepare('UPDATE rounds SET log_path=? WHERE id=?')
1344
+ .run(bindable(logPath), bindable(roundId));
1345
+ }
1346
+ handle.prepare('UPDATE rounds SET status=?, exit_code=?, summary=?, ended_at=? WHERE id=?')
1347
+ .run(bindable(status), bindable(exitCode), bindable(summary), nowStr(), bindable(roundId));
1348
+ });
1349
+ }
1350
+
1351
+ /** 清空任务的轮次记录(restart 调用,避免重启后 round_no 重复;对齐 `db.delete_rounds`)。 */
1352
+ export function deleteRounds(taskId, options = {}) {
1353
+ withWriteDb(options, (handle) => {
1354
+ handle.prepare('DELETE FROM rounds WHERE task_id=?').run(bindable(taskId));
1355
+ });
1356
+ }
1357
+
1358
+ /** 删除任务及其轮次记录(磁盘日志不清理;对齐 `db.delete_task`)。 */
1359
+ export function deleteTask(taskId, options = {}) {
1360
+ withWriteDb(options, (handle) => {
1361
+ handle.prepare('DELETE FROM rounds WHERE task_id=?').run(bindable(taskId));
1362
+ handle.prepare('DELETE FROM tasks WHERE id=?').run(bindable(taskId));
1363
+ });
1364
+ }
1365
+
1366
+ // --------------------------------------------------------------------------
1367
+ // 看板写口(M5-1:卡片 / 评论 / 设置;**SQL 与白名单照 db.py:1140-1394 逐字**)
1368
+ // --------------------------------------------------------------------------
1369
+ //
1370
+ // 形态与口径(与 `db.py` 同名函数逐点等价——差异即对拍红灯):
1371
+ // - 每次调用一个写事务(`withWriteDb`,缺省 `BEGIN`…`COMMIT`);给了 `{db}` 句柄就复用它
1372
+ // ——此时**不代为开事务**(M4.5 登记偏差:Python 的多语句写口包在一个事务里,而
1373
+ // `{db}` 在库内的惯用法是「加入调用方事务」,见 `withWriteDb` 的文档与 M4.5 记录 §3.1)。
1374
+ // `{db, tx:true}` 独立选项是 M5 的登记重构项,本批**未**改(本段只追加,不动既有语义)。
1375
+ // - 「变更信号」= `localbus.publish(boardTopic(projectId))`,**只发信号不带载荷**
1376
+ // (前端收到后重取 payload)。发信号的位置与 Python **同序**:都在写事务的 `fn` 内、
1377
+ // COMMIT 之前(Python 的 `with connect() as conn:` 是「提交在退出块时」,块内即未提交)。
1378
+ // - 返回**值**即契约:`insert_*` 回 `lastrowid`,`mark_card_viewed` 回布尔,
1379
+ // 其余 Python 隐式回 `None` ⇒ Node 返回 `undefined`(调用方不得依赖返回值)。
1380
+ //
1381
+ // 谁发信号 / 谁不发(逐条对齐 db.py,**不是遗漏**):
1382
+ // - 发:`insertBoardCard`(用**传参**的 project_id)、`updateBoardCard`(先读行,卡不在则
1383
+ // 整条 no-op 故不发)、`markCardViewed`(**只有真清了标记才发**)、`trashBoardCard`、
1384
+ // `restoreBoardCard`、`purgeBoardCard`(用**删前**读到的 project_id,存在才发);
1385
+ // - 不发:`insertBoardComment`、`updateBoardComment`、`deleteBoardComment`、
1386
+ // `setBoardSettings`,以及 `purgeTrashedCards` —— **清空回收站在 Python 侧空/非空都不发**
1387
+ // (`db.py:1326-1337` 全函数无 `localbus` 调用,已用真 Python 探针实测确认)。
1388
+ // 前端「清空回收站」按响应自行重取,Node 侧照抄,**不许顺手补一个信号**。
1389
+
1390
+ /**
1391
+ * `update_board_card` 的可更新字段白名单(防注入;照抄 `db.py:BOARD_CARD_FIELDS`)。
1392
+ *
1393
+ * **不改这份清单**:只有清单里的列能进 SQL。注意 `unread` **不在**其中:它只由
1394
+ * `updateBoardCard` 按「列真变化」派生、由 `markCardViewed` 清除,调用方无法经 `fields` 误置。
1395
+ */
1396
+ export const BOARD_CARD_FIELDS = Object.freeze(new Set([
1397
+ 'title', 'description', 'column_key', 'sort_order', 'session_id', 'sessions',
1398
+ 'block_kind', 'block_text', 'parent_card_id', 'readonly', 'model',
1399
+ 'scheduled_at', 'worktree', 'jira_key', 'last_error', 'last_error_at',
1400
+ 'done_at', 'origin',
1401
+ ]));
1402
+
1403
+ /** `update_board_comment` 的可更新字段白名单(照抄 `db.py:update_board_comment` 的局部 `allowed`)。 */
1404
+ export const BOARD_COMMENT_UPDATE_FIELDS = Object.freeze(new Set([
1405
+ 'sent', 'session_id', 'sent_text',
1406
+ ]));
1407
+
1408
+ /**
1409
+ * 卡片写路径的进程内变更信号(对齐 `db.py:_notify_card_change`)。
1410
+ *
1411
+ * 同连接按主键读 `project_id`(带索引,微秒级),命中才发对应看板 topic 的信号。
1412
+ * **异常一律吞掉**(Python 是空 `except`):通知失败绝不影响业务写。
1413
+ * 调用位置在写事务的 `fn` 内(COMMIT 之前),与 Python 完全同序。
1414
+ * @param {import('node:sqlite').DatabaseSync} handle
1415
+ * @param {number} cardId
1416
+ */
1417
+ function notifyCardChange(handle, cardId) {
1418
+ try {
1419
+ const row = handle.prepare('SELECT project_id FROM board_cards WHERE id=?')
1420
+ .get(bindable(cardId));
1421
+ if (row) publish(boardTopic(row.project_id));
1422
+ } catch {
1423
+ // 通知失败绝不影响业务写(对应 Python 的空 except)
1424
+ }
1425
+ }
1426
+
1427
+ /**
1428
+ * 新建看板卡片(默认落 `todo` 列尾),返回卡片 id(对齐 `db.insert_board_card`)。
1429
+ *
1430
+ * 列尾口径照抄 Python:`MAX(sort_order)+1 WHERE project_id=? AND column_key='todo'`
1431
+ * ——**没有 `trashed=0` 过滤**,回收站里的 todo 卡也占号(实测:手工插一张
1432
+ * `trashed=1, sort_order=50` 的 todo 卡后,新卡取 51)。这不是疏漏,是 oracle 的既有行为,
1433
+ * 顺手加过滤会让两侧 `sort_order` 在「删卡后新建」这条路径上分叉。
1434
+ *
1435
+ * `created_at` 与 `updated_at` 是**两次** `now_str()`(Python 同),故理论上可能跨秒不同。
1436
+ * 插入后在**同一写事务内**发一次变更信号(用的是**传参**的 `projectId`,不重读行)。
1437
+ *
1438
+ * @param {number} projectId
1439
+ * @param {string} title
1440
+ * @param {string} [description]
1441
+ * @param {string} [jiraKey]
1442
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1443
+ * @returns {number} 新卡片 id(`lastrowid`)
1444
+ */
1445
+ export function insertBoardCard(projectId, title, description = '', jiraKey = '', options = {}) {
1446
+ return withWriteDb(options, (handle) => {
1447
+ const row = handle.prepare(
1448
+ 'SELECT COALESCE(MAX(sort_order),0)+1 FROM board_cards'
1449
+ + " WHERE project_id=? AND column_key='todo'").get(bindable(projectId));
1450
+ const sortOrder = Object.values(row)[0]; // 单列未命名:按位置取(同 Python 的 row[0])
1451
+ const info = handle.prepare(
1452
+ 'INSERT INTO board_cards(project_id, title, description, column_key,'
1453
+ + ' sort_order, jira_key, created_at, updated_at)'
1454
+ + " VALUES(?,?,?,'todo',?,?,?,?)",
1455
+ ).run(bindable(projectId), bindable(title), bindable(description), bindable(sortOrder),
1456
+ bindable(jiraKey), nowStr(), nowStr());
1457
+ publish(boardTopic(projectId)); // P4 看板事件化:新建卡片也发一次信号
1458
+ return Number(info.lastInsertRowid);
1459
+ });
1460
+ }
1461
+
1462
+ /**
1463
+ * 按白名单字段更新卡片,自动刷新 `updated_at`(对齐 `db.update_board_card`)。
1464
+ *
1465
+ * 四条语义**逐字照抄**(顺序也是契约的一部分):
1466
+ * ① 白名单过滤:`BOARD_CARD_FIELDS` 之外的一律丢弃;**过滤后为空直接返回**(连
1467
+ * `updated_at` 都不写、信号也不发);
1468
+ * ② `done_at` 派生:`column_key` 在 fields 且**未显式给** `done_at` ⇒ 落 `done` 盖当前时间、
1469
+ * 其它列清 `NULL`(集中在此维护,覆盖所有「置 done」的写路径);
1470
+ * ③ `unread` 派生:`markUnread` 为真且本次带 `column_key` ⇒ **同连接读现值**比对,
1471
+ * 列**真变化**才置 `unread=1`(同列写回不置位);读不到行 ⇒ **整体 no-op 返回**
1472
+ * (卡片已删:不动任何列、不刷 `updated_at`、不发信号)。`markUnread=false` 用于
1473
+ * 「用户本人在看板上操作引起」的列迁移(拖列/开始/停止/作答送达),不给自己提醒;
1474
+ * ④ SQL 在派生**之后**拼(列集合此时才定),恒带 `, updated_at=?`。
1475
+ *
1476
+ * 列序 = `fields` 的**键序**,其后依次追加派生的 `done_at`、`unread`——与 Python dict 的
1477
+ * 插入序一致(显式给了 `done_at` 就保持它原来的位置,不追加)。Python 的 `**fields` 是
1478
+ * 一份**新** dict,本实现同样不修改调用方传入的 `fields`(只读它的键值);除 `updated_at`
1479
+ * 外不写任何隐式列;`null` 原样写(`done_at: null` = 清空)。
1480
+ *
1481
+ * @param {number} cardId
1482
+ * @param {any} [markUnread] 真值判定按 Python 口径(见 `pyTruthyOf`)
1483
+ * @param {Record<string, any>} [fields]
1484
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1485
+ */
1486
+ export function updateBoardCard(cardId, markUnread = true, fields = {}, options = {}) {
1487
+ const given = fields || {};
1488
+ const cols = Object.keys(given).filter((k) => BOARD_CARD_FIELDS.has(k));
1489
+ if (!cols.length) return; // ① 全被白名单挡掉:整条 no-op
1490
+ const values = cols.map((c) => given[c]); // 与 cols 同步追加,保证「列序 = 值序」
1491
+ if (Object.hasOwn(given, 'column_key') && !Object.hasOwn(given, 'done_at')) {
1492
+ // ② done_at 派生。时刻口径与 Python 同:Python 也是在 `with connect()` **之前**算好这个值,
1493
+ // 故它与事务内取的 `updated_at` 是两次 now_str()(理论上可跨秒,实测同秒)。
1494
+ cols.push('done_at');
1495
+ values.push(given.column_key === 'done' ? nowStr() : null);
1496
+ }
1497
+ withWriteDb(options, (handle) => {
1498
+ if (pyTruthyOf(markUnread) && Object.hasOwn(given, 'column_key')) {
1499
+ const row = handle.prepare('SELECT column_key FROM board_cards WHERE id=?')
1500
+ .get(bindable(cardId));
1501
+ if (row === undefined) return; // ③ 卡片已删:不动任何列
1502
+ if (row.column_key !== given.column_key) {
1503
+ cols.push('unread');
1504
+ values.push(1);
1505
+ }
1506
+ }
1507
+ // ④ SQL 在派生之后拼(列集合此时才是最终的);恒带 updated_at
1508
+ const sql = 'UPDATE board_cards SET '
1509
+ + cols.map((c) => `${c}=?`).join(',') + ', updated_at=? WHERE id=?';
1510
+ handle.prepare(sql).run(...values.map(bindable), nowStr(), bindable(cardId));
1511
+ notifyCardChange(handle, cardId);
1512
+ });
1513
+ }
1514
+
1515
+ /**
1516
+ * 清卡片「状态有更新·用户未打开」标记(对齐 `db.mark_card_viewed`)。
1517
+ *
1518
+ * 只写 `unread=0`,**刻意不刷 `updated_at`**:查看不是卡片内容变更,顺带刷时间会污染
1519
+ * 「更新新→旧」排序口径与 `list_queued_board_cards` 的排队顺序(该查询按 `updated_at` 排)。
1520
+ * 返回本次是否真清了标记(无卡 / 本来就未读 ⇒ false);**只有真清过才发看板变更信号**
1521
+ * (省掉无意义的前端重取)。
1522
+ *
1523
+ * @param {number} cardId
1524
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1525
+ * @returns {boolean}
1526
+ */
1527
+ export function markCardViewed(cardId, options = {}) {
1528
+ return withWriteDb(options, (handle) => {
1529
+ const info = handle.prepare('UPDATE board_cards SET unread=0 WHERE id=? AND unread=1')
1530
+ .run(bindable(cardId));
1531
+ const changed = Number(info.changes) > 0; // Python: cur.rowcount > 0
1532
+ if (changed) notifyCardChange(handle, cardId);
1533
+ return changed;
1534
+ });
1535
+ }
1536
+
1537
+ /**
1538
+ * 卡片移入回收站(软删):`trashed=1` + `trashed_at` + `updated_at`,发信号(对齐
1539
+ * `db.trash_board_card`)。此时卡片从看板列表消失(读板路径统一 `trashed=0` 过滤),
1540
+ * 行与评论保留待还原。行不存在时 UPDATE 是 0 行,且 `notifyCardChange` 读不到行 ⇒
1541
+ * **不发信号**(与 Python `_notify_card_change` 同)。
1542
+ * @param {number} cardId
1543
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1544
+ */
1545
+ export function trashBoardCard(cardId, options = {}) {
1546
+ withWriteDb(options, (handle) => {
1547
+ handle.prepare('UPDATE board_cards SET trashed=1, trashed_at=?, updated_at=? WHERE id=?')
1548
+ .run(nowStr(), nowStr(), bindable(cardId));
1549
+ notifyCardChange(handle, cardId);
1550
+ });
1551
+ }
1552
+
1553
+ /**
1554
+ * 回收站还原:清 `trashed` 标记(回原列原位置,`sort_order` 保留)+ 清 `trashed_at`,
1555
+ * 发信号(对齐 `db.restore_board_card`)。
1556
+ * @param {number} cardId
1557
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1558
+ */
1559
+ export function restoreBoardCard(cardId, options = {}) {
1560
+ withWriteDb(options, (handle) => {
1561
+ handle.prepare('UPDATE board_cards SET trashed=0, trashed_at=NULL, updated_at=? WHERE id=?')
1562
+ .run(nowStr(), bindable(cardId));
1563
+ notifyCardChange(handle, cardId);
1564
+ });
1565
+ }
1566
+
1567
+ /**
1568
+ * 彻底删除卡片并连带删其评论(对齐 `db.purge_board_card`)。
1569
+ *
1570
+ * 信号用的是**删前**读到的 `project_id`(删完就查不到行了,故不能走 `notifyCardChange`);
1571
+ * 行本来就不存在 ⇒ 不发信号(Python `if pid_row:`)。三条语句在同一连接内按序执行
1572
+ * (Python 的隐式事务在第一条 DML 才 BEGIN,故那次 SELECT 实际在事务外——结果等价)。
1573
+ * @param {number} cardId
1574
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1575
+ */
1576
+ export function purgeBoardCard(cardId, options = {}) {
1577
+ withWriteDb(options, (handle) => {
1578
+ const pidRow = handle.prepare('SELECT project_id FROM board_cards WHERE id=?')
1579
+ .get(bindable(cardId));
1580
+ handle.prepare('DELETE FROM board_comments WHERE card_id=?').run(bindable(cardId));
1581
+ handle.prepare('DELETE FROM board_cards WHERE id=?').run(bindable(cardId));
1582
+ if (pidRow) publish(boardTopic(pidRow.project_id));
1583
+ });
1584
+ }
1585
+
1586
+ /**
1587
+ * 清空项目回收站:真删全部已删卡及其评论(对齐 `db.purge_trashed_cards`)。
1588
+ *
1589
+ * **空回收站直接返回**(连 DELETE 都不发)。**非空也不发看板信号**——Python 原文全函数
1590
+ * 没有 `localbus` 调用(已实测确认),端点靠响应让前端重取;照抄,不许顺手补信号。
1591
+ * 那次 SELECT 与两条 `IN (?,…)` 删除在同一连接内按序执行(同 `purgeBoardCard` 的事务口径)。
1592
+ * @param {number} projectId
1593
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1594
+ */
1595
+ export function purgeTrashedCards(projectId, options = {}) {
1596
+ withWriteDb(options, (handle) => {
1597
+ const rows = handle.prepare(
1598
+ 'SELECT id FROM board_cards WHERE project_id=? AND trashed=1').all(bindable(projectId));
1599
+ const ids = rows.map((r) => r.id);
1600
+ if (!ids.length) return;
1601
+ const marks = ids.map(() => '?').join(','); // Python: ",".join("?" * len(ids))
1602
+ handle.prepare(`DELETE FROM board_comments WHERE card_id IN (${marks})`)
1603
+ .run(...ids.map(bindable));
1604
+ handle.prepare(`DELETE FROM board_cards WHERE id IN (${marks})`).run(...ids.map(bindable));
1605
+ });
1606
+ }
1607
+
1608
+ /**
1609
+ * 给卡片加评论,返回评论 id(对齐 `db.insert_board_comment`)。**不发看板信号**
1610
+ * (评论不改变卡面列/排序,Python 侧同样不发)。
1611
+ * @param {number} cardId
1612
+ * @param {string} text
1613
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1614
+ * @returns {number}
1615
+ */
1616
+ export function insertBoardComment(cardId, text, options = {}) {
1617
+ return withWriteDb(options, (handle) => Number(handle.prepare(
1618
+ 'INSERT INTO board_comments(card_id, text, created_at) VALUES(?,?,?)',
1619
+ ).run(bindable(cardId), bindable(text), nowStr()).lastInsertRowid));
1620
+ }
1621
+
1622
+ /**
1623
+ * 按白名单更新评论(投递状态 / 投递文本 / 投递会话;对齐 `db.update_board_comment`)。
1624
+ * 白名单外的键静默丢弃,过滤后为空即 no-op(不报错、不发信号)。
1625
+ * @param {number} commentId
1626
+ * @param {Record<string, any>} [fields]
1627
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1628
+ */
1629
+ export function updateBoardComment(commentId, fields = {}, options = {}) {
1630
+ const given = fields || {};
1631
+ const cols = Object.keys(given).filter((k) => BOARD_COMMENT_UPDATE_FIELDS.has(k));
1632
+ if (!cols.length) return;
1633
+ const sql = 'UPDATE board_comments SET ' + cols.map((c) => `${c}=?`).join(',') + ' WHERE id=?';
1634
+ withWriteDb(options, (handle) => {
1635
+ handle.prepare(sql).run(...cols.map((c) => bindable(given[c])), bindable(commentId));
1636
+ });
1637
+ }
1638
+
1639
+ /**
1640
+ * 删除单条评论(对齐 `db.delete_board_comment`)。行不存在同样静默(0 行)。
1641
+ * @param {number} commentId
1642
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1643
+ */
1644
+ export function deleteBoardComment(commentId, options = {}) {
1645
+ withWriteDb(options, (handle) => {
1646
+ handle.prepare('DELETE FROM board_comments WHERE id=?').run(bindable(commentId));
1647
+ });
1648
+ }
1649
+
1650
+ /**
1651
+ * 覆盖写看板设置(对齐 `db.set_board_settings`)。
1652
+ *
1653
+ * 序列化必须走 `pyJsonDumps`(= CPython `json.dumps(..., ensure_ascii=False)` 的默认形态):
1654
+ * 与 Python **逐字节一致**,中文不转义、默认分隔符 `", "` / `": "`。
1655
+ * 例:`{a:"中文",b:[1,2]}` ⇒ `{"a": "中文", "b": [1, 2]}`。**不发看板信号**(Python 同)。
1656
+ * @param {number} projectId
1657
+ * @param {any} settings
1658
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1659
+ */
1660
+ export function setBoardSettings(projectId, settings, options = {}) {
1661
+ withWriteDb(options, (handle) => {
1662
+ handle.prepare('INSERT OR REPLACE INTO board_settings(project_id, json) VALUES(?,?)')
1663
+ .run(bindable(projectId), pyJsonDumps(settings));
1664
+ });
1665
+ }
1666
+
1667
+ // --------------------------------------------------------------------------
1668
+ // 管理面写口(M6):用户 / 项目 / 设置(**SQL 与列序照 db.py 逐字**)
1669
+ // --------------------------------------------------------------------------
1670
+ //
1671
+ // 形态与口径(与 `db.py` 同名函数逐点等价——差异即对拍红灯):
1672
+ // - **位置参数顺序一字不改**(`insert_user(username, pass_hash, salt="")` ⇒
1673
+ // `insertUser(username, passHash, salt = '')`):Python 的缺省 = JS 的**末位形参缺省**
1674
+ // ——只有 `undefined`(=「没给」)才吃缺省,显式 `null` 与 Python 一样落 NULL、由
1675
+ // NOT NULL 约束拦下(同 `insertTask` 的 `orDefault` 口径)。末位多一个
1676
+ // `options = {dbPath, db, env}`,与文件内既有读/写口同形(`withWriteDb` 认句柄直传)。
1677
+ // - **约束错原样抛,绝不吞**:Python `sqlite3.IntegrityError`(唯一索引 2067 /
1678
+ // NOT NULL 1299 …整个约束族)在 Node 是
1679
+ // `err.code === 'ERR_SQLITE_ERROR' && (err.errcode & 0xff) === 19`(`SQLITE_CONSTRAINT`)。
1680
+ // 判据的既有实现是 `waitq.js` 的 `isIntegrityError`(该函数未导出);本文件**不新增
1681
+ // 重复实现**——写口只负责把错原样抛给调用方(路由层翻 400),需要判据的调用方自行
1682
+ // `err.errcode` 判定或从 `waitq.js` 引入(本批不许改 waitq.js)。
1683
+ // - JSON 列一律 `pyJsonDumps`(**不是** `JSON.stringify`):中文不转义、分隔符
1684
+ // `", "`/`": "`,与 CPython `json.dumps(..., ensure_ascii=False)` 逐字节一致。
1685
+ // - 返回值即契约:`insert_*` 回 `lastrowid`,其余 Python 隐式回 `None` ⇒ 返回 `undefined`。
1686
+
1687
+ /**
1688
+ * 插入新用户,返回新 id(对齐 `db.insert_user`(db.py:823))。
1689
+ *
1690
+ * 用户名唯一冲突(`sqlite3.IntegrityError`)**原样抛**,由调用方翻 400。
1691
+ * @param {string} username
1692
+ * @param {string} passHash
1693
+ * @param {string} [salt] 缺省空串(Python `salt=""`)
1694
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1695
+ * @returns {number} 新用户 id(`lastrowid`)
1696
+ */
1697
+ export function insertUser(username, passHash, salt = '', options = {}) {
1698
+ return withWriteDb(options, (handle) => Number(handle.prepare(
1699
+ 'INSERT INTO users(username, pass_hash, salt, created_at) VALUES(?,?,?,?)',
1700
+ ).run(bindable(username), bindable(passHash), bindable(salt), nowStr()).lastInsertRowid));
1701
+ }
1702
+
1703
+ /**
1704
+ * 更新用户名与密码哈希(后台管理重置;对齐 `db.update_user`(db.py:831))。
1705
+ * 行不存在静默(0 行);用户名撞唯一索引同样**原样抛**。
1706
+ * @param {number} userId
1707
+ * @param {string} username
1708
+ * @param {string} passHash
1709
+ * @param {string} salt
1710
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1711
+ */
1712
+ export function updateUser(userId, username, passHash, salt, options = {}) {
1713
+ withWriteDb(options, (handle) => {
1714
+ handle.prepare('UPDATE users SET username=?, pass_hash=?, salt=? WHERE id=?')
1715
+ .run(bindable(username), bindable(passHash), bindable(salt), bindable(userId));
1716
+ });
1717
+ }
1718
+
1719
+ /**
1720
+ * 删除用户及其会话(对齐 `db.delete_user`(db.py:838))。两条 DELETE 在**同一事务**内
1721
+ * (Python 的 `with connect()` 同款);行不存在静默(各 0 行)。
1722
+ * @param {number} userId
1723
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1724
+ */
1725
+ export function deleteUser(userId, options = {}) {
1726
+ withWriteDb(options, (handle) => {
1727
+ handle.prepare('DELETE FROM sessions WHERE user_id=?').run(bindable(userId));
1728
+ handle.prepare('DELETE FROM users WHERE id=?').run(bindable(userId));
1729
+ });
1730
+ }
1731
+
1732
+ /**
1733
+ * 更新密码哈希并清除「强制改密」标记(改密成功后不再拦截其他端点;对齐
1734
+ * `db.change_password`(db.py:845))。`must_change_pw` 写常量 0(不是绑定参数)。
1735
+ * @param {number} userId
1736
+ * @param {string} passHash
1737
+ * @param {string} salt
1738
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1739
+ */
1740
+ export function changePassword(userId, passHash, salt, options = {}) {
1741
+ withWriteDb(options, (handle) => {
1742
+ handle.prepare('UPDATE users SET pass_hash=?, salt=?, must_change_pw=0 WHERE id=?')
1743
+ .run(bindable(passHash), bindable(salt), bindable(userId));
1744
+ });
1745
+ }
1746
+
1747
+ /**
1748
+ * 插入项目(归属 `userId`),返回新 id(对齐 `db.insert_project`(db.py:950))。
1749
+ *
1750
+ * 两处**必须照做**的细节:
1751
+ * - `bug_dir` 列已废弃(bug 报告目录由 `work_dir` 派生),**恒写空串**——旧库该列
1752
+ * NOT NULL 且无默认值,不能省略不写;
1753
+ * - `(user_id, name)` 上有唯一索引 `idx_projects_user_name`,同用户重名**原样抛**
1754
+ * `sqlite3.IntegrityError`(不同用户同名不冲突)。
1755
+ *
1756
+ * 位置参数顺序照 Python(`user_id` 在前,而 INSERT 列序里 `name` 在前——两者不同,别顺手对齐)。
1757
+ * @param {number} userId
1758
+ * @param {string} name
1759
+ * @param {string} projectDir
1760
+ * @param {string} agentPath
1761
+ * @param {string} workDir
1762
+ * @param {string} [guideText]
1763
+ * @param {string} [commitSpec]
1764
+ * @param {string} [deploySpec]
1765
+ * @param {string} [envLabel]
1766
+ * @param {string} [model]
1767
+ * @param {string} [skillUnderstand]
1768
+ * @param {string} [skillDeploy]
1769
+ * @param {string} [skillCommit]
1770
+ * @param {string} [skillTest]
1771
+ * @param {string} [skillCases]
1772
+ * @param {string} [reasoningEffort]
1773
+ * @param {string} [permissionMode]
1774
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1775
+ * @returns {number} 新项目 id(`lastrowid`)
1776
+ */
1777
+ export function insertProject(userId, name, projectDir, agentPath, workDir,
1778
+ guideText = '', commitSpec = '', deploySpec = '', envLabel = '', model = '',
1779
+ skillUnderstand = '', skillDeploy = '', skillCommit = '', skillTest = '', skillCases = '',
1780
+ reasoningEffort = '', permissionMode = '', options = {}) {
1781
+ return withWriteDb(options, (handle) => Number(handle.prepare(
1782
+ 'INSERT INTO projects(name, user_id, project_dir, agent_path, work_dir, bug_dir,'
1783
+ + ' guide_text, commit_spec, deploy_spec, env_label, model,'
1784
+ + ' skill_understand, skill_deploy, skill_commit, skill_test, skill_cases,'
1785
+ + ' reasoning_effort, permission_mode, created_at)'
1786
+ + ' VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
1787
+ ).run(
1788
+ bindable(name), bindable(userId), bindable(projectDir), bindable(agentPath),
1789
+ bindable(workDir), '', // bug_dir 恒空串(列已废弃,见函数注释)
1790
+ bindable(guideText), bindable(commitSpec), bindable(deploySpec), bindable(envLabel),
1791
+ bindable(model), bindable(skillUnderstand), bindable(skillDeploy), bindable(skillCommit),
1792
+ bindable(skillTest), bindable(skillCases), bindable(reasoningEffort),
1793
+ bindable(permissionMode), nowStr(),
1794
+ ).lastInsertRowid));
1795
+ }
1796
+
1797
+ /**
1798
+ * 更新项目(对齐 `db.update_project`(db.py:975)):只写这 16 列。
1799
+ *
1800
+ * `bug_dir`(已废弃)与 `created_at` **不碰**——别顺手补进去。同用户重名**原样抛**约束错。
1801
+ * @param {number} projectId
1802
+ * @param {string} name
1803
+ * @param {string} projectDir
1804
+ * @param {string} agentPath
1805
+ * @param {string} workDir
1806
+ * @param {string} [guideText]
1807
+ * @param {string} [commitSpec]
1808
+ * @param {string} [deploySpec]
1809
+ * @param {string} [envLabel]
1810
+ * @param {string} [model]
1811
+ * @param {string} [skillUnderstand]
1812
+ * @param {string} [skillDeploy]
1813
+ * @param {string} [skillCommit]
1814
+ * @param {string} [skillTest]
1815
+ * @param {string} [skillCases]
1816
+ * @param {string} [reasoningEffort]
1817
+ * @param {string} [permissionMode]
1818
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1819
+ */
1820
+ export function updateProject(projectId, name, projectDir, agentPath, workDir,
1821
+ guideText = '', commitSpec = '', deploySpec = '', envLabel = '', model = '',
1822
+ skillUnderstand = '', skillDeploy = '', skillCommit = '', skillTest = '', skillCases = '',
1823
+ reasoningEffort = '', permissionMode = '', options = {}) {
1824
+ withWriteDb(options, (handle) => {
1825
+ handle.prepare(
1826
+ 'UPDATE projects SET name=?, project_dir=?, agent_path=?, work_dir=?,'
1827
+ + ' guide_text=?, commit_spec=?, deploy_spec=?, env_label=?, model=?,'
1828
+ + ' skill_understand=?, skill_deploy=?, skill_commit=?, skill_test=?,'
1829
+ + ' skill_cases=?, reasoning_effort=?, permission_mode=? WHERE id=?',
1830
+ ).run(
1831
+ bindable(name), bindable(projectDir), bindable(agentPath), bindable(workDir),
1832
+ bindable(guideText), bindable(commitSpec), bindable(deploySpec), bindable(envLabel),
1833
+ bindable(model), bindable(skillUnderstand), bindable(skillDeploy), bindable(skillCommit),
1834
+ bindable(skillTest), bindable(skillCases), bindable(reasoningEffort),
1835
+ bindable(permissionMode), bindable(projectId),
1836
+ );
1837
+ });
1838
+ }
1839
+
1840
+ /**
1841
+ * 删除项目及其任务/轮次(外键级联由应用层执行;对齐 `db.delete_project`(db.py:994))。
1842
+ * 三条 DELETE **同序**(先 rounds 后 tasks 再 projects)且在同一事务内。
1843
+ * @param {number} projectId
1844
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1845
+ */
1846
+ export function deleteProject(projectId, options = {}) {
1847
+ withWriteDb(options, (handle) => {
1848
+ handle.prepare('DELETE FROM rounds WHERE task_id IN (SELECT id FROM tasks'
1849
+ + ' WHERE project_id=?)').run(bindable(projectId));
1850
+ handle.prepare('DELETE FROM tasks WHERE project_id=?').run(bindable(projectId));
1851
+ handle.prepare('DELETE FROM projects WHERE id=?').run(bindable(projectId));
1852
+ });
1853
+ }
1854
+
1855
+ /**
1856
+ * 设置项目归档标记(True=归档 False=恢复);只改标记,不动任务与文件(对齐
1857
+ * `db.set_project_archived`(db.py:1003))。
1858
+ *
1859
+ * 真值判定照 Python(`1 if archived else 0`):`[]`/`{}` 在 Python 是假、JS 原生真值却是真,
1860
+ * 故走 `pyTruthyOf`;`archived` 列的**存储类型就是整数** 1/0。
1861
+ * @param {number} projectId
1862
+ * @param {any} archived
1863
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1864
+ */
1865
+ export function setProjectArchived(projectId, archived, options = {}) {
1866
+ withWriteDb(options, (handle) => {
1867
+ handle.prepare('UPDATE projects SET archived=? WHERE id=?')
1868
+ .run(pyTruthyOf(archived) ? 1 : 0, bindable(projectId));
1869
+ });
1870
+ }
1871
+
1872
+ /**
1873
+ * UPSERT 全局 KV 设置(dict 序列化为 json 列,整对象覆盖;对齐 `db.set_app_setting`(db.py:1414))。
1874
+ * 序列化走 `pyJsonDumps`(中文不转义、`", "`/`": "` 分隔符,与 Python 逐字节一致)。
1875
+ * @param {string} key
1876
+ * @param {any} obj
1877
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1878
+ */
1879
+ export function setAppSetting(key, obj, options = {}) {
1880
+ withWriteDb(options, (handle) => {
1881
+ handle.prepare(
1882
+ 'INSERT INTO app_settings(key, json) VALUES(?, ?)'
1883
+ + ' ON CONFLICT(key) DO UPDATE SET json=excluded.json',
1884
+ ).run(bindable(key), pyJsonDumps(obj));
1885
+ });
1886
+ }
1887
+
1888
+ /**
1889
+ * UPSERT 一条 UI 偏好(value 为可 JSON 序列化对象,整对象覆盖;对齐
1890
+ * `db.set_user_pref`(db.py:1475))。
1891
+ *
1892
+ * 冲突键是 `(user_id, project_id, key)` 三元组;覆盖时 `value` 与 `updated_at` 一起换新
1893
+ * (`excluded.value` / `excluded.updated_at`)。序列化同样走 `pyJsonDumps`。
1894
+ * @param {number} userId
1895
+ * @param {number} projectId
1896
+ * @param {string} key
1897
+ * @param {any} value
1898
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1899
+ */
1900
+ export function setUserPref(userId, projectId, key, value, options = {}) {
1901
+ withWriteDb(options, (handle) => {
1902
+ handle.prepare(
1903
+ 'INSERT INTO user_prefs(user_id, project_id, key, value, updated_at)'
1904
+ + ' VALUES(?, ?, ?, ?, ?)'
1905
+ + ' ON CONFLICT(user_id, project_id, key)'
1906
+ + ' DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at',
1907
+ ).run(bindable(userId), bindable(projectId), bindable(key), pyJsonDumps(value), nowStr());
1908
+ });
1909
+ }
1910
+
1911
+ // --------------------------------------------------------------------------
1912
+ // 飞书族数据访问(M7-R1:`db.py:1423-1623` 的 17 个函数)
1913
+ // --------------------------------------------------------------------------
1914
+ //
1915
+ // 形态与口径(与 `db.py` 同名函数逐点等价——差异即对拍红灯):
1916
+ // - 名字映射 = Python 名整串 snake_case → camelCase(`get_feishu_user_cfg` ⇒
1917
+ // `getFeishuUserCfg`),**基名不许多字少字**(R0 量具按该映射自动找 Node 函数)。
1918
+ // 末位多一个 `options = {db, dbPath, env}`,与文件内既有读/写口同形。
1919
+ // - **一次调用一个短连接**(对齐 Python 的 `with connect() as conn`);给了 `{db}` 就复用
1920
+ // 调用方句柄(事务边界归调用方,同 `withWriteDb` 的文档)。
1921
+ // - `setFeishuBinding` 的 DELETE + INSERT 在**同一个 `withWriteDb`** 内(= Python 那一条
1922
+ // `with connect()`,同一事务):中途失败不许留下「已解绑」残态。实测把 INSERT 打成必失败
1923
+ // (触发器 `RAISE(ABORT)`)后,旧行连同 `bound_at` 一起回滚 ⇒ **不许拆成两次写**。
1924
+ // - `feishuOutboxPush` 的 dedup SELECT 与 INSERT 在 Python 侧是**两个独立事务**
1925
+ // (`db.py:1510-1522` 两个 `with connect()` 块)⇒ 这里照抄:读走 `withReadDb`、写走
1926
+ // `withWriteDb`,中间的 **check-then-insert 窗口照留**(并发下同 dedup_key 可能插出两条
1927
+ // pending,属 oracle 既有行为,登记为未验项,不在这里「顺手修」)。唯一形态差异:调用方
1928
+ // 给 `{db}` 且自己开着事务时该窗口会塌掉 —— Python 每次调用都是新连接,不存在这种形态。
1929
+ // - JSON 列一律 `pyJsonDumps` / `pyJsonLoads`(中文不转义、分隔符 `", "` / `": "`),与
1930
+ // CPython `json.dumps(..., ensure_ascii=False)` 逐字节一致。
1931
+ // - `feishu_outbox` 的插入把 `next_at` **硬编码 0**、`created_at` 取 `nowStr()`
1932
+ // (= `db.now_str()`:本地时间 `YYYY-MM-DD HH:MM:SS`),与 `db.py` 的 SQL 字面量同形。
1933
+ // - 两张表**不加 CHECK 约束、不加索引**(oracle 的 DDL 就没有;`schema.sql` 是共同真源)。
1934
+ // - 写口返回 `undefined`(Python 隐式 `None` 的对应物),只有 `feishuOutboxPush` 回 `lastrowid`。
1935
+
1936
+ /**
1937
+ * Python 侧 sqlite3 会把 bool 隐式适配成 1/0(`True` → 1);`node:sqlite` 拒绑 boolean
1938
+ * (实测 `TypeError: Provided value cannot be bound to SQLite parameter N`)。
1939
+ * `setFeishuHook` 的 `enabled` 是唯一可能收到 bool 的形参,故在此做同形转换;其余值原样下传。
1940
+ * @param {unknown} value
1941
+ * @returns {unknown}
1942
+ */
1943
+ function pyBoolInt(value) {
1944
+ return typeof value === 'boolean' ? (value ? 1 : 0) : value;
1945
+ }
1946
+
1947
+ /**
1948
+ * 用户飞书配置 dict(`feishu_user_cfgs` 表 json 列):dict 或 `{}`(无记录降级)。
1949
+ * 对齐 `db.get_feishu_user_cfg`(db.py:1423)。坏 JSON 降级 `{}`(**只吞 ValueError 分支**,
1950
+ * 即 Node 的 SyntaxError;类型错照 Python 抛 TypeError)。
1951
+ * @param {number} userId
1952
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1953
+ * @returns {object}
1954
+ */
1955
+ export function getFeishuUserCfg(userId, options = {}) {
1956
+ const row = withReadDb(options, (handle) => plainRow(handle.prepare(
1957
+ 'SELECT json FROM feishu_user_cfgs WHERE user_id=?').get(bindable(userId))));
1958
+ if (row === null) return {};
1959
+ try {
1960
+ return pyJsonLoads(row.json);
1961
+ } catch (error) {
1962
+ if (!(error instanceof SyntaxError)) throw error; // 只吞 ValueError 分支(同 Python)
1963
+ return {};
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * UPSERT 用户飞书配置(整对象覆盖;secret 以明文落库,同旧全局语义)。
1969
+ * 对齐 `db.set_feishu_user_cfg`(db.py:1436);JSON 文本走 `pyJsonDumps`(逐字节同 Python)。
1970
+ * @param {number} userId
1971
+ * @param {any} obj
1972
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1973
+ */
1974
+ export function setFeishuUserCfg(userId, obj, options = {}) {
1975
+ withWriteDb(options, (handle) => {
1976
+ handle.prepare(
1977
+ 'INSERT INTO feishu_user_cfgs(user_id, json) VALUES(?, ?)'
1978
+ + ' ON CONFLICT(user_id) DO UPDATE SET json=excluded.json',
1979
+ ).run(bindable(userId), pyJsonDumps(obj));
1980
+ });
1981
+ }
1982
+
1983
+ /**
1984
+ * 全部用户的飞书配置 `[[user_id, dict], …]`(服务启动遍历入站长连接用)。
1985
+ * 对齐 `db.list_feishu_user_cfgs`(db.py:1445):**坏 JSON 整行丢弃**(不是降级 `{}`——
1986
+ * 那是 `getFeishuUserCfg` 的单行语义)。返回二元组数组(Python 的 list[tuple])。
1987
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1988
+ * @returns {Array<[number, object]>}
1989
+ */
1990
+ export function listFeishuUserCfgs(options = {}) {
1991
+ const rows = withReadDb(options, (handle) => plainRows(handle.prepare(
1992
+ 'SELECT user_id, json FROM feishu_user_cfgs').all()));
1993
+ const out = [];
1994
+ for (const row of rows) {
1995
+ try {
1996
+ out.push([row.user_id, pyJsonLoads(row.json)]);
1997
+ } catch (error) {
1998
+ if (!(error instanceof SyntaxError)) throw error; // 只吞 ValueError 分支(同 Python)
1999
+ // 坏 JSON 行跳过(视同无配置)
2000
+ }
2001
+ }
2002
+ return out;
2003
+ }
2004
+
2005
+ /**
2006
+ * 项目飞书推送绑定行(webhook/secret/事件开关),无记录返回 `null`。
2007
+ * 对齐 `db.get_feishu_hook`(db.py:1487)。
2008
+ * @param {number} projectId
2009
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2010
+ * @returns {object|null}
2011
+ */
2012
+ export function getFeishuHook(projectId, options = {}) {
2013
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
2014
+ 'SELECT * FROM feishu_hooks WHERE project_id=?').get(bindable(projectId))));
2015
+ }
2016
+
2017
+ /**
2018
+ * UPSERT 项目推送绑定(`events` 为逗号拼接串,合法性由调用方保证)。
2019
+ * 对齐 `db.set_feishu_hook`(db.py:1494):覆盖时四列一起换新(`enabled` 不做 0/1 归一,
2020
+ * 原样落库;bool 走 `pyBoolInt`,与 Python sqlite3 的 bool→int 适配同形)。
2021
+ * @param {number} projectId
2022
+ * @param {string} webhookUrl
2023
+ * @param {string} webhookSecret
2024
+ * @param {string} events
2025
+ * @param {any} enabled
2026
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2027
+ */
2028
+ export function setFeishuHook(projectId, webhookUrl, webhookSecret, events, enabled,
2029
+ options = {}) {
2030
+ withWriteDb(options, (handle) => {
2031
+ handle.prepare(
2032
+ 'INSERT INTO feishu_hooks(project_id, webhook_url, webhook_secret, events, enabled)'
2033
+ + ' VALUES(?,?,?,?,?)'
2034
+ + ' ON CONFLICT(project_id) DO UPDATE SET webhook_url=excluded.webhook_url,'
2035
+ + ' webhook_secret=excluded.webhook_secret, events=excluded.events,'
2036
+ + ' enabled=excluded.enabled',
2037
+ ).run(bindable(projectId), bindable(webhookUrl), bindable(webhookSecret),
2038
+ bindable(events), bindable(pyBoolInt(enabled)));
2039
+ });
2040
+ }
2041
+
2042
+ /**
2043
+ * 投递入队(飞书事件唯一入口):同 `dedupKey` 已有 `pending` 行时幂等跳过(返回 `null`),
2044
+ * 否则插入并返回新行 id。对齐 `db.feishu_outbox_push`(db.py:1506)。
2045
+ *
2046
+ * 语义要点(逐条实测自 oracle,别改):
2047
+ * - dedup 只按 `dedup_key` + `status='pending'`,**不看 `user_id`**(换用户推同一 key 也算重复);
2048
+ * - 命中的是 `sent`/`failed` 行 ⇒ **照插新行**;
2049
+ * - `dedupKey` 空串(假值)⇒ **恒插**,不做任何去重查询;
2050
+ * - `next_at` 硬编码 `0`、`created_at` 用 `nowStr()`(本地时间口径)。
2051
+ * SELECT 与 INSERT 分属两次连接(两个事务),check-then-insert 窗口见本段文件头注释。
2052
+ * @param {string} target
2053
+ * @param {string} secret
2054
+ * @param {string} payload 调用方已序列化好的 JSON 文本(本函数**不做**序列化)
2055
+ * @param {string} [dedupKey]
2056
+ * @param {number} [userId] 投递归属用户(项目所有者,供设置页过滤)
2057
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2058
+ * @returns {number|null} 新行 id;dedup 命中回 `null`
2059
+ */
2060
+ export function feishuOutboxPush(target, secret, payload, dedupKey = '', userId = 0,
2061
+ options = {}) {
2062
+ if (dedupKey) {
2063
+ const dup = withReadDb(options, (handle) => plainRow(handle.prepare(
2064
+ "SELECT id FROM feishu_outbox WHERE dedup_key=? AND status='pending'")
2065
+ .get(bindable(dedupKey))));
2066
+ if (dup !== null) return null;
2067
+ }
2068
+ return withWriteDb(options, (handle) => Number(handle.prepare(
2069
+ 'INSERT INTO feishu_outbox(user_id, target, secret, payload, dedup_key,'
2070
+ + ' next_at, created_at) VALUES(?,?,?,?,?,0,?)')
2071
+ .run(bindable(userId), bindable(target), bindable(secret), bindable(payload),
2072
+ bindable(dedupKey), nowStr()).lastInsertRowid));
2073
+ }
2074
+
2075
+ /**
2076
+ * 到期待投递行(`pending` 且 `next_at <= nowTs`,按 id 稳定序限量)。
2077
+ * 对齐 `db.feishu_outbox_due`(db.py:1525):边界**含相等**(实测 `next_at=1000` 在
2078
+ * `nowTs=1000` 时命中、在 `999.9999` 时不命中)。
2079
+ * @param {number} nowTs epoch 秒(REAL 列,支持小数)
2080
+ * @param {number} [limit]
2081
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2082
+ * @returns {object[]}
2083
+ */
2084
+ export function feishuOutboxDue(nowTs, limit = 5, options = {}) {
2085
+ return withReadDb(options, (handle) => plainRows(handle.prepare(
2086
+ "SELECT * FROM feishu_outbox WHERE status='pending' AND next_at<=?"
2087
+ + ' ORDER BY id LIMIT ?').all(bindable(nowTs), bindable(limit))));
2088
+ }
2089
+
2090
+ /**
2091
+ * 投递结果落账(发送线程专用:状态/重试数/下次重试时刻/末次错误摘要)。
2092
+ * 对齐 `db.feishu_outbox_mark`(db.py:1533):**只改这四列**(`payload`/`target`/
2093
+ * `created_at`/`dedup_key` 一律不动);id 不存在时静默 no-op(不抛)。
2094
+ * @param {number} oid
2095
+ * @param {string} status
2096
+ * @param {number} retries
2097
+ * @param {number} nextAt
2098
+ * @param {string} [lastError]
2099
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2100
+ */
2101
+ export function feishuOutboxMark(oid, status, retries, nextAt, lastError = '', options = {}) {
2102
+ withWriteDb(options, (handle) => {
2103
+ handle.prepare(
2104
+ 'UPDATE feishu_outbox SET status=?, retries=?, next_at=?, last_error=? WHERE id=?')
2105
+ .run(bindable(status), bindable(retries), bindable(nextAt), bindable(lastError),
2106
+ bindable(oid));
2107
+ });
2108
+ }
2109
+
2110
+ /**
2111
+ * 最近投递记录(**新→旧**,`ORDER BY id DESC`);`userId` 非 `null` 时仅返回该用户的
2112
+ * (设置页过滤)。对齐 `db.feishu_outbox_recent`(db.py:1541)。
2113
+ *
2114
+ * 注意 `userId=0` 是**合法过滤值**(系统/遗留投递归 0),不是「不过滤」——
2115
+ * 不过滤的唯一形态是 `null`(缺省)。
2116
+ * @param {number} [limit]
2117
+ * @param {number|null} [userId]
2118
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2119
+ * @returns {object[]}
2120
+ */
2121
+ export function feishuOutboxRecent(limit = 50, userId = null, options = {}) {
2122
+ return withReadDb(options, (handle) => {
2123
+ if (userId === null) {
2124
+ return plainRows(handle.prepare(
2125
+ 'SELECT * FROM feishu_outbox ORDER BY id DESC LIMIT ?').all(bindable(limit)));
2126
+ }
2127
+ return plainRows(handle.prepare(
2128
+ 'SELECT * FROM feishu_outbox WHERE user_id=? ORDER BY id DESC LIMIT ?')
2129
+ .all(bindable(userId), bindable(limit)));
2130
+ });
2131
+ }
2132
+
2133
+ /**
2134
+ * 飞书号绑定行(`open_id` → Touchstone 用户),未绑返回 `null`。
2135
+ * 对齐 `db.get_feishu_binding_by_open`(db.py:1553)。
2136
+ * @param {string} openId
2137
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2138
+ * @returns {object|null}
2139
+ */
2140
+ export function getFeishuBindingByOpen(openId, options = {}) {
2141
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
2142
+ 'SELECT * FROM feishu_bindings WHERE open_id=?').get(bindable(openId))));
2143
+ }
2144
+
2145
+ /**
2146
+ * 用户绑定行(Touchstone 用户 → 飞书号),未绑返回 `null`。
2147
+ * 对齐 `db.get_feishu_binding_by_user`(db.py:1560)。
2148
+ * @param {number} userId
2149
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2150
+ * @returns {object|null}
2151
+ */
2152
+ export function getFeishuBindingByUser(userId, options = {}) {
2153
+ return withReadDb(options, (handle) => plainRow(handle.prepare(
2154
+ 'SELECT * FROM feishu_bindings WHERE user_id=?').get(bindable(userId))));
2155
+ }
2156
+
2157
+ /**
2158
+ * 绑定飞书号 ↔ 用户(双向一对一),对齐 `db.set_feishu_binding`(db.py:1567)。
2159
+ *
2160
+ * 两条语句、**同一事务**(见本段文件头):先 `DELETE WHERE user_id=?`(换号重绑:该 user
2161
+ * 名下的旧 open_id 行一律清掉),再对 `open_id` UPSERT;写「当前会话」四列时一律归零——
2162
+ * 绑定态不能跨身份继承(2026-10-08 起「换绑归零」)。
2163
+ *
2164
+ * `bound_at` **不在 INSERT 的列清单里**(照抄 oracle),故两条路径行为不同(实测):
2165
+ * - 「先 DELETE 再 INSERT」路径:新行取列默认 `datetime('now','localtime')` ⇒ `bound_at`
2166
+ * 刷新;同 openId 同 user 重绑也走这里,因为 DELETE **会删掉自己那一行**(实测表里另有
2167
+ * 更高 rowid 行时 rowid 由 1 变 3),随后是无冲突的普通插入;
2168
+ * - `ON CONFLICT(open_id)` 覆盖路径(同 openId 换 user):冲突分支**不更新** `bound_at`
2169
+ * (只设 user_id / default_project_id 与四列),旧值保留。
2170
+ * @param {string} openId
2171
+ * @param {number} userId
2172
+ * @param {number} [defaultProjectId]
2173
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2174
+ */
2175
+ export function setFeishuBinding(openId, userId, defaultProjectId = 0, options = {}) {
2176
+ withWriteDb(options, (handle) => {
2177
+ handle.prepare('DELETE FROM feishu_bindings WHERE user_id=?').run(bindable(userId));
2178
+ handle.prepare(
2179
+ 'INSERT INTO feishu_bindings(open_id, user_id, default_project_id,'
2180
+ + " cur_sid, cur_project_id, cur_title, cur_bound_at)"
2181
+ + " VALUES(?,?,?,'',0,'','') ON CONFLICT(open_id) DO UPDATE SET"
2182
+ + ' user_id=excluded.user_id, default_project_id=excluded.default_project_id,'
2183
+ + " cur_sid='', cur_project_id=0, cur_title='', cur_bound_at=''",
2184
+ ).run(bindable(openId), bindable(userId), bindable(defaultProjectId));
2185
+ });
2186
+ }
2187
+
2188
+ /**
2189
+ * 按飞书号解绑。对齐 `db.del_feishu_binding_by_open`(db.py:1584):不存在的行静默 no-op。
2190
+ * @param {string} openId
2191
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2192
+ */
2193
+ export function delFeishuBindingByOpen(openId, options = {}) {
2194
+ withWriteDb(options, (handle) => {
2195
+ handle.prepare('DELETE FROM feishu_bindings WHERE open_id=?').run(bindable(openId));
2196
+ });
2197
+ }
2198
+
2199
+ /**
2200
+ * 按用户解绑(用户主动解绑 / admin 强制解绑)。对齐 `db.del_feishu_binding_by_user`(db.py:1590)。
2201
+ * @param {number} userId
2202
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2203
+ */
2204
+ export function delFeishuBindingByUser(userId, options = {}) {
2205
+ withWriteDb(options, (handle) => {
2206
+ handle.prepare('DELETE FROM feishu_bindings WHERE user_id=?').run(bindable(userId));
2207
+ });
2208
+ }
2209
+
2210
+ /**
2211
+ * 设置单聊默认项目(0=无;归属校验由调用方保证)。对齐 `db.set_feishu_default_project`(db.py:1596):
2212
+ * 行不存在时静默 no-op;**不动**「当前会话」四列,也不刷 `bound_at`。
2213
+ * @param {string} openId
2214
+ * @param {number} projectId
2215
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2216
+ */
2217
+ export function setFeishuDefaultProject(openId, projectId, options = {}) {
2218
+ withWriteDb(options, (handle) => {
2219
+ handle.prepare('UPDATE feishu_bindings SET default_project_id=? WHERE open_id=?')
2220
+ .run(bindable(projectId), bindable(openId));
2221
+ });
2222
+ }
2223
+
2224
+ /**
2225
+ * 写「当前会话」绑定(`open_id` 行必须已存在;`cur_bound_at` 取本地时间字符串)。
2226
+ * 对齐 `db.set_feishu_cur_session`(db.py:1604)。
2227
+ *
2228
+ * 空值口径照 Python 的 `or`:`sid or ""` / `title or ""` / `int(project_id or 0)`——
2229
+ * 传 `null`/`undefined`/空串等价于清空对应字段(行上不出现 NULL)。`int()` 走 `pyIntOf`
2230
+ * (非法值抛 TypeError,同 `db.py` 里 `int()` 抛 ValueError 的位置)。行不存在 = no-op。
2231
+ * @param {string} openId
2232
+ * @param {number} projectId
2233
+ * @param {string} sid
2234
+ * @param {string} [title]
2235
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2236
+ */
2237
+ export function setFeishuCurSession(openId, projectId, sid, title = '', options = {}) {
2238
+ withWriteDb(options, (handle) => {
2239
+ handle.prepare(
2240
+ 'UPDATE feishu_bindings SET cur_sid=?, cur_project_id=?, cur_title=?,'
2241
+ + ' cur_bound_at=? WHERE open_id=?')
2242
+ .run(pyTruthyOf(sid) ? sid : '',
2243
+ pyTruthyOf(projectId) ? pyIntOf(projectId) : 0,
2244
+ pyTruthyOf(title) ? title : '', nowStr(), bindable(openId));
2245
+ });
2246
+ }
2247
+
2248
+ /**
2249
+ * 清「当前会话」绑定(**保留默认项目**;未绑定时为 no-op)。对齐
2250
+ * `db.clear_feishu_cur_session`(db.py:1618)。
2251
+ * @param {string} openId
2252
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2253
+ */
2254
+ export function clearFeishuCurSession(openId, options = {}) {
2255
+ withWriteDb(options, (handle) => {
2256
+ handle.prepare(
2257
+ "UPDATE feishu_bindings SET cur_sid='', cur_project_id=0,"
2258
+ + " cur_title='', cur_bound_at='' WHERE open_id=?").run(bindable(openId));
2259
+ });
2260
+ }