@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.
- package/README.md +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/dsh-plugin/lib/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Touchstone dsh 插件 — Node 薄壳(服务端半)
|
|
3
3
|
* 职责四件(路线 A 起,见 doc_ai/spec/dsh_plugin/dsh插件形态.md):
|
|
4
|
-
* 1. spawn Touchstone
|
|
4
|
+
* 1. spawn Touchstone 后端子进程(127.0.0.1 随机端口 + 插件版前端 + 免登信任头):
|
|
5
|
+
* 缺省 Node(`backend/server.js`,2026-10-15 M8 翻)/ `config.backend: python` 时 Python(`server.py`)
|
|
5
6
|
* 2. 向 dsh webServer 注册 /touchstone 前缀路由, 全量反代到子进程(流式管道, SSE 不缓冲)
|
|
6
7
|
* 3. agent 驱动(路线 A): 进程内会话池 + 会话事件 SSE 推送, 挂 /touchstone-agent 前缀;
|
|
7
8
|
* 驱动地址与一次性令牌经环境变量下发给 server.py(见 lib/agent-driver.js)
|
|
8
9
|
* 4. 插件停用时注销路由、断开 SSE、释放常驻会话并 SIGTERM 子进程(server.py 侧有优雅退出)
|
|
9
|
-
* 静态页/SPA 回退/REST/SSE
|
|
10
|
+
* 静态页/SPA 回退/REST/SSE 全部由后端自身承载, 薄壳本身不实现业务
|
|
11
|
+
* (两版后端共用同一份 apply() 其余逻辑: 端口解析、日志落盘、stopBackend、驱动下发都不分叉)。
|
|
10
12
|
*
|
|
11
13
|
* 生命周期自愈(2026-10-04,修「Plugins 面板停用不回收」):
|
|
12
14
|
* dsh 热重载换下条目时**不保证**走到本插件的 disposer。真机实测(隔离 profile):
|
|
@@ -22,9 +24,11 @@
|
|
|
22
24
|
* 两条与正常 dispose 共用同一个幂等 `teardown()`, 因此正常路径行为不变。
|
|
23
25
|
*/
|
|
24
26
|
import { spawn } from 'node:child_process';
|
|
25
|
-
import { existsSync, readFileSync
|
|
27
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync }
|
|
28
|
+
from 'node:fs';
|
|
26
29
|
import http from 'node:http';
|
|
27
|
-
import {
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
32
|
import { fileURLToPath } from 'node:url';
|
|
29
33
|
import { AgentDriver, loadUserMessageFactory, loadModelSelectionInstaller }
|
|
30
34
|
from './agent-driver.js';
|
|
@@ -45,6 +49,10 @@ const BACKEND_EXIT_GRACE_MS = 5000;
|
|
|
45
49
|
/** 掉线自检节拍(ms):热重载把本条目换下却不 dispose 时的兜底发现窗口 */
|
|
46
50
|
const UNMOUNT_WATCH_MS = 2000;
|
|
47
51
|
|
|
52
|
+
/** 插件形态后端日志(2026-10-08):与库同目录追加写;启动时超过该体积先轮转一份 `.1` */
|
|
53
|
+
const BACKEND_LOG_NAME = 'plugin-backend.log';
|
|
54
|
+
const BACKEND_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
55
|
+
|
|
48
56
|
/** 进程级存活壳注册表键(同一 dsh 进程内只允许一份有效壳) */
|
|
49
57
|
const LIVE_KEY = Symbol.for('touchstone.live');
|
|
50
58
|
|
|
@@ -62,6 +70,85 @@ const LIVE_KEY = Symbol.for('touchstone.live');
|
|
|
62
70
|
const FATAL_PY_MODULES = ['zstandard', 'requests'];
|
|
63
71
|
const PREFLIGHT_TIMEOUT_MS = 10000;
|
|
64
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 缺省解释器探测顺序(未配置 config.pythonPath 时): `python` → `python3`。
|
|
75
|
+
*
|
|
76
|
+
* 为什么 `python` 排第一(2026-10-11 用户裁定「兜底应该用 python, 不要用 python3」):
|
|
77
|
+
* - **Windows 上没有 `python3` 这个名字**(官方安装器只装 `python.exe` / `py.exe`),
|
|
78
|
+
* 桌面端零配置安装下, 写死 `python3` 会让 `spawn` 直接 `ENOENT` —— 比「找到解释器但
|
|
79
|
+
* 缺依赖」更糟:预检连跑都跑不起来, 只能走 `skip()` 放行, 然后后端在导入期崩掉,
|
|
80
|
+
* 用户既看不到缺什么、也拿不到可复制的修复命令。
|
|
81
|
+
* - Linux 上两者都在(本机用户 shell 的 `python`/`python3` 同为 conda my_pyenv),
|
|
82
|
+
* 顺序换成 `python` 优先不改变命中结果。
|
|
83
|
+
* - 用 `python3` 当第二档是为了兜住「只有 python3、没有 python」的环境
|
|
84
|
+
* (部分精简发行版/容器镜像如此), 比单点写死更稳。
|
|
85
|
+
* 探测方式 = 真 spawn 一次 `-c pass`(受 PATH 解析、能识破 Microsoft Store 的
|
|
86
|
+
* `python3.exe` 占位别名——它是 GUI 应用, 跑起来不会正常退出), 而不是查文件表。
|
|
87
|
+
*/
|
|
88
|
+
const PYTHON_CANDIDATES = ['python', 'python3'];
|
|
89
|
+
const PYTHON_PROBE_TIMEOUT_MS = 5000;
|
|
90
|
+
/**
|
|
91
|
+
* 探测用的命令。带一个可识别的标记:测试里的假解释器要能把「探测调用」与
|
|
92
|
+
* 「依赖预检调用」区分开(两者都是 `-c`),否则探测会污染预检的调用记录。
|
|
93
|
+
* 标记写成 Python 注释, 对真解释器没有任何语义影响。
|
|
94
|
+
*/
|
|
95
|
+
const PYTHON_PROBE_CODE = 'pass # touchstone-python-probe';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 后端实现选择(2026-10-11 M0 起为 `config.backend`;**2026-10-15 M8 翻缺省**):
|
|
99
|
+
* `config.backend` = `'node'`(**缺省**)| `'python'`(显式,仍完整可用)。
|
|
100
|
+
*
|
|
101
|
+
* 为什么是配置键而不是「有 Python 就用 Python、没有就自动用 Node」:换后端是**验收动作**,
|
|
102
|
+
* 必须能一行改回;自动探测会让「对拍还没过的版本」在用户机器上被静默启用。
|
|
103
|
+
*
|
|
104
|
+
* 缺省值变迁(M8,用户 2026-10-15 裁决「默认可以改为 nodejs 后台,但 python 版现在不能删」):
|
|
105
|
+
* - 翻转前:缺省 python(Node 是影子/可选路径,未验收前谁都不受影响);
|
|
106
|
+
* - 翻转后:缺省 node(Node 已在 M0–M7.5 逐项对拍过关,Gate 1 的全量对拍判据已达成);
|
|
107
|
+
* - Python 版**不退役**:`server.py` 与包内 `*.py` 照旧随包分发,`backend: python`
|
|
108
|
+
* 是观测期唯一的回滚动作(**必须显式写 `python`,删键现在等于 node**)。
|
|
109
|
+
*
|
|
110
|
+
* `backend: 'node'` 时整条 Python 链(解释器探测 + 依赖预检 + 缺依赖提示页)**一律跳过** ——
|
|
111
|
+
* 那正是换 Node 的收益本身(不再要求用户机器上有 Python 与 pip)。改由 Node 侧自己的能力探测
|
|
112
|
+
* (`node:sqlite`,见 `renderNodeRuntimeHint`)兜「宿主 Node 太老」这一类失败。
|
|
113
|
+
*/
|
|
114
|
+
const BACKEND_PYTHON = 'python';
|
|
115
|
+
const BACKEND_NODE = 'node';
|
|
116
|
+
|
|
117
|
+
/** Node 后端入口(相对 repoDir)。进程契约见方案 §3.2,两版 argv 只差入口路径与 node 选项。 */
|
|
118
|
+
const NODE_ENTRY_REL = 'backend/server.js';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 只给 Node 后端的进程选项:`node:sqlite` 在 22.x 是实验特性,每次启动往 stderr 打
|
|
122
|
+
* `ExperimentalWarning`(R1c)——而 `plugin-backend.log` 是排障唯一证据源,噪声必须压掉。
|
|
123
|
+
*/
|
|
124
|
+
const NODE_EXTRA_FLAGS = ['--disable-warning=ExperimentalWarning'];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 宿主 Node 能力探测(M8 新增)。判据是**能力**而不是版本号比较:
|
|
128
|
+
* `node:sqlite` 从 22.5 才有、且 22.19 之前必须显式 `--experimental-sqlite` 才 import 得到
|
|
129
|
+
* (方案 R1 实测:22.19 unflag 可用),而自编译/裁剪过的 Node 也可能没编进 sqlite。
|
|
130
|
+
* 所以真 spawn 一个 `process.execPath`(=随后要拉起后端的那一个二进制)探一次,
|
|
131
|
+
* 探测用的 flags 与 env 与随后 spawn **同一份** —— 与「缺省解释器改真 spawn 探一次」同一条口径。
|
|
132
|
+
*
|
|
133
|
+
* 探到 `DatabaseSync` 不是函数也算失败:import 成功但 API 缺失(22.5-22.18 带 flag 的形态)
|
|
134
|
+
* 会在建库那一刻才炸,那时用户看到的只是「后端起不来」。
|
|
135
|
+
*/
|
|
136
|
+
const NODE_PROBE_CODE = 'import("node:sqlite").then((m)=>{'
|
|
137
|
+
+ 'if(typeof m.DatabaseSync!=="function"){console.error("node:sqlite 无 DatabaseSync");process.exit(4)}'
|
|
138
|
+
+ 'console.log("node:sqlite ok");process.exit(0)},(e)=>{'
|
|
139
|
+
+ 'console.error(String((e&&e.message)||e));process.exit(3)})';
|
|
140
|
+
/** 探测超时(与 Python 侧 PREFLIGHT_TIMEOUT_MS 同量级:探不动就放行,绝不卡住激活) */
|
|
141
|
+
const NODE_PROBE_TIMEOUT_MS = 5000;
|
|
142
|
+
|
|
143
|
+
/** 解析 `config.backend`;未知值 warn 一句后按缺省 node 处理(不静默、不猜测) */
|
|
144
|
+
function resolveBackend(config, logger) {
|
|
145
|
+
const raw = config.backend;
|
|
146
|
+
if (raw === undefined || raw === null || raw === '') return BACKEND_NODE;
|
|
147
|
+
if (raw === BACKEND_PYTHON || raw === BACKEND_NODE) return raw;
|
|
148
|
+
logger.warn(`touchstone: 未知的 config.backend=${JSON.stringify(raw)}(只认 python/node),按缺省 node 处理`);
|
|
149
|
+
return BACKEND_NODE;
|
|
150
|
+
}
|
|
151
|
+
|
|
65
152
|
/** 子进程句柄与解析出的后端端口(每 profile 一个插件实例, 模块级单例即可) */
|
|
66
153
|
let child = null;
|
|
67
154
|
let backendPort = 0;
|
|
@@ -78,6 +165,51 @@ function escapeHtml(text) {
|
|
|
78
165
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
79
166
|
}
|
|
80
167
|
|
|
168
|
+
/**
|
|
169
|
+
* 探一个候选解释器能不能真跑起来(`-c pass`, 5s 超时)。
|
|
170
|
+
*
|
|
171
|
+
* 为什么不是「查文件在不在」: spawn 走 PATH 解析, 而 Windows 的 Microsoft Store
|
|
172
|
+
* 别名会在 PATH 上放一个 `python3.exe` 占位(是 GUI 应用, 跑起来不返回)——
|
|
173
|
+
* 只有真跑一次才知道它是不是能用的解释器。任何异常(ENOENT/超时)都算不可用。
|
|
174
|
+
* `env` 由调用方传(用 `{...process.env, ...extraEnv}`): profile 的 extraEnv 可能改 PATH,
|
|
175
|
+
* 探测必须与随后真正 spawn 后端的环境一致, 否则会出现「探测到 A、实际跑 B」。
|
|
176
|
+
*/
|
|
177
|
+
function canRunPython(name, env) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let settled = false;
|
|
180
|
+
const done = (ok) => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
resolve(ok);
|
|
184
|
+
};
|
|
185
|
+
let proc = null;
|
|
186
|
+
try {
|
|
187
|
+
proc = spawn(name, ['-c', PYTHON_PROBE_CODE], { stdio: 'ignore', env });
|
|
188
|
+
} catch {
|
|
189
|
+
done(false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
try { proc.kill('SIGKILL'); } catch { /* 已退出:忽略 */ }
|
|
194
|
+
done(false);
|
|
195
|
+
}, PYTHON_PROBE_TIMEOUT_MS);
|
|
196
|
+
if (timer.unref) timer.unref();
|
|
197
|
+
proc.on('error', () => { clearTimeout(timer); done(false); });
|
|
198
|
+
proc.on('close', (code) => { clearTimeout(timer); done(code === 0); });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 解析未配置 pythonPath 时该用哪个解释器: 按 PYTHON_CANDIDATES 顺序探测, 返回第一个能跑的。
|
|
204
|
+
* 全都跑不起来(或本平台一个都没有)时返回 null, 由调用方给明确的配置指引。
|
|
205
|
+
*/
|
|
206
|
+
async function resolveDefaultPython(env = process.env) {
|
|
207
|
+
for (const name of PYTHON_CANDIDATES) {
|
|
208
|
+
if (await canRunPython(name, env)) return name;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
81
213
|
/**
|
|
82
214
|
* 读 requirements.txt 的发行名清单(注释/空行/选项行跳过, 剥掉版本与 extras 约束)。
|
|
83
215
|
* 读不到就返回空数组 —— 预检降级为「只验硬依赖」, 不影响启动。
|
|
@@ -101,7 +233,7 @@ function readRequirementNames(repoDir) {
|
|
|
101
233
|
* advisory —— requirements.txt 里缺失的可选依赖(只影响对应功能)
|
|
102
234
|
* skipped —— 预检本身没跑成(解释器缺失/超时/输出不可解析)⇒ 调用方照常启动
|
|
103
235
|
*/
|
|
104
|
-
function preflightDeps(pythonPath, repoDir, logger) {
|
|
236
|
+
function preflightDeps(pythonPath, repoDir, logger, env) {
|
|
105
237
|
const advisory = readRequirementNames(repoDir)
|
|
106
238
|
.filter((name) => !FATAL_PY_MODULES.includes(name.toLowerCase()));
|
|
107
239
|
const script = [
|
|
@@ -130,7 +262,9 @@ function preflightDeps(pythonPath, repoDir, logger) {
|
|
|
130
262
|
logger.warn(`touchstone: 依赖预检未完成(${why}), 照常启动后端`);
|
|
131
263
|
settle({ fatal: [], advisory: [], skipped: true });
|
|
132
264
|
};
|
|
133
|
-
|
|
265
|
+
// env 必须显式传(与探测/启动后端同一份): profile 的 extraEnv 可能改 PATH,
|
|
266
|
+
// 不传就出现「探测用 A、预检用 B」的分裂(2026-10-11 自检抓到的真 bug)。
|
|
267
|
+
const proc = spawn(pythonPath, argv, { cwd: repoDir, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
134
268
|
let stdout = '';
|
|
135
269
|
const timer = setTimeout(() => {
|
|
136
270
|
skip(`超过 ${PREFLIGHT_TIMEOUT_MS}ms`);
|
|
@@ -168,11 +302,83 @@ function preflightDeps(pythonPath, repoDir, logger) {
|
|
|
168
302
|
});
|
|
169
303
|
}
|
|
170
304
|
|
|
305
|
+
/**
|
|
306
|
+
* 真 spawn 探一次宿主 Node 能力(判据与理由见 `NODE_PROBE_CODE`),返回
|
|
307
|
+
* `{ ok, skipped, detail }`:
|
|
308
|
+
* ok=true —— 能力齐备(`detail` 是子进程 stdout 那一行,进日志当证据);
|
|
309
|
+
* ok=false —— 探到了且不满足(`detail` = 子进程 stderr / 退出码)⇒ 调用方**不 spawn 后端**;
|
|
310
|
+
* skipped —— 探测本身没跑成(超时 / 无法 spawn / 无输出)⇒ **照常启动后端**:
|
|
311
|
+
* 与 Python 依赖预检同一口径(预检不确定不许阻断一个本来能跑的部署),
|
|
312
|
+
* 真起不来还有「起来就崩」提示页兜底。
|
|
313
|
+
*
|
|
314
|
+
* env 必须与随后 spawn 后端同一份(profile 的 extraEnv 可能改 `NODE_OPTIONS`/`PATH`),
|
|
315
|
+
* flags 也同一份(`NODE_EXTRA_FLAGS`)——「探测用 A、启动用 B」的分裂是 Python 侧踩过的真 bug。
|
|
316
|
+
*/
|
|
317
|
+
function probeNodeRuntime(execPath, flags, env) {
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
let settled = false;
|
|
320
|
+
const settle = (value) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
resolve(value);
|
|
324
|
+
};
|
|
325
|
+
const skip = (why) => settle({ ok: false, skipped: true, detail: why });
|
|
326
|
+
let proc = null;
|
|
327
|
+
try {
|
|
328
|
+
proc = spawn(execPath, [...flags, '-e', NODE_PROBE_CODE], {
|
|
329
|
+
cwd: homedir(), env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
330
|
+
});
|
|
331
|
+
} catch (error) {
|
|
332
|
+
skip(error && error.message ? error.message : String(error));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
let stdout = '';
|
|
336
|
+
let stderr = '';
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
skip(`超过 ${NODE_PROBE_TIMEOUT_MS}ms`);
|
|
339
|
+
try {
|
|
340
|
+
proc.kill('SIGKILL');
|
|
341
|
+
} catch { /* 已退出:忽略 */ }
|
|
342
|
+
}, NODE_PROBE_TIMEOUT_MS);
|
|
343
|
+
if (timer.unref) timer.unref();
|
|
344
|
+
proc.stdout.on('data', (buf) => { stdout += String(buf); });
|
|
345
|
+
proc.stderr.on('data', (buf) => { stderr += String(buf); });
|
|
346
|
+
proc.on('error', (err) => {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
skip(err.message);
|
|
349
|
+
});
|
|
350
|
+
// 用 close 而不是 exit:exit 可能早于 stdout 最后一个数据块(Python 预检的同一教训)。
|
|
351
|
+
proc.on('close', (code) => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
const out = stdout.trim().split('\n').pop() || '';
|
|
354
|
+
const err = stderr.trim().split('\n').pop() || '';
|
|
355
|
+
if (code === 0) {
|
|
356
|
+
if (!out) { skip('无输出'); return; }
|
|
357
|
+
settle({ ok: true, skipped: false, detail: out });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
settle({ ok: false, skipped: false, detail: err || `探测进程退出 code=${code}` });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 能力探测的测试缝(`dev-check-backend.mjs` 场景 G 用;生产路径恒为 null ⇒ 走真探测)。
|
|
367
|
+
* 「宿主 Node 太老」这一支在真机上造不出来(本机是能力齐备的 Node),不给缝就只能是未验项。
|
|
368
|
+
*/
|
|
369
|
+
let nodeProbeForTest = null;
|
|
370
|
+
export function _setNodeRuntimeProbeForTest(fn) {
|
|
371
|
+
nodeProbeForTest = fn;
|
|
372
|
+
}
|
|
373
|
+
|
|
171
374
|
/**
|
|
172
375
|
* 依赖缺失时的面板提示页(代替 503 JSON): 缺什么 + 一条可直接复制的修复命令
|
|
173
376
|
* + 修完怎么让插件重来(停用再启用即会重跑 apply, 按 spec §8.8 不必重启 dsh web)。
|
|
377
|
+
*
|
|
378
|
+
* `viaDefault` = 这个解释器是缺省兜底探到的(用户没配 `pythonPath`), 页面上要说明一句 ——
|
|
379
|
+
* 否则用户修完命令仍可能指错解释器, 且不知道该往哪里配。
|
|
174
380
|
*/
|
|
175
|
-
function renderDepHint(pythonPath, repoDir, missing) {
|
|
381
|
+
function renderDepHint(pythonPath, repoDir, missing, viaDefault) {
|
|
176
382
|
const cmd = `${pythonPath} -m pip install -r ${join(repoDir, 'requirements.txt')}`;
|
|
177
383
|
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
178
384
|
+ '<title>Touchstone 后端未启动</title></head>'
|
|
@@ -184,11 +390,175 @@ function renderDepHint(pythonPath, repoDir, missing) {
|
|
|
184
390
|
+ '<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;'
|
|
185
391
|
+ `padding:.8rem 1rem;overflow:auto">${escapeHtml(cmd)}</pre>`
|
|
186
392
|
+ '<p>装完把本插件<b>停用再启用</b>(Plugins 面板里那一行的开关)即会重试,不必重启 dsh web。</p>'
|
|
187
|
-
+
|
|
188
|
-
|
|
393
|
+
+ (viaDefault
|
|
394
|
+
? '<p style="color:#8b949e">上面这个解释器是<b>缺省探测</b>得到的(未配置 '
|
|
395
|
+
+ '<code>pythonPath</code>:依次试 <code>python</code>、<code>python3</code>)。'
|
|
396
|
+
+ '若它不是你机器上装了依赖的那个,直接在它里面装依赖最省事(上面这条命令就是);'
|
|
397
|
+
+ '也可以改用另一个解释器 —— 在 profile patch 的 <code>touchstone-dsh</code> 条目里写 '
|
|
398
|
+
+ '<code>pythonPath</code> 指向它。</p>'
|
|
399
|
+
: '<p style="color:#8b949e">若这个解释器不对,可在 profile patch 的 <code>touchstone-dsh</code> 条目里配置 '
|
|
400
|
+
+ '<code>pythonPath</code> 指向含这些依赖的解释器。</p>')
|
|
401
|
+
+ '</body></html>';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 缺省解释器一个都跑不起来时的提示页: 这不是「缺依赖」而是「找不到 Python」,
|
|
406
|
+
* 要给的是安装/配置指引, 不是 pip 命令(此时 pip 也没得跑)。
|
|
407
|
+
*/
|
|
408
|
+
function renderNoPythonHint() {
|
|
409
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
410
|
+
+ '<title>Touchstone 后端未启动</title></head>'
|
|
411
|
+
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
412
|
+
+ 'background:#14161a;color:#e6e6e6">'
|
|
413
|
+
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:找不到 Python 解释器</h2>'
|
|
414
|
+
+ '<p>依次探测 <code>python</code>、<code>python3</code> 都没能跑起来(未配置 '
|
|
415
|
+
+ '<code>pythonPath</code>)。</p>'
|
|
416
|
+
+ '<p>请先安装 Python 3.12+(<code>python.org</code>,Windows 上安装时勾选 '
|
|
417
|
+
+ '<code>Add python.exe to PATH</code>),然后在 profile patch 的 '
|
|
418
|
+
+ '<code>touchstone-dsh</code> 条目里写 <code>pythonPath</code> 指向它;'
|
|
419
|
+
+ '若解释器已装好只是不在 PATH 上,也要靠这个配置项指过去。</p>'
|
|
420
|
+
+ '<p style="color:#8b949e">配置后把本插件<b>停用再启用</b>即会重试,不必重启 dsh web。</p>'
|
|
189
421
|
+ '</body></html>';
|
|
190
422
|
}
|
|
191
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Node 后端入口缺失时的提示页(缺省实现;也可显式配 `backend: node`)。
|
|
426
|
+
* 与「找不到 Python」并列的一类失败:装的是老包(包里还没有 `backend/` 目录)、
|
|
427
|
+
* 或 repoDir 指到了老检出。这条路径**不早退**——照常注册路由,面板因此能看到原因,
|
|
428
|
+
* 而不是一个 404 白页。
|
|
429
|
+
*/
|
|
430
|
+
function renderNoNodeBackendHint(repoDir) {
|
|
431
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
432
|
+
+ '<title>Touchstone 后端未启动</title></head>'
|
|
433
|
+
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
434
|
+
+ 'background:#14161a;color:#e6e6e6">'
|
|
435
|
+
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:Node 后端入口缺失</h2>'
|
|
436
|
+
+ `<p>没找到 <code>${escapeHtml(`${repoDir}/backend/server.js`)}</code>,`
|
|
437
|
+
+ '而本插件走的是 Node 后端(<b>缺省实现</b>,也可显式配 <code>backend: node</code>)。</p>'
|
|
438
|
+
+ '<p>两种常见原因:① 装的是不含 Node 后端的旧包 —— 重跑 <code>dsh-plugin/install.sh</code>;'
|
|
439
|
+
+ '② profile patch 里的 <code>repoDir</code> 指向了另一个检出 —— 改成含 '
|
|
440
|
+
+ '<code>backend/server.js</code> 的那个目录,或删掉该项用缺省(包自身目录)。</p>'
|
|
441
|
+
+ '<p style="color:#8b949e">改完把本插件<b>停用再启用</b>即会重试,不必重启 dsh web。'
|
|
442
|
+
+ '要退回 Python 后端:把 <code>backend</code> <b>显式写成</b> <code>python</code>'
|
|
443
|
+
+ '(包里的 <code>server.py</code> 仍在,Python 版未退役;注意<b>删掉该键现在等于 node</b>,'
|
|
444
|
+
+ '不能靠删键回退)。</p>'
|
|
445
|
+
+ '</body></html>';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 宿主 Node 能力不足时的提示页(M8 新增)。为什么需要:缺省翻成 node 之后,
|
|
450
|
+
* 「宿主 Node 太老」从「可选路径的边角情况」变成**默认路径上的头号失败**——
|
|
451
|
+
* `node:sqlite` 在 22.5 才有、22.19 之前要显式 `--experimental-sqlite`,更老的 Node 直接没有这个 builtin。
|
|
452
|
+
* 那时后端会在 import 期就退出,面板只会显示 503 JSON 或一段日志尾巴,用户不知道该升级什么、
|
|
453
|
+
* 也不知道其实**有一条一行配置的退路**。
|
|
454
|
+
*/
|
|
455
|
+
function renderNodeRuntimeHint({ execPath, version, detail, logPath }) {
|
|
456
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
457
|
+
+ '<title>Touchstone 后端未启动</title></head>'
|
|
458
|
+
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
459
|
+
+ 'background:#14161a;color:#e6e6e6">'
|
|
460
|
+
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:宿主 Node 不满足要求</h2>'
|
|
461
|
+
+ `<p>当前运行 dsh 的 Node 是 <code>${escapeHtml(String(version))}</code>`
|
|
462
|
+
+ `(<code>${escapeHtml(String(execPath))}</code>),探测 <code>node:sqlite</code> 未通过:</p>`
|
|
463
|
+
+ `<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;padding:.8rem 1rem;`
|
|
464
|
+
+ `overflow:auto">${escapeHtml(String(detail || ''))}</pre>`
|
|
465
|
+
+ '<p>Node 后端要求 <b>Node >= 22.19</b>(该版本起 <code>node:sqlite</code> 免 flag 可用;'
|
|
466
|
+
+ '22.5–22.18 需要 <code>--experimental-sqlite</code>,更老的版本没有这个内建模块)。'
|
|
467
|
+
+ '两条出路任选一条:</p>'
|
|
468
|
+
+ '<p>① <b>升级 Node 到 22.19+</b>,重启 dsh web(后端用的是宿主那个 Node,'
|
|
469
|
+
+ '与 PATH 上的 <code>node</code> 无关);<br>'
|
|
470
|
+
+ '② <b>改回 Python 后端</b>:在 profile patch 的 <code>touchstone-dsh</code> 条目里'
|
|
471
|
+
+ ' <b>显式</b>写 <code>backend: python</code>(注意<b>删掉该键等于 node</b>),'
|
|
472
|
+
+ '然后停用再启用本插件 —— Python 版随包分发、未退役。</p>'
|
|
473
|
+
+ '<p style="color:#8b949e">后端日志:<code>'
|
|
474
|
+
+ `${escapeHtml(logPath || '<库目录>/plugin-backend.log')}</code></p>`
|
|
475
|
+
+ '</body></html>';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* `backend: node` 且进程**起来就崩**时的提示页(M0 评审 I3)。
|
|
480
|
+
* 为什么需要:「入口缺失」只是新开关失败的一种;更常见的是入口在、跑不起来
|
|
481
|
+
* (宿主 Node 版本不认 `--disable-warning`、`dist-plugin` 缺失、被执行策略拦、脚本自身报错)。
|
|
482
|
+
* 那时 `backendPort` 一直是 0,proxy 只会回通用 503 JSON —— 面板里显示一段 JSON 原文,
|
|
483
|
+
* 用户既不知道发生了什么、也不知道去哪看。提示页给三件事:失败原因、日志路径、怎么回退。
|
|
484
|
+
*/
|
|
485
|
+
function renderBackendCrashHint({ entryPath, logPath, code, signal }) {
|
|
486
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
487
|
+
+ '<title>Touchstone 后端未启动</title></head>'
|
|
488
|
+
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
489
|
+
+ 'background:#14161a;color:#e6e6e6">'
|
|
490
|
+
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:Node 后端进程退出</h2>'
|
|
491
|
+
+ `<p>入口 <code>${escapeHtml(entryPath)}</code> 起来了但没有报出监听端口就退出`
|
|
492
|
+
+ `(code=${escapeHtml(String(code))}${signal ? `, signal=${escapeHtml(String(signal))}` : ''})。</p>`
|
|
493
|
+
+ '<p>下一步:看后端日志里的最后几行(启动失败原因就在那里)——</p>'
|
|
494
|
+
+ `<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;padding:.8rem 1rem;`
|
|
495
|
+
+ `overflow:auto">tail -n 40 ${escapeHtml(logPath || '<库目录>/plugin-backend.log')}</pre>`
|
|
496
|
+
+ '<p>常见原因:<code>dist-plugin</code> 构建产物缺失、被执行策略拦下、入口脚本自身报错。'
|
|
497
|
+
+ '若日志里是 <code>node:sqlite</code> / <code>ERR_UNKNOWN_BUILTIN_MODULE</code> 一类,'
|
|
498
|
+
+ '那是宿主 Node 太老 —— 升级到 22.19+ 或改回 <code>backend: python</code>。'
|
|
499
|
+
+ '改完把本插件<b>停用再启用</b>即会重试。</p>'
|
|
500
|
+
+ '<p style="color:#8b949e">要退回 Python 后端:把 profile 里的 <code>backend</code> '
|
|
501
|
+
+ '<b>显式写成</b> <code>python</code>(Python 版随包分发、未退役;'
|
|
502
|
+
+ '注意<b>删掉该键现在等于 node</b>)。</p>'
|
|
503
|
+
+ '</body></html>';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 插件形态后端日志路径:与子进程用的**同一个库**同目录(`<库目录>/plugin-backend.log`)。
|
|
508
|
+
* 不落 node_modules 下的 .run:装机目录可能只读、重装即清,而且日志跟着库走才对得上
|
|
509
|
+
* 首启口令与运行审计。库路径优先级与 server.py 侧一致:extraEnv(profile 显式配置)
|
|
510
|
+
* > 宿主环境 > 缺省 `~/.touchstone/touchstone.db`。
|
|
511
|
+
*/
|
|
512
|
+
function backendLogPath(extraEnv) {
|
|
513
|
+
const dbPath = resolve(extraEnv.TOUCHSTONE_DB || process.env.TOUCHSTONE_DB
|
|
514
|
+
|| join(homedir(), '.touchstone', 'touchstone.db'));
|
|
515
|
+
return join(dirname(dbPath), BACKEND_LOG_NAME);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 打开后端日志追加流(必要时先把超限的旧日志轮转成 `.1`)。任何失败都只 warn 并返回
|
|
520
|
+
* null —— 日志不可用**绝不阻断后端启动**,调用方退化为只走 dsh 日志。
|
|
521
|
+
*/
|
|
522
|
+
function openBackendLog(logPath, logger) {
|
|
523
|
+
try {
|
|
524
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
|
|
525
|
+
if (existsSync(logPath) && statSync(logPath).size > BACKEND_LOG_MAX_BYTES) {
|
|
526
|
+
try {
|
|
527
|
+
rmSync(logPath + '.1', { force: true }); // Windows 不允许改名覆盖已存在文件
|
|
528
|
+
renameSync(logPath, logPath + '.1');
|
|
529
|
+
} catch { /* 轮转失败照样追加:不因轮转挡启动 */ }
|
|
530
|
+
}
|
|
531
|
+
const stream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
532
|
+
let broken = false;
|
|
533
|
+
stream.on('error', (error) => {
|
|
534
|
+
if (broken) return; // 只报一次,避免刷屏
|
|
535
|
+
broken = true;
|
|
536
|
+
logger.warn(`touchstone: 后端日志写入失败(${error && error.message}), 后续只走 dsh 日志`);
|
|
537
|
+
});
|
|
538
|
+
return stream;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
logger.warn(`touchstone: 后端日志不可用(${error && error.message}), 输出只走 dsh 日志: ${logPath}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 行缓冲转发子进程输出。为什么不直接 `split('\n')`:data 事件按 chunk 到达,**行可能
|
|
547
|
+
* 被切开**——`TOUCHSTONE_LISTEN` 被切成两段就永远匹配不上,端口解析不到、面板固定 503
|
|
548
|
+
* (2026-10-08 缺陷定位时一并修)。每个流各持一段 carry,拼齐整行才交给 onLine。
|
|
549
|
+
*/
|
|
550
|
+
function pumpLines(stream, onLine) {
|
|
551
|
+
let carry = '';
|
|
552
|
+
stream.setEncoding('utf8'); // 内部 StringDecoder:多字节字符跨 chunk 不截断乱码
|
|
553
|
+
stream.on('data', (chunk) => {
|
|
554
|
+
carry += chunk;
|
|
555
|
+
const lines = carry.split('\n');
|
|
556
|
+
carry = lines.pop(); // 末段不完整,留到下一 chunk
|
|
557
|
+
for (const line of lines) onLine(line);
|
|
558
|
+
});
|
|
559
|
+
stream.on('end', () => { if (carry) onLine(carry); });
|
|
560
|
+
}
|
|
561
|
+
|
|
192
562
|
/**
|
|
193
563
|
* 反代 /touchstone/* 到本机 server.py 子进程: 剥掉 /touchstone 前缀后原样转发
|
|
194
564
|
* (路径/查询串原样; Node http 管道流式转发不缓冲, SSE 事件实时透传)。
|
|
@@ -272,7 +642,8 @@ function stopChild(proc, logger, why) {
|
|
|
272
642
|
/**
|
|
273
643
|
* cordis 插件入口。config 来自 profile patch insert 条目(install.sh 写入):
|
|
274
644
|
* repoDir Touchstone 平台根(可选; 缺省 = 包自身目录, 因为包是自包含的)
|
|
275
|
-
* pythonPath Python
|
|
645
|
+
* pythonPath Python 解释器(可选; 缺省按 `python` → `python3` 顺序真跑一次探一个,
|
|
646
|
+
* 本机该指含 zstandard 的 conda env 或已 pip 装过依赖的那支)
|
|
276
647
|
* extraEnv 透传子进程的额外环境变量(如 TOUCHSTONE_DB 指向隔离实例库)
|
|
277
648
|
* 返回组合 disposer(注销路由 + 断开驱动 + SIGTERM 子进程),dsh 停用插件时调用;
|
|
278
649
|
* 另有「启用即回收上一份壳」与「掉线自检」两条自愈路径(见文件头注释)。
|
|
@@ -282,9 +653,16 @@ export async function apply(ctx, config = {}) {
|
|
|
282
653
|
// repoDir 缺省 = 包自身目录(自包含包: 包里就有 server.py 与 webui/dist-plugin), 用户零配置即可跑;
|
|
283
654
|
// 显式 config.repoDir 仍优先 —— 要指向开发中的工作副本、或包被拆开放置时才需要写。
|
|
284
655
|
const repoDir = config.repoDir || PACKAGE_DIR;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
656
|
+
const backend = resolveBackend(config, logger);
|
|
657
|
+
const entryRel = backend === BACKEND_NODE ? NODE_ENTRY_REL : 'server.py';
|
|
658
|
+
const entryPath = join(repoDir, entryRel);
|
|
659
|
+
const entryMissing = !existsSync(entryPath);
|
|
660
|
+
if (entryMissing) {
|
|
661
|
+
logger.warn(`touchstone: 找不到 ${entryPath}(repoDir 配置有误, 或安装包不完整)`);
|
|
662
|
+
// Python 侧行为与改动前一致:不注册路由、直接退出(存量用户不受影响)。
|
|
663
|
+
// Node 侧改为继续往下走:注册路由 + backendHint,让新开关的失败在面板里自解释。
|
|
664
|
+
if (backend !== BACKEND_NODE) return;
|
|
665
|
+
backendHint = renderNoNodeBackendHint(repoDir);
|
|
288
666
|
}
|
|
289
667
|
// ① 启用即回收:上一份壳若没走到 disposer(热重载换下不 dispose),它的路由与
|
|
290
668
|
// server.py 子进程都还在。必须先回收干净再注册,否则本实例 register 抛
|
|
@@ -315,8 +693,9 @@ export async function apply(ctx, config = {}) {
|
|
|
315
693
|
const driverUrl = webServer && webServer.port
|
|
316
694
|
? `http://127.0.0.1:${webServer.port}/touchstone-agent` : '';
|
|
317
695
|
|
|
696
|
+
// 两个后端共用的参数尾巴(方案 §3.2 的进程契约):只差入口路径与 node 的进程选项,
|
|
697
|
+
// 因此从入口之后的部分**逐字相同** —— 薄壳不需要为哪个后端分叉。
|
|
318
698
|
const args = [
|
|
319
|
-
`${repoDir}/server.py`,
|
|
320
699
|
'--host', '127.0.0.1', // 只绑回环: 信任头等价 admin, 不得对外网开放
|
|
321
700
|
'--port', '0', // 随机端口, 按 stdout 的 TOUCHSTONE_LISTEN 行解析
|
|
322
701
|
'--web-dir', `${repoDir}/webui/dist-plugin`, // 插件版前端(base=/touchstone/)
|
|
@@ -324,6 +703,14 @@ export async function apply(ctx, config = {}) {
|
|
|
324
703
|
'--parent-watch', // 父死感知: dsh 退出/崩溃时后端自主退出
|
|
325
704
|
];
|
|
326
705
|
const extraEnv = { ...(config.extraEnv || {}) };
|
|
706
|
+
// 插件形态默认「空口令 admin」(2026-10-08 用户口径):面板本来就是免登 admin,
|
|
707
|
+
// 而随机一次性口令只印在启动横幅里、用户拿不到 —— 首装必被「强制改密门」锁死
|
|
708
|
+
// (横幅+门+薄壳丢 stdout 三者叠加,见 bug_report/20261008_1856)。这里下发开关:
|
|
709
|
+
// db.seed_admin 种子时存空口令、不置 must_change_pw;用户在设置页设了密码就按设置的来。
|
|
710
|
+
// 部署方显式配了 TS_ADMIN_PASSWORD 时无需让位(db 侧以该变量优先)。
|
|
711
|
+
if (!('TS_ADMIN_PASSWORDLESS' in extraEnv)) {
|
|
712
|
+
extraEnv.TS_ADMIN_PASSWORDLESS = '1';
|
|
713
|
+
}
|
|
327
714
|
if (driverUrl) {
|
|
328
715
|
// 驱动契约经环境变量下发(不写配置文件): 重启 server.py 即重新握手, 无陈旧状态
|
|
329
716
|
extraEnv.TS_AGENT_DRIVER_URL = driverUrl;
|
|
@@ -333,41 +720,125 @@ export async function apply(ctx, config = {}) {
|
|
|
333
720
|
// 依赖预检(2026-10-07): 缺硬依赖时**不 spawn** —— spawn 了也必然在导入期退出,
|
|
334
721
|
// 日志里只留一行 traceback。改为把「缺什么 + 一条可复制的修复命令」同时送进
|
|
335
722
|
// 日志与面板(backendHint 由 proxy 渲染), 让 npm 安装真正做到「装完就知道缺什么」。
|
|
336
|
-
|
|
337
|
-
|
|
723
|
+
// 解释器(2026-10-11 改): 配置优先; 没配就按 `python` → `python3` 真探一次
|
|
724
|
+
// (Windows 上没有 python3 这个名字, 写死它会直接 ENOENT——那样连缺依赖都报不出来)。
|
|
725
|
+
// 探测用「与随后 spawn 后端同一份 env」, extraEnv 里改过 PATH 时两者才一致。
|
|
726
|
+
//
|
|
727
|
+
// 这一段按 backend 分叉**只到「决定用什么命令拉起」为止**:探测/预检属 Python 专有,
|
|
728
|
+
// 之后 spawn、日志落盘、端口解析、退出处理全部共用(见下面的 launch 分支)。
|
|
729
|
+
const childEnv = { ...process.env, ...extraEnv };
|
|
338
730
|
let myChild = null;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
731
|
+
/** 本次壳的开的后端日志流(teardown 关闭;null = 不可用/未启动) */
|
|
732
|
+
let myLog = null;
|
|
733
|
+
/** 待拉起的命令(null = 本次不拉后端:入口缺失/缺依赖/缺解释器,三种都有提示页) */
|
|
734
|
+
let launch = null;
|
|
735
|
+
if (backend === BACKEND_NODE) {
|
|
736
|
+
// Node 后端:不探测解释器、不跑依赖预检、不会有缺依赖提示页。
|
|
737
|
+
// 用 process.execPath(= 当前 dsh 宿主跑的那个 node)而不是 PATH 上的 `node`:
|
|
738
|
+
// 用户机器上可能根本没有 node 在 PATH 里(dsh 自己是打包运行的),
|
|
739
|
+
// 而且这样能保证「探测的 Node」与「拉起后端的 Node」是同一个二进制(同一个 runtime)。
|
|
740
|
+
if (!entryMissing) {
|
|
741
|
+
// 能力探测(M8):探不到的常见形态是 import 期就退出的后端 —— 那时日志里只有一行
|
|
742
|
+
// `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`,用户无从下手。
|
|
743
|
+
// 探测本身不确定(超时/无法 spawn)**不阻断启动**,见 probeNodeRuntime。
|
|
744
|
+
const probe = nodeProbeForTest
|
|
745
|
+
? await nodeProbeForTest()
|
|
746
|
+
: await probeNodeRuntime(process.execPath, NODE_EXTRA_FLAGS, childEnv);
|
|
747
|
+
const logPath = backendLogPath(extraEnv);
|
|
748
|
+
if (probe.ok) {
|
|
749
|
+
backendHint = '';
|
|
750
|
+
// 观测期(Gate 1)要靠这行判「这次到底跑的是哪一版、跑在什么 Node 上」。
|
|
751
|
+
logger.info(`touchstone: 宿主 Node ${process.versions.node} 能力探测通过(${probe.detail})`);
|
|
752
|
+
launch = { cmd: process.execPath, argv: [...NODE_EXTRA_FLAGS, entryPath, ...args] };
|
|
753
|
+
} else if (probe.skipped) {
|
|
754
|
+
backendHint = '';
|
|
755
|
+
logger.warn(`touchstone: 宿主 Node 能力探测未完成(${probe.detail}), 照常启动后端`);
|
|
756
|
+
launch = { cmd: process.execPath, argv: [...NODE_EXTRA_FLAGS, entryPath, ...args] };
|
|
757
|
+
} else {
|
|
758
|
+
backendHint = renderNodeRuntimeHint({
|
|
759
|
+
execPath: process.execPath, version: process.versions.node, detail: probe.detail, logPath,
|
|
760
|
+
});
|
|
761
|
+
logger.warn(`touchstone: 后端未启动 —— 宿主 Node ${process.versions.node} 不满足要求: ${probe.detail}`);
|
|
762
|
+
logger.warn('touchstone: 两条出路: ① 升级 Node 到 >= 22.19 后重启 dsh web; '
|
|
763
|
+
+ '② 在本插件 config 里显式写 backend: python(Python 版随包分发、未退役)后停用再启用');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
344
766
|
} else {
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
767
|
+
const pythonPath = config.pythonPath || await resolveDefaultPython(childEnv);
|
|
768
|
+
if (!pythonPath) {
|
|
769
|
+
backendHint = renderNoPythonHint();
|
|
770
|
+
logger.warn(`touchstone: 找不到 Python 解释器(依次试过 ${PYTHON_CANDIDATES.join(', ')}), 插件不启动后端`);
|
|
771
|
+
logger.warn('touchstone: 请安装 Python 3.12+ 或在本插件 config 里配 pythonPath, 然后停用再启用');
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const viaDefault = !config.pythonPath;
|
|
775
|
+
if (viaDefault) logger.info(`touchstone: 未配置 pythonPath, 缺省探到解释器: ${pythonPath}`);
|
|
776
|
+
const deps = await preflightDeps(pythonPath, repoDir, logger, childEnv);
|
|
777
|
+
if (deps.fatal.length) {
|
|
778
|
+
backendHint = renderDepHint(pythonPath, repoDir, deps.fatal, viaDefault);
|
|
779
|
+
logger.warn(`touchstone: 后端未启动 —— Python 运行依赖缺失: ${deps.fatal.join(', ')}`);
|
|
780
|
+
logger.warn(`touchstone: 修复: ${pythonPath} -m pip install -r ${join(repoDir, 'requirements.txt')}`);
|
|
781
|
+
logger.warn('touchstone: 装完把本插件停用再启用即会重试(不必重启 dsh web)');
|
|
782
|
+
} else {
|
|
783
|
+
backendHint = '';
|
|
784
|
+
if (deps.advisory.length) {
|
|
785
|
+
logger.warn(`touchstone: 可选依赖未安装(只影响对应功能, 不影响启动): ${deps.advisory.join(', ')}`);
|
|
786
|
+
}
|
|
787
|
+
launch = { cmd: pythonPath, argv: [entryPath, ...args] };
|
|
348
788
|
}
|
|
349
|
-
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (launch) {
|
|
792
|
+
logger.info(`touchstone: 启动后端(${backend}) ${launch.cmd} ${launch.argv.join(' ')}`);
|
|
350
793
|
// stdio[0] 必须是 pipe 且**永不写入**:这是父死感知的 A 通道——dsh 进程无论
|
|
351
|
-
// 优雅退出还是被 kill -9,OS
|
|
352
|
-
//
|
|
353
|
-
|
|
794
|
+
// 优雅退出还是被 kill -9,OS 都会关闭该管道写端,后端读到 EOF 即自主退出
|
|
795
|
+
// (Python 侧实测父 kill -9 后 1.94s 退出;Node 侧走 process.stdin 的 end/close)。
|
|
796
|
+
// 改成 'ignore' 会让该通道失效。
|
|
797
|
+
myChild = spawn(launch.cmd, launch.argv, {
|
|
354
798
|
cwd: repoDir,
|
|
355
|
-
env:
|
|
799
|
+
env: childEnv,
|
|
356
800
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
357
801
|
});
|
|
358
802
|
child = myChild;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
803
|
+
// 子进程输出落盘(2026-10-08):stdout 此前只用来解析 marker,**其余行全部丢弃**——
|
|
804
|
+
// 首启一次性口令横幅(设计上唯一出口,见 server._seed_hint)与 [board]/[waitq]/[runner]
|
|
805
|
+
// 运行诊断都在这些行里。现在 stdout/stderr 都逐行追加到 <库目录>/plugin-backend.log。
|
|
806
|
+
myLog = openBackendLog(backendLogPath(extraEnv), logger);
|
|
807
|
+
const writeLog = (line) => {
|
|
808
|
+
if (!myLog) return;
|
|
809
|
+
try { myLog.write(line + '\n'); } catch { /* 流已坏:忽略,绝不打断读取 */ }
|
|
810
|
+
};
|
|
811
|
+
let sawListen = false; // 是否报出过监听端口(判「起来就崩」用,见 renderBackendCrashHint)
|
|
812
|
+
pumpLines(myChild.stdout, (line) => {
|
|
813
|
+
writeLog(line);
|
|
814
|
+
const m = line.match(/^TOUCHSTONE_LISTEN (\d+)/);
|
|
815
|
+
if (m) {
|
|
816
|
+
sawListen = true;
|
|
817
|
+
backendPort = Number(m[1]);
|
|
818
|
+
logger.info(`touchstone: 后端就绪 127.0.0.1:${backendPort}`);
|
|
366
819
|
}
|
|
367
820
|
});
|
|
368
|
-
myChild.stderr
|
|
369
|
-
|
|
370
|
-
|
|
821
|
+
pumpLines(myChild.stderr, (line) => {
|
|
822
|
+
writeLog(line);
|
|
823
|
+
if (line.trim()) logger.warn(`touchstone[${backend === BACKEND_NODE ? 'node' : 'py'}]: ${line.trim()}`);
|
|
824
|
+
});
|
|
825
|
+
// 「起来就崩」不再只留一行 dsh 日志:Node 分支给面板一个可读提示页(Python 分支行为不动)。
|
|
826
|
+
const crashed = (code, sig) => {
|
|
827
|
+
if (sawListen || backend !== BACKEND_NODE) return; // 只在 Node 分支、且从未就绪时给提示页
|
|
828
|
+
backendHint = renderBackendCrashHint({
|
|
829
|
+
entryPath, logPath: backendLogPath(extraEnv), code, signal: sig,
|
|
830
|
+
});
|
|
831
|
+
};
|
|
832
|
+
myChild.on('exit', (code, sig) => {
|
|
833
|
+
writeLog(`[touchstone] 后端退出 code=${code} sig=${sig}`);
|
|
834
|
+
logger.warn(`touchstone: 后端退出 code=${code} sig=${sig}`);
|
|
835
|
+
crashed(code, sig);
|
|
836
|
+
});
|
|
837
|
+
myChild.on('error', (err) => {
|
|
838
|
+
writeLog(`[touchstone] 后端拉起失败: ${err.message}`);
|
|
839
|
+
logger.warn(`touchstone: 后端拉起失败: ${err.message}`);
|
|
840
|
+
crashed('spawn-error', null);
|
|
841
|
+
});
|
|
371
842
|
}
|
|
372
843
|
|
|
373
844
|
let disposeRoute = null;
|
|
@@ -399,6 +870,10 @@ export async function apply(ctx, config = {}) {
|
|
|
399
870
|
logger.warn(`touchstone: 驱动释放失败(忽略): ${error && error.message}`);
|
|
400
871
|
}
|
|
401
872
|
await stopChild(myChild, logger, why);
|
|
873
|
+
if (myLog) { // 子进程停稳后再收日志流:最后几行不丢
|
|
874
|
+
try { myLog.end(); } catch { /* 已关闭:忽略 */ }
|
|
875
|
+
myLog = null;
|
|
876
|
+
}
|
|
402
877
|
logger.info(`touchstone: 壳已停(${why})`);
|
|
403
878
|
};
|
|
404
879
|
|
|
@@ -441,3 +916,18 @@ export async function apply(ctx, config = {}) {
|
|
|
441
916
|
// 注意: agents 服务**不在这里声明**——本插件的反代职责不应因「某 profile 没有 agent loop」
|
|
442
917
|
// 而整包不加载; agent 驱动改用 ctx.inject(['agents'], ...) 延迟注入(见 agent-driver.js)。
|
|
443
918
|
export const inject = ['webServer'];
|
|
919
|
+
|
|
920
|
+
// 自检入口(2026-10-11): `node lib/index.js --resolve-python` 打印缺省解释器解析结果
|
|
921
|
+
// —— 给 scripts/dev-check-python.mjs 用真 PATH 驱动「python → python3」探测顺序的判决,
|
|
922
|
+
// 也方便排障时在用户机器上一条命令看到「本机探到哪支」。命中输出名字、退出码 0;
|
|
923
|
+
// 一个都探不到输出一行错误、退出码 1。被 dsh import 时 process.argv[1] 不是本文件, 不触发。
|
|
924
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
925
|
+
&& process.argv.includes('--resolve-python')) {
|
|
926
|
+
const found = await resolveDefaultPython();
|
|
927
|
+
if (found) {
|
|
928
|
+
console.log(found);
|
|
929
|
+
process.exit(0);
|
|
930
|
+
}
|
|
931
|
+
console.error(`no python interpreter found (tried: ${PYTHON_CANDIDATES.join(', ')})`);
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|