@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Touchstone Node 后端 —— 入口(M0:进程契约 + HTTP/静态/SSE 骨架)。
4
+ *
5
+ * 为什么要有它:dsh 插件的后台现在要求用户机器上有 Python 3.12+ 与 pip,这是一键安装的
6
+ * 真正障碍。Node 版与 Python 版**行为等价**、可同时运行、可逐项对拍(见
7
+ * doc_ai/plan/202610/20261011_0835_Node后端重写与双轨共存改造方案.md)。M0 只交付骨架:
8
+ * 薄壳能按同一份 argv 拉起它、面板能打开,业务端点随里程碑逐个补齐。
9
+ *
10
+ * 进程契约(方案 §3.2,薄壳 dsh-plugin/lib/index.js:481-502 是权威):
11
+ * argv : <entry> --host 127.0.0.1 --port 0 --web-dir <repoDir>/webui/dist-plugin \
12
+ * --trust-internal-user --parent-watch
13
+ * stdout: 必须出现一行 `TOUCHSTONE_LISTEN <port>`(listen 成功后立刻写,**不要求首行**)
14
+ * stdin : 只读不写 ⇒ `--parent-watch` 时 EOF 即自行退出(父死感知,跨平台同码路径)
15
+ *
16
+ * M0 边界(有意为之,写进文档):
17
+ * - 只实现 GET 读路径骨架;写路径在 M3–M5 落地。非 GET 统一 501(与 Python 同状态码,
18
+ * 正文形状留待 M3 对齐)。
19
+ * - 会话解析属 M2;除 `/api/auth/me` 外的 `/api/*` 端点仍一律 404(M3 起逐个补齐)。
20
+ *
21
+ * M1 现状(2026-10-11):
22
+ * - **同库单实例锁已接入**(`lib/lock.js`,语义对齐 Python `lifecycle.SingleInstance`):
23
+ * 启动时在 `<db>.lock` 上取 SQLite 排它锁(跨语言:Python 的 flock / sqlite3 与 Node 互斥),
24
+ * 被真·另一实例占用则打印占用者并 **exit 3**(与 server.py 同码),`shutdown()` 里 release。
25
+ * - `--allow-shared-db` 因此成为真实逃生口(lib/args.js 已解析)。
26
+ * - **数据层已接线**(`lib/db.js`):取锁 → `initDb()`(建表/收敛/种子/声明式迁移)→
27
+ * 过期会话清理 → 起服务,顺序与 `server.py` 一致;`/api/auth/me` 由契约桩改为读 users 表
28
+ * (信任头 → admin 行;行不存在时 `/api/*` 一律 401,对齐 Python `_current_user()` 为 None)。
29
+ * 种子横幅走 stderr(Python 走 stdout 的 LISTEN 行之前):薄壳两者落同一份日志,
30
+ * 而 Node 自有 banner 落在 stderr(**与 Python 不同**:Python 的启动 banner 走 stdout)。
31
+ * ⚠ M4.5 修正:M0 写的「stdout 首行必须是端口行」**Python 自己就不成立**——实测
32
+ * `python3 server.py` 的 stdout 首行是 `[waitq] 对账完成:活跃行 0 条,收口 0 条`
33
+ * (调度核心在起监听前跑启动对账),Node 接上调度核心后同形。薄壳是**逐行**正则
34
+ * (`dsh-plugin/lib/index.js:663`),因此契约口径 = 「stdout 里恰好一行端口行」。
35
+ * 运行时诊断(`[waitq]`/`[runner]`/`[waitq-selfcheck]`)与 Python 同走 stdout(`console.log`),
36
+ * 以便日志对拍逐行可比;Node 自有 banner 仍留在 stderr(M1 决定,见上)。
37
+ */
38
+ import http from 'node:http';
39
+
40
+ import { REPO_ROOT, parseBackendArgs, resolveWebDir } from './lib/args.js';
41
+ import { mustChangePwBlocked } from './lib/access.js';
42
+ import { cookieToken } from './lib/cookie.js';
43
+ import { getUserById, getUserByName, getSession, initDb, openDb, purgeExpiredSessions,
44
+ schemaVersion } from './lib/db.js';
45
+ import * as dshevents from './lib/dshevents.js';
46
+ import * as dshdriver from './lib/dshdriver.js';
47
+ import * as board from './lib/board.js';
48
+ import * as chat from './lib/chat.js';
49
+ import * as feishu from './lib/feishu.js';
50
+ import * as feishuConv from './lib/feishu_conv.js';
51
+ import * as runnerMod from './lib/runner.js';
52
+ import { setMediaFetcher } from './lib/sessparse.js';
53
+ import { pidAlive } from './lib/proc.js';
54
+ import { respondError, respondUnsupportedMethod, sendRedirect } from './lib/http.js';
55
+ import { acquireSingleInstance, resolveDbPath } from './lib/lock.js';
56
+ import {
57
+ Runner, reconcileUnits, setBoardFinishHook, setBoardSelfcheckHooks, setInstance,
58
+ setLivenessProbe, setUnitHandlers, startUnitSelfcheck,
59
+ } from './lib/runner.js';
60
+ import { serveStatic, webDirReady } from './lib/static.js';
61
+ import { STATE_CACHE, dispatchSafely, isPublicRoute, makeContext } from './routes/index.js';
62
+
63
+ /** 反代注入的免登信任头(须与 dsh-plugin/lib/index.js 的 TRUST_HEADER 一致) */
64
+ const TRUST_HEADER = 'x-ts-internal-user';
65
+
66
+ /**
67
+ * 允许 `--trust-internal-user` 的监听地址白名单。
68
+ * 为什么必须有(对齐 `server.py:5423-5428`):信任头等价 admin 全权,一旦对外网监听,
69
+ * 任何能连上的人都拿到 admin;Python 侧对非回环 host 直接 `exit 1`。Node 侧少这一道,
70
+ * 就等于「改一行 `--host 0.0.0.0` = 局域网 admin 后门」。
71
+ * 名单与 Python **逐字相同**(不含 `::1`):等价优先于「看起来更全」——差异会在对拍里暴露。
72
+ */
73
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost']);
74
+
75
+ /** SPA 页面集合:与 `server.py` 的 `PUBLIC_PAGES` + ("/app","/monitor","/admin","/log") 一致 */
76
+ const SPA_PAGES = new Set(['/login', '/app', '/monitor', '/admin', '/log']);
77
+
78
+ /**
79
+ * 路由分派(`handler` = `server.py:do_GET` 的读路径骨架)。
80
+ * 顺序与 Python 严格一致:`/` 重定向 → SPA 页面 → `/api/*` 鉴权 → 静态(含 SPA 回退)。
81
+ */
82
+ function createHandler({ webDir, trustInternalUser, db }) {
83
+ /** 本次请求是否带薄壳反代注入的免登信任头(`_trusted_admin_request`) */
84
+ const hasTrustHeader = (req) => Boolean(trustInternalUser && req.headers[TRUST_HEADER] === 'admin');
85
+
86
+ /**
87
+ * 当前用户(逐条对齐 `server.py:_current_user`(5240)):
88
+ * ① 信任头 + users 里真有 admin 行 ⇒ 该行;**admin 行不存在时继续往下走 cookie 分支**
89
+ * (Python 是 `if admin is not None: return admin` 的落空,不是 return None);
90
+ * ② 取 cookie 里的 `ts_session`;**空串与缺失同处理**(Python `if not token: return None`
91
+ * —— `_cookie_token` 命中 `ts_session=` 时返回的是空串而非 None,falsy 一并挡掉);
92
+ * ③ 查会话行:不存在**或已过期** ⇒ `getSession` 回 null(过期判据在 db 层:`expires_at` 与
93
+ * 本地 now 的字符串比较,见 lib/db.js:getSession);
94
+ * ④ 按 `user_id` 取用户行(行可能已被删 ⇒ null ⇒ `/api/*` 401,与 Python 同)。
95
+ *
96
+ * 为什么要有 cookie 分支:M1 只搬了信任头分支,浏览器直连(无薄壳反代)时此后端不认会话
97
+ * ——登录端点下的 cookie 拿不到任何效果(`/api/auth/me` 恒 401)。
98
+ */
99
+ const currentUser = (req) => {
100
+ if (!db) return null;
101
+ if (hasTrustHeader(req)) {
102
+ const admin = getUserByName(db, 'admin');
103
+ if (admin) return admin;
104
+ }
105
+ const token = cookieToken(req.headers);
106
+ if (!token) return null;
107
+ const row = getSession(db, token);
108
+ if (!row) return null;
109
+ return getUserById(db, row.user_id);
110
+ };
111
+
112
+ return async (req, res) => {
113
+ const method = String(req.method || 'GET').toUpperCase();
114
+ const rawUrl = String(req.url || '/');
115
+ const path = rawUrl.split('?')[0];
116
+ const search = rawUrl.includes('?') ? rawUrl.slice(rawUrl.indexOf('?') + 1) : '';
117
+
118
+ // 静态与 SPA 页面(只有 GET 走;Python 侧 do_HEAD/do_PUT/do_OPTIONS 一律 501)
119
+ if (method === 'GET') {
120
+ if (path === '/') {
121
+ // Python: 已登录 → /app,未登录 → /login(插件形态下信任头 + admin 行即已登录)
122
+ sendRedirect(res, currentUser(req) ? '/app' : '/login');
123
+ return;
124
+ }
125
+ if (SPA_PAGES.has(path)) {
126
+ await serveStatic(res, webDir, '/index.html');
127
+ return;
128
+ }
129
+ if (!path.startsWith('/api/')) {
130
+ await serveStatic(res, webDir, path);
131
+ return;
132
+ }
133
+ } else if (method !== 'POST' && method !== 'PATCH' && method !== 'DELETE') {
134
+ // 没有 do_HEAD/do_PUT/do_OPTIONS ⇒ Python 的 BaseHTTPRequestHandler 回 501 的 HTML
135
+ // 错误页;这里逐字节复刻(见 lib/http.js:respondUnsupportedMethod),不留白名单
136
+ respondUnsupportedMethod(res, method);
137
+ return;
138
+ }
139
+
140
+ // ---- 公开端点(**在鉴权之前**分派,照 `server.py:do_POST`(1027) 头两行)---------
141
+ // `/api/auth/login` 与 `/api/auth/register` 是「未登录也能打」的端点——它们要是排在
142
+ // 鉴权之后,就成了「必须先登录才能登录」的死锁。Python 的顺序是:
143
+ // path == login/register → 直接处理(**不查 _current_user、不过强制改密闸**)
144
+ // path.startswith("/api/") → 401 检查 → 强制改密闸 → …
145
+ // 名单由 `routes/index.js:isPublicRoute` 从 ROUTES 里 `public: true` 的条目现算
146
+ // (单一真相源:新增公开端点只改路由表,不在 server.js 里再抄一份字符串)。
147
+ // ctx 的 `user: null` 与 `trusted: false` 是照实描述:公开端点分支上两者都还没有/不适用;
148
+ // 请求体由各 handler 在 Python 的同一位置自己 `await ctx.readBody()`(此处不预读)。
149
+ if (method === 'POST' && isPublicRoute(method, path)) {
150
+ const publicCtx = makeContext({
151
+ req, res, db, user: null, path, search, stateCache: STATE_CACHE, trusted: false,
152
+ });
153
+ if (await dispatchSafely(publicCtx)) return;
154
+ }
155
+
156
+ // ---- 鉴权(逐条对齐 Python 的 do_GET/do_POST/do_PATCH/do_DELETE)------------
157
+ // Python 的差别只在「哪些方法对非 /api/ 路径也要登录」:
158
+ // do_POST:只对 `/api/` 前缀鉴权(非 /api 的 POST 直接落到末尾 404 JSON);
159
+ // do_PATCH / do_DELETE:**任何路径**都先鉴权。
160
+ const needsAuth = method === 'POST' ? path.startsWith('/api/') : true;
161
+ const user = currentUser(req);
162
+ if (needsAuth && !user) {
163
+ // 紧凑错误体(与 Python `b'{"error":"unauthorized"}'` 逐字节相同,见 lib/http.js:respondError)
164
+ respondError(res, 401, 'unauthorized');
165
+ return;
166
+ }
167
+
168
+ // ---- 强制改密闸(**GET 与 POST 两个方法**)-------------------------------------
169
+ // 照 `server.py:1023`(do_GET) 与 `:1040`(do_POST):**两个方法都调** `_must_change_pw_blocked`,
170
+ // 且都在各自的 `if path.startswith("/api/")` 之内;`do_PATCH`(1227) 与 `do_DELETE`(1266)
171
+ // 只有 401 检查、**没有**这道闸——顺手给四种方法都加会与 Python 分叉
172
+ // (持一次性口令的账号在 PATCH/DELETE 上会拿到 403 而不是 404/业务码)。
173
+ // 判据本体在 `lib/access.js`(可单测;理由见该文件头)。
174
+ // ⚠ 2026-10-14 订正(M7-R3 车道实测,隔离实例真跑 server.py):此处**曾经**写成
175
+ // `method === 'POST'`,且注释声称「照 server.py:1040:只在 do_POST 里调」——**注释与源码不符**。
176
+ // oracle 实测:`GET /api/me/feishu-cfg` ⇒ 403、`POST` ⇒ 403、`PATCH` ⇒ 200、`DELETE` ⇒ 200、
177
+ // `PUT` ⇒ 501。漏掉 GET 的后果是「持一次性口令的账号」在 Node 侧能读遍全部 `/api/*` 读端点。
178
+ // `path.startsWith('/api/')` 这一半也不省:POST 到非 `/api/` 路径时 Python 压根不进鉴权段
179
+ // (直接落到末尾 404 JSON),少了它会把那种请求错判成 403。
180
+ const pwGated = path.startsWith('/api/') && (method === 'GET' || method === 'POST');
181
+ if (pwGated && mustChangePwBlocked(user, path, { trusted: hasTrustHeader(req) })) {
182
+ respondError(res, 403, '请先修改初始口令');
183
+ return;
184
+ }
185
+
186
+ // ---- 路由分派(`backend/routes/index.js` 的顺序表 = Python if/elif 链)-------
187
+ // 写路径的请求体由 `ctx.readBody()` **记忆化按需读**(时机 = Python `do_POST:1048` 那一行,
188
+ // 见 routes/index.js 的注释)——这里不预读,免得把「不读体的端点」也读掉。
189
+ // `trusted` 供 `_api_change_password` 决定是否免校验原口令(= `_trusted_admin_request`,
190
+ // Python 里只认「开关 + 信任头」,**与 admin 行是否存在无关**,故这里照 hasTrustHeader 传)。
191
+ const ctx = makeContext({
192
+ req, res, db, user, path, search, stateCache: STATE_CACHE, trusted: hasTrustHeader(req),
193
+ });
194
+ // 兜底分派:处理器抛异常只关这条连接(Python `handle_error` 语义),绝不让
195
+ // unhandledRejection 把整个后端带走 —— 见 routes/index.js:dispatchSafely
196
+ if (await dispatchSafely(ctx)) return;
197
+
198
+ // 未命中:`/api/*` 与非 API 的写方法都回 404 JSON(与 Python 各 do_* 的末行一致);
199
+ // GET 的非 /api 路径已在上面被静态托管吃掉,走不到这里。
200
+ //
201
+ // **POST 要先读体再回 404**:Python 的 `body = self._read_json() or {}`(1048) 在路径
202
+ // 分发**之前**,所以未命中路径的请求体照样被消费掉;Node 侧不读就留下未消费的请求体,
203
+ // 同一条 keep-alive 连接上的下一个请求语义会与 Python 分叉。读体抛错(非数字
204
+ // Content-Length)时照 Python 口径**不发响应**、直接掐断这条连接。
205
+ if (method === 'POST') {
206
+ try {
207
+ await ctx.readBody();
208
+ } catch (error) {
209
+ console.error('touchstone-node: 请求体读取失败,掐断连接(照 Python ValueError 口径): '
210
+ + `${(error && error.message) || error}`);
211
+ try {
212
+ res.destroy?.();
213
+ } catch {
214
+ // 连接可能已断:忽略
215
+ }
216
+ return;
217
+ }
218
+ }
219
+ respondError(res, 404, 'not found');
220
+ };
221
+ }
222
+
223
+ async function main() {
224
+ const args = parseBackendArgs();
225
+ if (args.trustInternalUser && !LOOPBACK_HOSTS.has(args.host)) {
226
+ // 安全阀(对齐 server.py:解析后、做任何事之前先挡)
227
+ console.error('touchstone-node: 错误: --trust-internal-user 仅允许 --host 127.0.0.1/localhost'
228
+ + `(当前 --host ${args.host};信任头等价 admin,不得对外网开放)`);
229
+ process.exit(1);
230
+ }
231
+ const webDir = resolveWebDir(args);
232
+ if (!(await webDirReady(webDir))) {
233
+ // 与 Python 侧「无 dist 直接退出」同口径:缺前端就不要假装在后面能跑起来
234
+ console.error(`touchstone-node: 静态目录缺少 index.html,退出: ${webDir}`);
235
+ process.exit(1);
236
+ }
237
+
238
+ // ---- 同库单实例锁(M1)----------------------------------------------------------
239
+ // 与 Python 侧 `server.py:5435-5447` **同序同位**:安全阀 → 静态目录校验 → 取锁 → 起服务。
240
+ // 锁口径见 lib/lock.js(`<db>.lock` 上的 SQLite 排它锁,与 Python 的 flock 跨语言互通);
241
+ // 拿不到就打印占用者与逃生口并 **exit 3**(与 Python 版退出码逐字一致)。
242
+ // 库路径只取一次:锁保护的就是这个库,两处(锁 / initDb)必须同源
243
+ const dbPath = resolveDbPath();
244
+ const instance = acquireSingleInstance({
245
+ dbPath,
246
+ repoDir: REPO_ROOT,
247
+ parentWatch: args.parentWatch,
248
+ allowShared: args.allowSharedDb,
249
+ logger: (msg) => console.error(msg),
250
+ });
251
+ if (!instance.ok) {
252
+ console.error(`touchstone-node: 错误: ${instance.reason}`);
253
+ process.exit(3);
254
+ }
255
+
256
+ // ---- 数据层初始化(M1)----------------------------------------------------------
257
+ // 顺序对齐 `server.py`:取锁 → `db.init_db()`(建表/补列收敛/种子/声明式迁移)→
258
+ // 过期会话清理 → 起服务。
259
+ //
260
+ // **为什么跑两次**(对齐 server.py:5329 与 :5479):Python 侧在**模块导入期**先跑一次
261
+ // `db.init_db()`(注释写明:建表必须先于 runner 全局实例,否则 worker 抢表会让队列静默瘫痪),
262
+ // `main()` 里再跑一次(`_MODULE_SEEDED or db.init_db()`:本次已种子则短路)。净效果是
263
+ // **老库在一次启动里经历两轮迁移**——`2026-09-04_projects_drop_global_unique_name` 重建表
264
+ // 只搬 12 个老列,后加的 9 列(model/archived/skill_* 五列/reasoning_effort/permission_mode)
265
+ // 要等第二轮补列才回来。Node 侧只跑一轮的话,极老库启动后会留下缺列的 projects
266
+ // (任何读 model/archived 的查询都报错),用户侧表现为「切到 Node 版就打不开」。
267
+ // 两轮是这里的**行为契约**,不是补丁:db-run.mjs 的 ancient 夹具逐轮钉住它。
268
+ let db;
269
+ try {
270
+ const boot = initDb({ dbPath });
271
+ const again = boot.seeded ? null : initDb({ dbPath });
272
+ const executedSteps = boot.executed.length + (again ? again.executed.length : 0);
273
+ db = openDb(dbPath);
274
+ purgeExpiredSessions(db);
275
+ const hint = !boot.seeded ? ''
276
+ : boot.seedPassword
277
+ ? ` (首次启动已创建默认账号 admin,一次性初始口令:${boot.seedPassword}`
278
+ + ',首次登录须修改;仅本次显示,请立即记录)'
279
+ : ' (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)';
280
+ // 文案只报「检查了几步」而不说「执行了几步」(M3 评审 D5):声明式迁移在**已迁移完**的库上
281
+ // 逐步 no-op(实测同一份快照库起停两次,表行数与文件 sha256 完全不变),旧文案
282
+ // 「本次执行迁移 N 步」读起来像「又迁移了一遍」,排障时会误导。
283
+ console.error(`touchstone-node: 数据库 ${dbPath}(schema 版本 ${schemaVersion(db)},`
284
+ + `启动检查声明式迁移 ${executedSteps} 步——幂等,已迁移的库上为 no-op)${hint}`);
285
+ } catch (error) {
286
+ console.error(`touchstone-node: 数据层初始化失败 ${dbPath}: ${error && error.message}`);
287
+ instance.release();
288
+ process.exit(1);
289
+ }
290
+
291
+ // ---- M4.5/M5 接线:统一队列调度核心 + 看板启动步(启动序硬约束照 `server.py:5481-5505`)
292
+ // 顺序逐条对照 Python:
293
+ // set_liveness_probe(board.unit_liveness) → runner.recover → **board.recover** →
294
+ // reconcile_units → release_boot_gate → board.start_scheduler → dshevents.start →
295
+ // board.start_ext_recover_after_connect → board.start_interaction_watcher →
296
+ // runner.start_unit_selfcheck
297
+ //
298
+ // ① 判活探针 = `board.unitLiveness`(web 族卡三态映射;对非卡行直接 return None,`board.py:1706`)。
299
+ // ② `recover()` 必须 **await**:Node 侧它是 async(Python 是同步函数);不 await 会让
300
+ // `reconcile_units` 在与恢复并发的状态下跑,「先 recover 后对账」这条硬约束就断了。
301
+ // ③ **`board.recover()` 必须夹在 `runner.recover()` 与 `reconcile_units()` 之间**(Python 硬顺序):
302
+ // 它把 busy web 卡的 `c:` 行置回 running、重建在管条目(`_RUNS`)、刷新 `ext:` 行对账;
303
+ // 对账提前会把 busy web 卡误杀。M4.5 时这一步整组缺席(响亮声明过),M5 补齐。
304
+ // ④ 启动闸 `new Runner(true, …)`:worker 就绪后先停在闸后,等对账跑完再 `releaseBootGate()`
305
+ // —— 这是 P7a 缺陷 F 的修复口径(提前拾取会被 `reconcile_units` 按证据误裁)。
306
+ // ⑤ `start_ext_recover_after_connect()` 的位置在 **dshevents.start() 之后**:它要等中枢
307
+ // 首连就绪再补跑一次 ext 行对账(启动时中枢未连接的僵尸占用行救不回来,2026-10-07 实障)。
308
+ // 后台线程,不阻塞端口绑定;未就绪直接结束。
309
+ setLivenessProbe(board.unitLiveness);
310
+ // board ↔ runner 的缝(M4 预留、M5 接线):runner 的收尾口与周期自检的两个 board 读钩子。
311
+ setBoardFinishHook((key, reason) => board.finish(key, reason, { db: null }));
312
+ setBoardSelfcheckHooks({ unitManaged: board.unitManaged, refreshExtRows: board.refreshExtRows });
313
+ const runnerInstance = new Runner(true, { dbPath });
314
+ setInstance(runnerInstance);
315
+ // ---- M6.5 接线:单元执行体(`setUnitHandlers`)-----------------------------------
316
+ // `t:` 与 `c:` 两路**接真**:`processTask`(轮次执行体:script_retest / 复压 / retest_bug /
317
+ // fix / pipeline / 轮次循环)与 `processCard`(看板卡片起跑)。接线前这两路一律抛具名错误
318
+ // `UNIT_HANDLER_MISSING`(「任务单元轮次执行体未移植(M6)」)⇒ 实例级判据
319
+ // `cases-http/m6_5_runner.json` 接线前是**红**的(rounds 缺行 + error 文案不同),接线后才绿。
320
+ //
321
+ // **只接两路**:`a:`(答案送达,`board._deliver_answer_unit`)仍留空缝 ⇒ 命中即抛
322
+ // `UnitHandlerMissing`(响亮,不静默)。这是 M5-2 划归「作答/归档族」的口,属 M7。
323
+ //
324
+ // 位置:`setInstance` 之后、`recover()` 之前 —— 执行体在闸后(`releaseBootGate`)才可能被
325
+ // 调用,此处注册只保证「任何一条被拾取的单元都有执行体」,不引入新的启动序。
326
+ setUnitHandlers({
327
+ processTask: (taskId) => runnerInstance.processTask(taskId),
328
+ processCard: (cardId) => runnerInstance.processCard(cardId),
329
+ });
330
+ await runnerInstance.recover();
331
+ await board.recover({ dbPath }); // ③ 硬顺序:runner.recover → board.recover → 对账
332
+ reconcileUnits(); // 顺序硬约束:对账在两次 recover 之后(提前会误杀 busy 卡)
333
+ runnerInstance.releaseBootGate(); // 闸放行点在对账之后
334
+ board.startScheduler(); // 看板 30s 调度线程(定时卡/巡视/ext 对账)
335
+ console.error('touchstone-node: 调度核心已接线(启动闸已放行;board 启动步随 M5 接全)');
336
+
337
+ // ---- M3 接线:会话附件取字节 + dsh 状态事件中枢 ---------------------------------
338
+ // ① `setMediaFetcher(dshdriver.media)`:sessparse 的 `resolve_media` 靠它把 dsh 附件 id
339
+ // 换成字节。**M2 评审 P1 的教训**:`resolve_media` 是 `async`,注入的函数也是 async,
340
+ // 漏了这一步附件会静默 404(更糟的是未捕获拒绝会崩进程)——所以它必须在起服务前接好。
341
+ // ② `dshevents.start()`:看板两条 SSE 与会话 meta 的 dsh 状态读口都依赖它(单条 SSE 订阅
342
+ // 插件的全局状态流)。独立形态(未下发 `TS_AGENT_DRIVER_URL`)下它直接返回 false、
343
+ // 不起线程——与 Python `dshevents.start()` 同口径(Python 侧同样在未配置时 no-op)。
344
+ setMediaFetcher(dshdriver.media);
345
+ const hubStarted = dshevents.start();
346
+ console.error(`touchstone-node: dsh 状态事件中枢 ${hubStarted ? '已启动' : '未启动'
347
+ + '(未下发 TS_AGENT_DRIVER_URL)'}`);
348
+
349
+ // ---- board 启动步(顺序照 Python:ext 补跑 → 调和器 → 等待项周期自检)-------------
350
+ // `startExtRecoverAfterConnect()` 等中枢首连后补跑一次 ext 行对账;`startInteractionWatcher()`
351
+ // 是事件驱动的交互调和器(`dshevents.wait` 唤醒 + 60s 兜底);两者都不阻塞端口绑定。
352
+ board.startExtRecoverAfterConnect();
353
+ board.startInteractionWatcher();
354
+
355
+ // ---- 等待项周期自检(`runner.start_unit_selfcheck()`,Python 在 dshevents.start() 之后)----
356
+ // 仅「可证明失效」的行自动收口;拍长 60s、行年龄豁免 15s。`managed` 判据已接
357
+ // `board.unitManaged`(平台在管的卡行不越权收口,收尾归巡视)。
358
+ startUnitSelfcheck();
359
+
360
+ // ---- 飞书启动步(照 `server.py:5505-5507`:自检 → 投递线程+终态钩子 → 入站长连接)------
361
+ // ① 投递线程口:注册 chat 的消息终态钩子(M7.5-R3 的 `feishu_conv.start`,函数内调用)
362
+ // + 起 outbox 投递循环(未配置时空转);
363
+ // ② 入站口:按用户逐条拉起长连接(凭据齐全才连;用户保存配置时也会即时拉起)。
364
+ // 硬约束:两条都必须在端口绑定之前——否则「端口已可用但入站还没拉起」的窗口里,飞书推来的
365
+ // 消息会丢;且终态钩子必须在入站可能收到第一条消息之前就位(C4/C6)。
366
+ feishu.startNotifier({ dbPath }); // = server.py:5506
367
+ feishu.startInboundAll({ dbPath }); // = server.py:5507
368
+
369
+ // ---- 三条接线缝的注入(Lead 裁决 D1;都必须在 listen 之前)--------------------------
370
+ // ③ `chat.setMsgDoneHook`:**不在这里注册**。oracle 的唯一注册位是 `feishu_conv.start()`
371
+ // (`feishu.py:2676`,在 `start_notifier` 内部,docstring 明说「这是本模块唯一的接线点,
372
+ // 漏掉则回流全哑且无任何报错」)⇒ 上面第 ① 步已经装上。这里再注册一次会变成「两个注册位」
373
+ // 的漂移口子,并掩盖 `feishu_conv.start()` 挂掉的情况(判据见 `tools/parity/test/feishu-seams.test.mjs`)。
374
+ // ④ `runner.setFailureNotifier`:6 位**位置参数**直通 `feishu.taskFailed`——`skippedN` 必须
375
+ // 走第 6 位,**不能**落 `options` 位(`feishu.js:1355-1358` 已登记该坑)。
376
+ runnerMod.setFailureNotifier((projectId, taskId, taskName, taskType, error, skippedN) =>
377
+ feishu.taskFailed(projectId, taskId, taskName, taskType, error, skippedN, { dbPath }));
378
+ // ⑤ `board.setBoardHooks`:D1 拆名后 review 路径 3 位、blocked 路径 4 位,
379
+ // 各自一一对应 `feishu.cardReview` / `feishu.cardBlocked`(不再靠元数猜形态)。
380
+ board.setBoardHooks({ feishuCardReview: feishu.cardReview, feishuCardBlocked: feishu.cardBlocked });
381
+
382
+ const server = http.createServer(createHandler({
383
+ webDir,
384
+ trustInternalUser: args.trustInternalUser,
385
+ db,
386
+ }));
387
+
388
+ try {
389
+ await new Promise((resolve, reject) => {
390
+ server.once('error', reject);
391
+ server.listen(args.port, args.host, resolve);
392
+ });
393
+ } catch (error) {
394
+ // 薄壳恒传 --port 0(随机端口),端口冲突只会出现在手工指定端口时;不做 Python 的
395
+ // port+1 顺延(那会让「我指定的端口」静默变成另一个端口),直接报错退出。
396
+ console.error(`touchstone-node: 监听失败 ${args.host}:${args.port}: ${error && error.message}`);
397
+ // 起不来就把已取的锁还回去(否则会留下一条死 pid 的 .pid 记录,误导下一次启动的排障)
398
+ instance.release();
399
+ process.exit(1);
400
+ }
401
+ server.on('error', (error) => {
402
+ console.error(`touchstone-node: 服务错误: ${error && error.message}`);
403
+ });
404
+
405
+ /**
406
+ * 解析层错误(`clientError`):**写方法的非法 `Content-Length` 必须无响应即断**。
407
+ *
408
+ * 为什么要有(M4.5 由实例级对拍量具抓出,见实施记录 §3.8):
409
+ * - Python 侧 `_read_json` 的 `int(self.headers.get("Content-Length", 0) or 0)` 遇非十进制
410
+ * 抛 ValueError → 一路冒到 socketserver → **不发任何响应、直接断这条连接**
411
+ * (`lib/http.js:readJsonBody` 的 ② 条契约就是照它写的);
412
+ * - Node 侧 llhttp **在解析层就拒收**非法 Content-Length(`HPE_INVALID_CONTENT_LENGTH`),
413
+ * 请求根本进不到 `readJsonBody` ⇒ 契约里那条 reject 分支没机会跑,默认行为是回 400。
414
+ * 实测:`POST /api/tasks/1/session/chat` + `Content-Length: abc` ⇒ Python 0 字节即断、
415
+ * Node 400。
416
+ * 这里按 `err.rawPacket` 的首行方法名做**窄口径**近似:只对写方法(POST/PATCH/DELETE,即
417
+ * Python 里会走到 `_read_json` 的那些)断连;其余解析错误照 Node 默认回 400
418
+ * (Python 是 400 + 一张 HTML 错误页,两边至少状态码同)。
419
+ *
420
+ * **已知不可对齐的一格**:`GET` + 非法 Content-Length —— Python 的 `do_GET` 压根不读体,
421
+ * 照样 200;而 llhttp 已拒收,Node 永远进不到路由。属协议层差异,登记在实施记录里。
422
+ */
423
+ server.on('clientError', (error, socket) => {
424
+ const raw = error && error.rawPacket ? String(error.rawPacket) : '';
425
+ const method = raw.split(' ', 1)[0].toUpperCase();
426
+ if (error && error.code === 'HPE_INVALID_CONTENT_LENGTH'
427
+ && (method === 'POST' || method === 'PATCH' || method === 'DELETE')) {
428
+ console.error(`touchstone-node: 非法 Content-Length(${method})——照 Python int() 口径无响应断连`);
429
+ socket.destroy();
430
+ return;
431
+ }
432
+ if (socket.writable) {
433
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
434
+ } else {
435
+ socket.destroy();
436
+ }
437
+ });
438
+
439
+ const port = server.address().port;
440
+
441
+ // ---- 退出路径与父死感知必须**在打出 LISTEN 行之前**武装好 -------------------------
442
+ // 为什么顺序不能反:端口行是「就绪」的唯一信号,消费方(薄壳 stopBackend、自检、对拍器)
443
+ // 一看到它就可能立刻发 SIGTERM。若 handler 还没注册,进程会**直接死于信号**——release()
444
+ // 与 stdin 看门狗全不跑,现场留下 `.pid` 记录与锁库热日志(M1 实测踩中:db-run 的 server
445
+ // 模式立刻停后端,出现 `sig=SIGTERM` 且 `.pid` 残留)。Python 侧同序:`server.py:5546-5547`
446
+ // 先注册 SIGTERM/SIGINT,`:5562` 才打印 `TOUCHSTONE_LISTEN`。
447
+ let stopping = false;
448
+ const shutdown = (why) => {
449
+ if (stopping) return;
450
+ stopping = true;
451
+ console.error(`touchstone-node: 退出(${why})`);
452
+ // 先释放单实例锁(关连接即释放 SQLite 锁;`.pid` 记录仅在仍属于自己时删除)。
453
+ // 被 kill -9 时走不到这里——锁由内核释放,这正是选 SQLite 锁的核心理由(测试钉住)。
454
+ try {
455
+ instance.release();
456
+ } catch (error) {
457
+ console.error(`touchstone-node: 释放单实例锁异常(忽略): ${error && error.message}`);
458
+ }
459
+ // 兜底定时器:长连接(SSE)挂着时 close 回调不会来,不能让父进程等我们
460
+ const hard = setTimeout(() => process.exit(0), 1000);
461
+ if (hard.unref) hard.unref();
462
+ server.close(() => process.exit(0));
463
+ };
464
+ process.on('SIGTERM', () => shutdown('sigterm'));
465
+ process.on('SIGINT', () => shutdown('sigint'));
466
+
467
+ // 契约行:薄壳按 stdout 逐行匹配它拿端口(跨 chunk 行缓冲),listen 成功后立刻写。
468
+ // 此刻退出路径已武装完毕,看到这一行的人可以安全地立刻停我们。
469
+ process.stdout.write(`TOUCHSTONE_LISTEN ${port}\n`);
470
+ console.error(`touchstone-node: 就绪 host=${args.host} port=${port} webDir=${webDir}`
471
+ + ` trust=${args.trustInternalUser ? 'on' : 'off'} parentWatch=${args.parentWatch ? 'on' : 'off'}`);
472
+
473
+ if (args.parentWatch) {
474
+ // 父死感知 A 通道:stdin 是薄壳开的管道(stdio[0]='pipe')且**永不写入**,
475
+ // 父进程无论优雅退出还是被 kill -9,OS 都会关闭写端 ⇒ 这里收到 end/close 即退出。
476
+ // 这条路径 Windows/Linux 同码,不需要 Python 侧那条 Linux 专属的 PR_SET_PDEATHSIG。
477
+ //
478
+ // **EOF 后必须复核父存活**(对齐 `lifecycle.arm_parent_watch` 的误报防护,2026-10-11 补):
479
+ // stdin 不是管道(`< /dev/null`)或被无关方关闭时同样会来 EOF,无条件自杀会让「手工或包装
480
+ // 脚本起后端」莫名其妙退出——实测 `node backend/server.js … < /dev/null` 立刻退出,而 Python
481
+ // 版继续跑(`_watch_stdin` 里 `if not parent_alive(): _fire() else: 记一次误报继续`)。
482
+ const onStdinGone = (why) => {
483
+ const ppid = process.ppid;
484
+ if (ppid > 1 && pidAlive(ppid)) {
485
+ console.error(`touchstone-node: stdin 关闭但父进程仍存活(ppid=${ppid},${why}):`
486
+ + '判定为误报,继续运行');
487
+ return;
488
+ }
489
+ shutdown(why);
490
+ };
491
+ process.stdin.resume();
492
+ process.stdin.on('end', () => onStdinGone('parent-eof'));
493
+ process.stdin.on('close', () => onStdinGone('parent-eof'));
494
+ process.stdin.on('error', () => onStdinGone('stdin-error'));
495
+ }
496
+ }
497
+
498
+ await main();
package/board.py CHANGED
@@ -96,6 +96,30 @@ def card_workspace(project, card):
96
96
  return wt or project["project_dir"]
