@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,278 @@
1
+ /**
2
+ * 进程探活与进程树终止(Node 后端对 `platcompat` + `lifecycle` 的等价实现,M1)。
3
+ *
4
+ * 为什么单独一个模块:单实例锁的「存量孤儿收口」要判活 + **只向单个 pid** 发信号,
5
+ * 后续里程碑的退出清理要连子进程树一起收掉;两处都要求零依赖、可单测。
6
+ *
7
+ * 与 Python 侧的逐项对应(2026-10-11 复核 platcompat.py / lifecycle.py):
8
+ * `platcompat.pid_alive` → pidAlive :POSIX `kill(pid, 0)`;EPERM 视为存活
9
+ * `lifecycle._looks_like_server` → looksLikeBackend :读命令行的粗校验(防 PID 复用误杀)
10
+ * `lifecycle._term_pid` → terminatePid :只向单个 pid 发信号,异常全吞
11
+ * `platcompat.kill_tree` → killTree :整树终止(口径见下)
12
+ * `platcompat.spawn_session` → spawnSession :独立进程组的后台子进程(口径见下)
13
+ *
14
+ * killTree 的取舍(与 Python 侧的**有意差异**,务必读):
15
+ * - Python POSIX 走 `killpg(getpgid(pid))`,要求目标自成进程组(`spawn_session` 保证);
16
+ * Windows 走 psutil「先子孙后自身」。**Node 没有「进程组归属」的现成读口**,而调用点
17
+ * 未必 setsid——薄壳起来的后端与 dsh 宿主同组,`killpg` 等价物在 Node 里就是「杀掉宿主」。
18
+ * Python 侧 `lifecycle._safe_kill_tree` 正是为此加了「同组降级为单进程」的防护;
19
+ * Node 侧一律按**父子关系递归收树**,天然不会碰到无关的同组进程。
20
+ * - 信号升级口径照抄 `lifecycle.kill_children`:先 SIGTERM → 宽限 `KILL_GRACE_MS` → 仍活则 SIGKILL。
21
+ * 判活用 `isRunning`(比 `pidAlive` 多认僵尸):等待是同步的,自己的子进程在父 reap 前
22
+ * 一直是僵尸,只认 `pidAlive` 会白等满宽限并向僵尸投 SIGKILL(详见 isRunning 注释)。
23
+ *
24
+ * 依据(Linux,本机实测可用):`/proc/<pid>/task/<tid>/children` 给出该线程**直接子进程**的
25
+ * pid 列表(空格分隔,Linux 3.5+)。**逐线程读**是必需的:子进程可能由任意线程 fork 出来,
26
+ * 只读主线程会漏掉。无 `/proc` 的平台(macOS 等)回落 `ps -A -o pid=,ppid=` 的父子表;
27
+ * Windows 无 ps(Python 侧靠 psutil),此时退化为「只终止已知 pid」,见 collectDescendants 注释。
28
+ *
29
+ * 不实现 `platcompat.listen_ports`:Python 侧它已随 kimi web/opencode serve 退场、暂无调用方,
30
+ * M1 也不需要(端口归属判定在 POSIX 由调用方读 /proc 承担)。
31
+ */
32
+ import { execFileSync, spawn } from 'node:child_process';
33
+ import fs from 'node:fs';
34
+
35
+ /** SIGTERM 后升级 SIGKILL 的宽限(毫秒)。对齐 `lifecycle.KILL_GRACE = 1.5s` */
36
+ export const KILL_GRACE_MS = 1500;
37
+ /** 等待进程退出的轮询间隔(毫秒)。对齐 Python 侧 `time.sleep(0.05)` 的同量级口径 */
38
+ export const POLL_MS = 50;
39
+
40
+ /** 同步 sleep 用的共享缓冲(Atomics.wait:主线程可用的同步等待,不占 CPU) */
41
+ const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
42
+
43
+ /** 同步睡眠(毫秒)。killTree 是同步 API(对齐 `platcompat.kill_tree`),不能用 await */
44
+ function sleepMs(ms) {
45
+ if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
46
+ }
47
+
48
+ /**
49
+ * 进程探活(对齐 `platcompat.pid_alive`):存在且有权发信号 ⇒ true。
50
+ *
51
+ * `process.kill(pid, 0)` 只做存在性校验、不投递信号;EPERM(进程存在但无权发信号,
52
+ * 例如别人的 root 进程)按 Python 同口径视为**存活**,ESRCH/EINVAL 视为不存在。
53
+ * 注意:僵尸进程同样算「存活」(`kill(pid, 0)` 成功)——与 Python 行为一致,
54
+ * 调用方若需要「真退出」应等自己的子进程 exit 事件(父进程 reap 之后 pid 才消失)。
55
+ */
56
+ export function pidAlive(pid) {
57
+ const n = Number(pid);
58
+ if (!Number.isInteger(n) || n <= 0) return false;
59
+ try {
60
+ process.kill(n, 0);
61
+ return true;
62
+ } catch (error) {
63
+ return Boolean(error) && error.code === 'EPERM';
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 进程是否仍在**运行**(比 `pidAlive` 更严:Linux 上僵尸进程不算)。
69
+ *
70
+ * 为什么需要这一层:本项目的等待循环都是**同步**的(`Atomics.wait` 阻塞事件循环),
71
+ * 而自己直接 spawn 的子进程在父进程 reap 之前会一直以僵尸形态存在——事件循环被阻塞时
72
+ * 根本不会去 reap,`pidAlive` 便一直为真,等待方会白等满宽限、向僵尸白投 SIGKILL。
73
+ * 生产路径(存量孤儿是上一轮运行的残留,不是本进程的子进程)不受影响,但
74
+ * 「父进程自己收子进程」与全部测试场景必须认僵尸,否则宽限/升级计数全是假的。
75
+ *
76
+ * Linux 依据:`/proc/<pid>/stat` 的 `state` 字段 `Z`。comm 字段可能含空格与括号,
77
+ * 故取**最后一个 `)` 之后**的第一个字符(内核文档推荐的解析方式)。
78
+ * 非 Linux(无僵尸语义)与读不到 stat 时退化为 `pidAlive` 的口径。
79
+ */
80
+ export function isRunning(pid) {
81
+ if (!pidAlive(pid)) return false;
82
+ if (process.platform !== 'linux') return true;
83
+ try {
84
+ const stat = fs.readFileSync(`/proc/${Number(pid)}/stat`, 'utf8');
85
+ const close = stat.lastIndexOf(')');
86
+ return stat.slice(close + 2, close + 3) !== 'Z';
87
+ } catch {
88
+ return true; // 读不到就按 pidAlive 的口径算活着(宁可不杀)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 拉起「独立进程组」的后台子进程(对齐 `platcompat.spawn_session`,M6.5-R2 落地)。
94
+ *
95
+ * POSIX 语义 = `subprocess.Popen(..., start_new_session=True)`:`detached: true` 让子进程
96
+ * `setsid()`,自成会话亦即新进程组——`killTree(pid)` 的「整树」语义与「用户停止发压」
97
+ * 都靠它(脚本可能自起子进程,只杀组长会留孤儿)。
98
+ *
99
+ * 与 Python 侧的**有意差异**(逐条备案):
100
+ * - Python 把 `preexec_fn` 透传给 Popen(父死联动 PDEATHSIG 等);Node 的 `spawn` 无
101
+ * preexec 口,且**当前无调用方**(唯一生产调用点 `_spawn_load` 不传)⇒ 本函数不收该参数;
102
+ * - Windows:Python 显式丢弃 `start_new_session` 并加 `CREATE_NO_WINDOW`(隐藏控制台窗口);
103
+ * Node 的 `detached: true` 在 Windows 上是 `DETACHED_PROCESS`(同样脱离控制台),
104
+ * 无 CREATE_NO_WINDOW 位的等价设置——Windows 不在 M6.5 的判据面内,登记不追。
105
+ * - **启动失败是异步的**:Python `Popen` 在 fork 失败时**同步抛** OSError,Node 的 `spawn`
106
+ * 把 ENOENT/EACCES 这类错误放到 `'error'` 事件上 ⇒ 调用方必须等 `'spawn'/'error'`
107
+ * 二者之一才算「拉起来了」(`runner.spawnLoad` 就是照 Python 的 try/except OSError 写的)。
108
+ *
109
+ * @param {string[]|string} cmd 完整 argv(`[可执行文件, …参数]`)
110
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, stdio?: Array}} [options]
111
+ * @returns {import('node:child_process').ChildProcess}
112
+ */
113
+ export function spawnSession(cmd, { cwd, env, stdio } = {}) {
114
+ const argv = Array.isArray(cmd) ? cmd : [cmd];
115
+ const options = { detached: true };
116
+ if (cwd !== undefined) options.cwd = cwd;
117
+ if (env !== undefined) options.env = env;
118
+ if (stdio !== undefined) options.stdio = stdio;
119
+ return spawn(argv[0], argv.slice(1), options);
120
+ }
121
+
122
+ /**
123
+ * 只向**单个 pid** 发信号(对齐 `lifecycle._term_pid`):异常一律吞(收口失败由调用方的
124
+ * 等待循环兜底)。返回是否成功投递。
125
+ *
126
+ * 收口存量孤儿**绝不能**用本函数之外的进程组/树动作:孤儿往往与它的宿主(乃至正在启动
127
+ * 我们的这个 dsh 进程)同组,`killpg` 会连带杀掉自己——Python 侧实测踩中过。
128
+ * Windows 说明:Node 在 Windows 上对任何信号都走 TerminateProcess(无优雅信号可投递),
129
+ * 与 `lifecycle._term_pid` 的 psutil 分支同效。
130
+ */
131
+ export function terminatePid(pid, sig = 'SIGTERM') {
132
+ const n = Number(pid);
133
+ if (!Number.isInteger(n) || n <= 0) return false;
134
+ try {
135
+ process.kill(n, sig);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /** 读一个 pid 的**直接子进程**:Linux 走 /proc(逐线程),其余平台回落 ps 父子表 */
143
+ function directChildren(pid) {
144
+ if (process.platform === 'linux') return procChildren(pid);
145
+ return psChildren(pid);
146
+ }
147
+
148
+ /**
149
+ * Linux:逐线程读 `/proc/<pid>/task/<tid>/children`。
150
+ * 读不到(进程刚消失、权限不足、线程正在退出)一律跳过——收集是尽力而为,
151
+ * 漏掉的孙进程会因父死被 init 收养,不再属于本树。
152
+ */
153
+ function procChildren(pid) {
154
+ const out = [];
155
+ let tids;
156
+ try {
157
+ tids = fs.readdirSync(`/proc/${pid}/task`);
158
+ } catch {
159
+ return out; // 进程已退出/无 /proc:无子进程可收
160
+ }
161
+ for (const tid of tids) {
162
+ let text = '';
163
+ try {
164
+ text = fs.readFileSync(`/proc/${pid}/task/${tid}/children`, 'utf8');
165
+ } catch {
166
+ continue; // 线程刚消失:忽略
167
+ }
168
+ for (const token of text.trim().split(/\s+/)) {
169
+ const child = Number(token);
170
+ if (Number.isInteger(child) && child > 0 && !out.includes(child)) out.push(child);
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * 非 Linux 回落:`ps -A -o pid=,ppid=`(Linux/BSD 都认)建一次父子表。
178
+ * `ps` 不可用(Windows 无 ps)时返回空表 ⇒ 收树退化为「只终止根 pid」,
179
+ * 与「Node 版 Windows 尚无 psutil 等价物」的现状一致(M1 不把 Windows 树杀当承诺)。
180
+ */
181
+ function psChildren(pid) {
182
+ let text = '';
183
+ try {
184
+ text = execFileSync('ps', ['-A', '-o', 'pid=,ppid='], {
185
+ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
186
+ });
187
+ } catch {
188
+ return [];
189
+ }
190
+ const out = [];
191
+ for (const line of text.split('\n')) {
192
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
193
+ if (m && Number(m[2]) === Number(pid)) out.push(Number(m[1]));
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * 收集 pid 的**全部后代**(不含自身),宽度优先 ⇒ 父一定排在子前面。
200
+ * 调用方按需 reverse 得到「先子孙后自身」的终止顺序。带 visited 去重防环。
201
+ */
202
+ export function collectDescendants(pid) {
203
+ const root = Number(pid);
204
+ if (!Number.isInteger(root) || root <= 0) return [];
205
+ const seen = new Set([root]);
206
+ const out = [];
207
+ const queue = [root];
208
+ while (queue.length > 0) {
209
+ const current = queue.shift();
210
+ for (const child of directChildren(current)) {
211
+ if (seen.has(child)) continue;
212
+ seen.add(child);
213
+ out.push(child);
214
+ queue.push(child);
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * 整树终止(对齐 `lifecycle.kill_children` 的顺序 + `platcompat.kill_tree` 的「整树」语义)。
222
+ *
223
+ * 顺序:**先子孙、后自身**(父先死会让子孙被 init 收养、父子关系丢失,反而更难收)。
224
+ * 过程:对全树投一次 SIGTERM → 等 `graceMs`(轮询,全树不再运行即提前结束)→
225
+ * 对仍在运行的升级 SIGKILL。
226
+ * 异常面:本函数**不抛**(与 `lifecycle._safe_kill_tree` 吞 OSError 同口径),
227
+ * 已消失/无权的 pid 直接跳过。
228
+ *
229
+ * 返回 `{signaled, killed, alive}`:投递信号的目标数、升级 SIGKILL 的个数、
230
+ * 调用结束时**仍在运行**的 pid 列表(僵尸按「已退出」计,见 `isRunning`——
231
+ * 否则父进程自己收子进程时永远收不了尾)。
232
+ */
233
+ export function killTree(pid, { sig = 'SIGTERM', graceMs = KILL_GRACE_MS } = {}) {
234
+ const root = Number(pid);
235
+ const result = { signaled: 0, killed: 0, alive: [] };
236
+ if (!Number.isInteger(root) || root <= 0) return result;
237
+
238
+ const descendants = collectDescendants(root);
239
+ const targets = [...descendants.reverse(), root]; // 子孙在前,自身最后
240
+ for (const target of targets) {
241
+ if (terminatePid(target, sig)) result.signaled += 1;
242
+ }
243
+
244
+ const deadline = Date.now() + Math.max(Number(graceMs) || 0, 0);
245
+ while (targets.some((target) => isRunning(target)) && Date.now() < deadline) {
246
+ sleepMs(POLL_MS);
247
+ }
248
+
249
+ for (const target of targets) {
250
+ if (!isRunning(target)) continue; // 已退出/僵尸:不升级(僵尸不该再收信号)
251
+ terminatePid(target, 'SIGKILL');
252
+ result.killed += 1;
253
+ }
254
+ result.alive = targets.filter((target) => isRunning(target));
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * 命令行粗校验:这个 pid 是否**像**本平台后端(防 PID 复用误杀,对齐
260
+ * `lifecycle.SingleInstance._looks_like_server`)。
261
+ *
262
+ * 与 Python 的差异:Python 只认 `server.py`;Node 侧**同时接受 `backend/server.js`**
263
+ * ——两个版本互为孤儿时都要能收口(Python 杀 Node 孤儿、Node 杀 Python 孤儿)。
264
+ * 读不到命令行(非 Linux 无 /proc、进程刚消失、权限不足)一律返回 **true**:
265
+ * 调用点只在「pid 记录声明 parent_watch 且其父已死」时才走到这里,宁可不杀不误杀。
266
+ */
267
+ export function looksLikeBackend(pid) {
268
+ const n = Number(pid);
269
+ let raw = '';
270
+ if (process.platform !== 'linux') return true; // 无 /proc:读不到命令行 ⇒ 宁可不杀
271
+ try {
272
+ raw = fs.readFileSync(`/proc/${n}/cmdline`, 'utf8');
273
+ } catch {
274
+ return true; // 读不到 ⇒ 宁可不杀(对齐 Python)
275
+ }
276
+ const cmdline = raw.replace(/\\/g, '/'); // Windows 风格分隔符归一,便于同一判据
277
+ return cmdline.includes('server.py') || cmdline.includes('backend/server.js');
278
+ }