rexp-web 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,20 @@
1
+ export function errorInfo(error) {
2
+ let details = error instanceof Error ? error.message : String(error);
3
+ try {
4
+ const parsed = JSON.parse(details);
5
+ if (typeof parsed.message === "string") details = parsed.message;
6
+ } catch { /* Ordinary non-JSON errors remain valid diagnostic details. */ }
7
+ let state = "error", message = "实验数据暂时无法读取,稍后会自动重试。";
8
+ if (/需要更新以支持按需读取/u.test(details)) {
9
+ message = '此节点的平台需要更新,才能按页读取参数搜索。';
10
+ } else if (/Unknown or disabled plugin: rexp|Missing plugin settings?: [^\n]*(?:platformRoot|rexpPath)/iu.test(details)) {
11
+ state = "unconfigured"; message = "此节点尚未配置实验平台。";
12
+ } else if (/(?:rexp(?:\.exe)?['"\s:].*(?:not recognized|not found|No such file)|(?:term|command).*rexp\.exe.*not recognized)|No module named ['"]?rexp/isu.test(details)) {
13
+ state = "not_installed"; message = "此节点的实验平台尚未安装完成,安装后会自动检测。";
14
+ } else if (/Platform not initialized|No platform root configured|未初始化/iu.test(details)) {
15
+ state = "not_initialized"; message = "此节点的实验平台尚未初始化,请确认数据根目录。";
16
+ } else if (/Connection closed|timed? out|ECONNRESET|ECONNREFUSED/iu.test(details)) {
17
+ message = "暂时无法连接此节点,正在等待恢复。";
18
+ }
19
+ return { state, message, details: details.slice(0, 6000) };
20
+ }
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { errorInfo } from "./errors.mjs";
4
+
5
+ const alertStatuses = new Set(["COMPLETED", "FAILED", "RECOVERABLE"]);
6
+ const maxEvents = 500;
7
+ const maxEventAge = 24 * 60 * 60 * 1000;
8
+
9
+ export class RunMonitor {
10
+ constructor({ listServers, readRuns, stateFile, intervalMs = 30_000 }) {
11
+ this.listServers = listServers;
12
+ this.readRuns = readRuns;
13
+ this.stateFile = stateFile;
14
+ this.intervalMs = intervalMs;
15
+ this.observed = {};
16
+ this.snapshots = new Map();
17
+ this.events = [];
18
+ this.sequence = 0;
19
+ this.listeners = new Set();
20
+ this.running = false;
21
+ this.stopped = true;
22
+ this.generation = 0;
23
+ this.lastCheckedAt = null;
24
+ this.lastError = null;
25
+ this.nodeErrors = {};
26
+ this.load();
27
+ }
28
+
29
+ load() {
30
+ try {
31
+ const saved = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
32
+ if (saved.version !== 1 || !saved.observed || typeof saved.observed !== "object") return;
33
+ this.observed = saved.observed;
34
+ this.sequence = Number.isSafeInteger(saved.sequence) ? saved.sequence : 0;
35
+ this.events = Array.isArray(saved.events) ? saved.events.filter((item) => item && Number.isSafeInteger(item.id) && Date.now() - Date.parse(item.at) < maxEventAge).slice(-maxEvents) : [];
36
+ } catch { /* An absent or invalid cache starts with a fresh baseline. */ }
37
+ }
38
+
39
+ save() {
40
+ fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 });
41
+ const temp = `${this.stateFile}.${process.pid}.tmp`;
42
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, observed: this.observed, sequence: this.sequence, events: this.events }), { mode: 0o600 });
43
+ fs.renameSync(temp, this.stateFile);
44
+ }
45
+
46
+ async tick() {
47
+ if (this.running || this.stopped) return;
48
+ const generation = this.generation;
49
+ clearTimeout(this.timer);
50
+ this.running = true;
51
+ this.lastError = null;
52
+ try {
53
+ const servers = await this.listServers();
54
+ const aliases = new Set(servers.map(server => server.alias));
55
+ for (const alias of Object.keys(this.nodeErrors)) if (!aliases.has(alias)) delete this.nodeErrors[alias];
56
+ for (const server of servers) {
57
+ if (this.stopped || generation !== this.generation) break;
58
+ if (server.state === "offline") continue;
59
+ try {
60
+ const snapshot = await this.readRuns(server.alias);
61
+ if (this.stopped || generation !== this.generation) break;
62
+ delete this.nodeErrors[server.alias];
63
+ this.snapshots.set(server.alias, snapshot);
64
+ if (!snapshot.available) {
65
+ delete this.observed[server.alias];
66
+ for (const listener of this.listeners) listener({ type: "runs", alias: server.alias, snapshot });
67
+ continue;
68
+ }
69
+ const previous = this.observed[server.alias];
70
+ const current = Object.fromEntries(snapshot.runs.map((run) => [run.id, run.status]));
71
+ this.observed[server.alias] = current;
72
+ this.snapshots.set(server.alias, snapshot);
73
+ if (previous) {
74
+ for (const run of snapshot.runs) {
75
+ if (!alertStatuses.has(run.status) || previous[run.id] === run.status) continue;
76
+ const event = {
77
+ id: ++this.sequence, at: new Date().toISOString(), alias: server.alias,
78
+ runId: run.id, project: run.project, version: run.version,
79
+ experiment: run.experiment, description: run.description?.trim() || undefined,
80
+ status: run.status, claimed: false,
81
+ };
82
+ this.events.push(event);
83
+ for (const listener of this.listeners) listener({ type: "transition", event });
84
+ }
85
+ }
86
+ for (const listener of this.listeners) listener({ type: "runs", alias: server.alias, snapshot });
87
+ this.events = this.events.filter((item) => Date.now() - Date.parse(item.at) < maxEventAge).slice(-maxEvents);
88
+ this.save();
89
+ } catch (error) {
90
+ this.nodeErrors[server.alias] = errorInfo(error);
91
+ }
92
+ }
93
+ this.lastCheckedAt = new Date().toISOString();
94
+ } catch (error) {
95
+ this.lastError = "无法更新节点列表,监测将自动重试。";
96
+ } finally {
97
+ this.running = false;
98
+ if (generation !== this.generation) {
99
+ if (!this.stopped) void this.tick();
100
+ return;
101
+ }
102
+ for (const listener of this.listeners) listener({ type: "monitor-status", status: this.status() });
103
+ if (!this.stopped) this.timer = setTimeout(() => { void this.tick(); }, this.intervalMs);
104
+ }
105
+ }
106
+
107
+ start() {
108
+ if (!this.stopped) return;
109
+ this.generation++;
110
+ this.stopped = false;
111
+ this.observed = {};
112
+ this.snapshots.clear();
113
+ this.lastCheckedAt = null;
114
+ this.lastError = null;
115
+ this.nodeErrors = {};
116
+ return this.tick();
117
+ }
118
+
119
+ stop() {
120
+ this.generation++;
121
+ this.stopped = true;
122
+ clearTimeout(this.timer);
123
+ this.snapshots.clear();
124
+ this.listeners.clear();
125
+ }
126
+
127
+ snapshot(alias) { return this.snapshots.get(alias); }
128
+ cursor() { return this.sequence; }
129
+ eventsAfter(cursor) { return this.events.filter((event) => event.id > cursor); }
130
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
131
+ status() { return { monitoring: !this.stopped, intervalSeconds: this.intervalMs / 1000, lastCheckedAt: this.lastCheckedAt, lastError: this.lastError, nodeErrors: this.nodeErrors, cursor: this.sequence }; }
132
+
133
+ claim(id) {
134
+ const event = this.events.find((item) => item.id === id);
135
+ if (!event || event.claimed) return false;
136
+ event.claimed = true;
137
+ this.save();
138
+ return true;
139
+ }
140
+ }
@@ -0,0 +1,38 @@
1
+ // Discover configured nodes without waiting for any SSH or platform probe.
2
+ export function createServerDirectory({ invoke, now = Date.now, cacheMs = 1000, probeIntervalMs = 30_000 }) {
3
+ let snapshot;
4
+ let pending;
5
+ let probing;
6
+ let lastProbe = -Infinity;
7
+
8
+ function refreshStatus() {
9
+ if (probing || now() - lastProbe < probeIntervalMs) return;
10
+ lastProbe = now();
11
+ probing = Promise.resolve().then(() => invoke('list-servers', { refresh: true }, 45_000))
12
+ .then(() => { snapshot = undefined; })
13
+ .catch(() => { /* Node discovery still works when one status probe fails. */ })
14
+ .finally(() => { probing = undefined; });
15
+ }
16
+
17
+ return {
18
+ async list(force = false) {
19
+ if (!force && snapshot && now() - snapshot.at < cacheMs) return snapshot.items;
20
+ if (pending) return pending;
21
+ pending = (async () => {
22
+ const items = JSON.parse(await invoke('list-servers', { verbose: true }, 10_000));
23
+ if (!Array.isArray(items)) throw new Error('SSH-MCP 未返回服务器列表');
24
+ snapshot = { at: now(), items };
25
+ refreshStatus();
26
+ return items;
27
+ })();
28
+ try { return await pending; }
29
+ finally { pending = undefined; }
30
+ },
31
+ clear() { snapshot = undefined; },
32
+ };
33
+ }
34
+
35
+ export function serverState(item, busy) {
36
+ if (item.connected) return busy ? 'busy' : 'online';
37
+ return item.status?.reachable === false ? 'offline' : 'checking';
38
+ }
@@ -0,0 +1,21 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ export function webPaths(home = process.env.REXP_WEB_HOME || os.homedir()) {
6
+ const configDir = path.join(home, '.config', 'rexp-hub');
7
+ const stateDir = path.join(home, '.cache', 'rexp-hub', 'web');
8
+ return {home, configDir, settingsFile:path.join(configDir,'web.json'), stateDir,
9
+ pidFile:path.join(stateDir,'service.json'), logFile:path.join(stateDir,'service.log')};
10
+ }
11
+ export function readWebSettings(paths = webPaths()) {
12
+ try {
13
+ const value = JSON.parse(fs.readFileSync(paths.settingsFile,'utf8'));
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object');
15
+ return value;
16
+ } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
17
+ }
18
+ export function saveWebSettings(changes, paths = webPaths()) {
19
+ fs.mkdirSync(paths.configDir,{recursive:true,mode:0o700});
20
+ fs.writeFileSync(paths.settingsFile,JSON.stringify({...readWebSettings(paths),...changes},null,2)+'\n',{mode:0o600});
21
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { closeBridge, handleApi } from "./bridge.mjs";
6
+ import {webPaths} from "./web-paths.mjs";
7
+
8
+ const projectDir = fileURLToPath(new URL("../", import.meta.url));
9
+ const distDir = path.join(projectDir, "dist");
10
+ const {stateDir,pidFile} = webPaths();
11
+ const port = Number(process.env.REXP_WEB_PORT || 5173);
12
+ const host = "127.0.0.1";
13
+ const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2" };
14
+
15
+ function serveFile(req, res) {
16
+ let pathname;
17
+ try { pathname = decodeURIComponent(new URL(req.url, `http://${host}`).pathname); }
18
+ catch { res.writeHead(400).end(); return; }
19
+ if (pathname.includes("\0") || pathname.split("/").includes("..")) { res.writeHead(400).end(); return; }
20
+ const target = path.resolve(distDir, `.${pathname}`);
21
+ if (target !== distDir && !target.startsWith(`${distDir}${path.sep}`)) { res.writeHead(403).end(); return; }
22
+ const file = fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(distDir, "index.html");
23
+ if (!fs.existsSync(file)) { res.writeHead(503).end("Web 页面尚未构建"); return; }
24
+ res.writeHead(200, { "Content-Type": mime[path.extname(file)] || "application/octet-stream", "Cache-Control": file.endsWith("index.html") ? "no-cache" : "public, max-age=3600" });
25
+ fs.createReadStream(file).pipe(res);
26
+ }
27
+
28
+ const server = http.createServer((req, res) => {
29
+ if (req.url === "/api/web/health") {
30
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
31
+ res.end(JSON.stringify({ service: "rexp-hub-web", pid: process.pid }));
32
+ return;
33
+ }
34
+ if (req.url?.startsWith("/api/")) { void handleApi(req, res, () => res.writeHead(404).end()); return; }
35
+ if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end(); return; }
36
+ handleApi(req, res, () => serveFile(req, res));
37
+ });
38
+
39
+ let stopping = false;
40
+ async function stop(exitCode = 0) {
41
+ if (stopping) return;
42
+ stopping = true;
43
+ try { await closeBridge(); } catch (error) { console.error(error); exitCode = 1; }
44
+ await Promise.race([
45
+ new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }),
46
+ new Promise((resolve) => setTimeout(resolve, 2000)),
47
+ ]);
48
+ try {
49
+ const state = JSON.parse(fs.readFileSync(pidFile, "utf8"));
50
+ if (state.pid === process.pid) fs.unlinkSync(pidFile);
51
+ } catch { /* A removed state file needs no cleanup. */ }
52
+ process.exit(exitCode);
53
+ }
54
+
55
+ process.on("SIGINT", () => { void stop(); });
56
+ process.on("SIGTERM", () => { void stop(); });
57
+ process.on("uncaughtException", (error) => { console.error(error); void stop(1); });
58
+
59
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("REXP_WEB_PORT 必须是有效端口");
60
+ server.once("error", (error) => { console.error(error); process.exitCode = 1; });
61
+ server.listen(port, host, () => {
62
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
63
+ fs.writeFileSync(pidFile, JSON.stringify({ pid: process.pid, port, projectDir }), { mode: 0o600 });
64
+ console.log(`REXP Hub 已启动:http://${host}:${port}/`);
65
+ });
@@ -0,0 +1,38 @@
1
+ # REXP Web 使用说明
2
+
3
+ REXP Web 在 SSH-MCP 所在的控制端运行。通过 SSH-MCP 的 `plugin-run`,选择
4
+ `plugin: rexp-web`,传入下面的 argv 即可管理;不要传远端 connectionName。
5
+
6
+ ## 首次安装
7
+
8
+ 1. 安装后先询问用户是否开启登录系统后自动启动。安装包本身不会修改系统启动项。
9
+ 用户已明确提供选择或委托时沿用其选择,不重复询问。
10
+ 2. 执行 `configure --autostart on` 或 `configure --autostart off`。
11
+ 该步骤保存宿主提供的 SSH-MCP 程序和个人配置路径,登录启动后仍使用同一份配置。
12
+ 3. 执行 `open`,后台启动并打开浏览器。提醒用户将页面加入书签,下次直接打开。
13
+
14
+ ## 生命周期命令
15
+
16
+ - `status --json`:查看服务地址、运行状态和自启动设置。
17
+ - `start`:后台启动;已运行则复用,不重复启动。不会更改自启动偏好。
18
+ - `open`:确保服务启动,随后打开控制台并提示添加书签。
19
+ - `stop`:明确停止 Web。保留自启动选择,远端实验和 Agent 的 SSH-MCP 不受影响。
20
+ - `autostart status --json`、`autostart on`、`autostart off`:查询或修改登录启动。
21
+ 关闭自启动只影响下次登录,不关闭当前 Web。网页右上角设置也能修改同一个开关。
22
+ - `logs`:读取后台日志。`version --json`:查询版本。
23
+
24
+ 程序安装位置、配置和启动项都属于 MCP 宿主,不属于远端实验服务器。
25
+ 系统启动环境可能没有终端 PATH,因此启动项使用绝对 Node 和程序路径。
26
+ macOS、Windows 与 Linux 桌面支持用户登录时启动,不需要系统管理员权限。
27
+ 无桌面的 Linux 暂不自动安装系统服务,应说明限制,保留手动/MCP 启动方式。
28
+
29
+ ## 使用约定
30
+
31
+ Agent 退出不应停止 Web;只有用户明确要求关闭时才执行 stop。
32
+ 用户未开启自启动时,可随时通过 MCP 的 open 恢复使用。
33
+ 若用户通过 Agent 提交实验并希望网页监测,先查 status;未运行时询问是否启动,
34
+ 拒绝后本次会话不重复询问,也不阻塞实验提交。此约定是 Agent 指引,不是对所有
35
+ REXP 提交命令的自动拦截;不修改远端实验调度器。
36
+ 关闭所有网页后停止相关远端监测,后台服务保留待命。
37
+
38
+ 不要复制 SSH 密码到 Web 项目或启动项,也不要修改远端平台来启停本地 Web。