97
97
 
98
98
 
99
+ def card_ts_ms(ts_str):
100
+ """库内时间串(`YYYY-MM-DD HH:MM:SS`,db.now_str 口径=**服务器本地时间**)→ 毫秒 epoch。
101
+
102
+ 卡面「最后更新时间」(2026-10-10 批次)的下发形态:库里存的是无时区标记的本地
103
+ 时间串(本容器=UTC,Windows 原生运行=+8),前端若直接 `new Date(串)` 会按浏览器
104
+ 本地时区解析 ⇒ 在 UTC 服务器 + 北京浏览器下整整差 8 小时。故一律在服务端换算成
105
+ epoch(与前端既有的 `scheduled_at`/`last_error_at` 同口径),前端只管按浏览器
106
+ 时区渲染。
107
+
108
+ 解释口径必须与写入口径互逆:`db.now_str` 用 `time.strftime`(本地时间),此处用
109
+ `time.mktime`(同按本地时区反解)——Linux(UTC)/Windows(+8) 下都自洽,见
110
+ tests/test_board_card_time.py 的 TZ 钉子用例。
111
+
112
+ 坏值/空值返回 None(前端不渲染该小字),绝不抛异常打穿读板路径;不用 0 兜底
113
+ (0 会被前端当 1970 年渲染)。
114
+ """
115
+ if not ts_str:
116
+ return None
117
+ try:
118
+ return int(time.mktime(time.strptime(ts_str, "%Y-%m-%d %H:%M:%S"))) * 1000
119
+ except (TypeError, ValueError, OverflowError, OSError):
120
+ return None
121
+
122
+
99
123
  def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None):
100
124
  """卡片 Row -> 前端 dict。running=平台在管运行;busy=会话级运行
101
125
  (平台运行或外部 busy——用户在 dsh GUI 直跑的同步卡会话,前端徽标
@@ -112,7 +136,11 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
112
136
  (queue_state_of 判定序最高级即 answer 在场 ⟺ answer_pending,免二次点查)。
113
137
  worktree(2026-10-06 批次):独立 worktree 卡的落点绝对路径,空串=普通卡
114
138
  (前端据此渲染 🌿 徽标与详情行;只写不改口径见 plan D3)——经 `_card_opt`
115
- 读取,兼容无该列的老库。"""
139
+ 读取,兼容无该列的老库。
140
+ updated_at_ts(2026-10-10 批次):`updated_at` 的毫秒 epoch 形态(`card_ts_ms`
141
+ 换算,服务器本地时间解释),供卡面渲染「最后更新时间」的相对时间/本地绝对时间;
142
+ 语义务必与 `updated_at` 一致=**卡片行最后被改写的时刻**(agent 在会话里干活不刷
143
+ 它,会话活动时间是另一回事)。坏值/空值 ⇒ None,前端不渲染该小字。"""
116
144
  if queue_state is None:
117
145
  answer = is_answer_pending(row["id"])
118
146
  queue_state = queue_state_of(row, running, busy, msg_queued, answer=answer)
@@ -130,6 +158,7 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
130
158
  "last_error": row["last_error"], "last_error_at": row["last_error_at"],
131
159
  "worktree": _card_opt(row, "worktree"),
132
160
  "created_at": row["created_at"], "updated_at": row["updated_at"],
161
+ "updated_at_ts": card_ts_ms(row["updated_at"]),
133
162
  # 状态有更新·用户未打开(2026-10-07 批次):卡面打「有更新」标记的依据,
134
163
  # 置位/清除全在 db 层派生维护,读侧只透传布尔值。
135
164
  "unread": bool(_card_opt(row, "unread") or 0),
package/builtin_assets.py CHANGED
File without changes
File without changes
File without changes