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,891 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import { npuQuery, parseNpuSnapshot } from "./accelerators.mjs";
8
+ import { RunMonitor } from "./run-monitor.mjs";
9
+ import { errorInfo } from "./errors.mjs";
10
+ import { createServerDirectory, serverState } from "./server-directory.mjs";
11
+ import {autostartStatus,setAutostart} from './autostart.mjs';
12
+ import {webPaths} from './web-paths.mjs';
13
+
14
+ const settingsFile = webPaths().settingsFile;
15
+
16
+ export function mcpSettings() {
17
+ let settings = {};
18
+ try { settings = JSON.parse(fs.readFileSync(settingsFile, "utf8")); }
19
+ catch (error) {
20
+ if (error.code !== "ENOENT") throw new Error(`无法读取 Web 连接配置:${settingsFile}`);
21
+ }
22
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
23
+ throw new Error(`Web 连接配置应为 JSON 对象:${settingsFile}`);
24
+ }
25
+ for (const key of ["mcpDir", "mcpConfig", "mcpPlugins"]) {
26
+ if (settings[key] !== undefined && (typeof settings[key] !== "string" || !path.isAbsolute(settings[key]))) {
27
+ throw new Error(`Web 连接配置的 ${key} 必须是绝对路径:${settingsFile}`);
28
+ }
29
+ }
30
+ const configuredDir = process.env.HANDFREE_MCP_DIR || settings.mcpDir;
31
+ if (!configuredDir) throw new Error('尚未接入 SSH-MCP;请通过 rexp-web 插件完成 configure,或在 Web 个人配置中指定 mcpDir。');
32
+ const mcpDir = path.resolve(configuredDir);
33
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
34
+ const mcpConfig = path.resolve(process.env.HANDFREE_MCP_CONFIG || settings.mcpConfig || path.join(configHome, "handfree-ssh-mcp", "servers.yaml"));
35
+ const mcpPlugins = path.resolve(process.env.HANDFREE_PLUGINS_CONFIG || settings.mcpPlugins || path.join(configHome, "handfree-ssh-mcp", "plugins.yaml"));
36
+ return { mcpDir, mcpConfig, mcpPlugins };
37
+ }
38
+
39
+ const runtimeDir = path.join(os.homedir(), ".cache", "rexp-hub", "ssh-mcp");
40
+ const outputLogDir = path.join(runtimeDir, ".handfree-output");
41
+ let clientPromise;
42
+ const serverDirectory = createServerDirectory({ invoke: (...args) => callTool(...args) });
43
+ const gpuStreams = new Map();
44
+ const acceleratorFacts = new Map();
45
+ const npuSamples = new Map();
46
+ const activeStreamLogs = new Set();
47
+ const runMonitor = new RunMonitor({
48
+ listServers: () => listServers(),
49
+ readRuns: (alias) => runsData(alias),
50
+ stateFile: path.join(os.homedir(), ".cache", "rexp-hub", "web", "run-monitor.json"),
51
+ });
52
+
53
+ function pruneOutputLogs(maxAgeMs = 2000) {
54
+ if (!fs.existsSync(outputLogDir)) return;
55
+ const visit = (dir) => {
56
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
+ const file = path.join(dir, entry.name);
58
+ if (entry.isDirectory()) {
59
+ visit(file);
60
+ if (!fs.readdirSync(file).length) fs.rmdirSync(file);
61
+ } else if (entry.isFile() && entry.name.endsWith(".log") && !activeStreamLogs.has(file)) {
62
+ if (Date.now() - fs.statSync(file).mtimeMs >= maxAgeMs) fs.unlinkSync(file);
63
+ }
64
+ }
65
+ };
66
+ try { visit(outputLogDir); } catch { /* Cache cleanup must not interrupt data reading. */ }
67
+ }
68
+
69
+ export async function closeBridge() {
70
+ runMonitor.stop();
71
+ for (const stream of gpuStreams.values()) {
72
+ stream.closed = true;
73
+ for (const subscriber of stream.subscribers) subscriber.end();
74
+ stream.subscribers.clear();
75
+ }
76
+ gpuStreams.clear();
77
+ const pending = clientPromise;
78
+ clientPromise = undefined;
79
+ serverDirectory.clear();
80
+ acceleratorFacts.clear();
81
+ npuSamples.clear();
82
+ if (pending) {
83
+ try { await (await pending).close(); }
84
+ catch { /* The child may already have exited. */ }
85
+ }
86
+ try { fs.rmSync(outputLogDir, { recursive: true, force: true }); } catch { /* Cache can be cleared later. */ }
87
+ }
88
+
89
+ const bridgeKey = Symbol.for("rexp-hub.ssh-mcp-bridge");
90
+ if (globalThis[bridgeKey]) void globalThis[bridgeKey]();
91
+ globalThis[bridgeKey] = closeBridge;
92
+
93
+ async function mcpClient() {
94
+ if (!clientPromise) {
95
+ clientPromise = (async () => {
96
+ const { mcpDir, mcpConfig, mcpPlugins } = mcpSettings();
97
+ const entry = path.join(mcpDir, "build/index.js");
98
+ if (!fs.existsSync(entry)) throw new Error(`找不到 SSH-MCP 程序:${entry}`);
99
+ fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
100
+ const client = new Client({ name: "rexp-hub", version: "0.1.0" });
101
+ const transport = new StdioClientTransport({
102
+ command: process.execPath,
103
+ args: [entry, ...(fs.existsSync(mcpConfig) ? ["--config", mcpConfig] : []), "--plugins", mcpPlugins],
104
+ cwd: runtimeDir,
105
+ stderr: "pipe",
106
+ });
107
+ let startupError = "";
108
+ transport.stderr?.on("data", (chunk) => {
109
+ const errorLine = chunk.toString().split("\n").find((line) => line.includes("[ERROR]"));
110
+ if (errorLine) startupError = errorLine.slice(0, 1000);
111
+ });
112
+ try { await client.connect(transport); }
113
+ catch (error) {
114
+ await client.close().catch(() => {});
115
+ throw new Error(`SSH-MCP 启动失败:${startupError || error.message}`);
116
+ }
117
+ client.onclose = () => { clientPromise = undefined; serverDirectory.clear(); };
118
+ return client;
119
+ })().catch((error) => { clientPromise = undefined; throw error; });
120
+ }
121
+ return clientPromise;
122
+ }
123
+
124
+ async function callTool(name, args, timeout = 30_000) {
125
+ const client = await mcpClient();
126
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout });
127
+ const body = result.content?.filter((part) => part.type === "text").map((part) => part.text).join("\n") ?? "";
128
+ if (result.isError) throw new Error(body || `${name} 调用失败`);
129
+ if (name !== "command-status") pruneOutputLogs();
130
+ return body;
131
+ }
132
+
133
+ function parseJsonTool(text) {
134
+ try { return JSON.parse(text); }
135
+ catch { throw new Error("SSH-MCP 返回的数据格式无法识别"); }
136
+ }
137
+
138
+ function readCommandOutput(text) {
139
+ if (/\[EXIT CODE: [1-9]\d*\]\s*$/u.test(text)) throw new Error(text.trim());
140
+ if (text.startsWith("[OUTPUT TRUNCATED]")) throw new Error("远端输出被截断");
141
+ return text.trim();
142
+ }
143
+
144
+ function numberOrUndefined(value) {
145
+ const text = String(value ?? "").trim();
146
+ if (!text || text === "—" || /^N\/A$/iu.test(text)) return undefined;
147
+ const n = Number(text);
148
+ return Number.isFinite(n) ? n : undefined;
149
+ }
150
+
151
+ function parseProgress(text) {
152
+ const value = String(text ?? "").trim();
153
+ const fraction = /^(\d+(?:\.\d+)?|—)\s*\/\s*(\d+)$/u.exec(value);
154
+ if (fraction) return { progress: numberOrUndefined(fraction[1]), total: Number(fraction[2]) };
155
+ return { progress: numberOrUndefined(value) };
156
+ }
157
+
158
+ export function parseGpuCsv(text) {
159
+ return text.split(/\r?\n/u).filter(Boolean).map((line) => {
160
+ const cells = line.split(",").map((part) => part.trim());
161
+ if (cells.length < 14) return null;
162
+ const [index, uuid, name, utilization, usedMiB, totalMiB, temperature, fanPercent, powerWatts, powerLimitWatts, gpuClockMHz, memoryClockMHz, pcieGeneration, pcieWidth] = cells;
163
+ const device = numberOrUndefined(index);
164
+ if (device === undefined) return null;
165
+ return {
166
+ index: device, uuid, name,
167
+ utilization: numberOrUndefined(utilization),
168
+ usedMemory: numberOrUndefined(usedMiB) === undefined ? undefined : numberOrUndefined(usedMiB) / 1024,
169
+ totalMemory: numberOrUndefined(totalMiB) === undefined ? undefined : numberOrUndefined(totalMiB) / 1024,
170
+ temperature: numberOrUndefined(temperature), fanPercent: numberOrUndefined(fanPercent),
171
+ powerWatts: numberOrUndefined(powerWatts), powerLimitWatts: numberOrUndefined(powerLimitWatts),
172
+ gpuClockMHz: numberOrUndefined(gpuClockMHz), memoryClockMHz: numberOrUndefined(memoryClockMHz),
173
+ pcieGeneration: numberOrUndefined(pcieGeneration), pcieWidth: numberOrUndefined(pcieWidth),
174
+ };
175
+ }).filter(Boolean);
176
+ }
177
+
178
+ export function parseRunsTable(text) {
179
+ const rows = [];
180
+ let group = "";
181
+ for (const line of text.split(/\r?\n/u)) {
182
+ if (!line.startsWith("│")) continue;
183
+ const cells = line.slice(1, line.endsWith("│") ? -1 : undefined).split("│").map((value) => value.trim());
184
+ if (cells.length !== 5 && cells.length !== 7) continue;
185
+ if (cells[0] === "run_id") continue;
186
+ const [id, a, b, c, d, e, f] = cells;
187
+ if (!id && a.includes("/") && cells.slice(2).every((value) => !value)) {
188
+ group = a;
189
+ continue;
190
+ }
191
+ if (/^run_\d+$/u.test(id)) {
192
+ const wide = cells.length === 7;
193
+ const projectVersion = wide ? a : group;
194
+ const separator = projectVersion.lastIndexOf("/");
195
+ rows.push({
196
+ id,
197
+ project: separator >= 0 ? projectVersion.slice(0, separator) : projectVersion,
198
+ version: separator >= 0 ? projectVersion.slice(separator + 1) : "",
199
+ experiment: wide ? b : a,
200
+ status: wide ? c : b,
201
+ ...parseProgress(wide ? d : c),
202
+ environment: wide ? e : undefined,
203
+ submitted: wide ? f : d,
204
+ });
205
+ continue;
206
+ }
207
+ const last = rows.at(-1);
208
+ if (last && !id) {
209
+ last.experiment += cells.length === 7 ? b : a;
210
+ last.status += cells.length === 7 ? c : b;
211
+ }
212
+ }
213
+ return rows.sort((left, right) => Number(right.id.slice(4)) - Number(left.id.slice(4)));
214
+ }
215
+
216
+ export function parseRunsJson(text) {
217
+ const payload = JSON.parse(text);
218
+ if (payload?.schema_version !== 1 || !Array.isArray(payload.runs)) {
219
+ throw new Error("不支持的平台实验列表格式");
220
+ }
221
+ return payload.runs.map((record) => {
222
+ if (!/^run_\d+$/u.test(record.run_id) || typeof record.status !== "string") {
223
+ throw new Error("平台实验列表包含无效记录");
224
+ }
225
+ return {
226
+ id: record.run_id,
227
+ project: record.project ?? "",
228
+ version: record.version ?? "",
229
+ experiment: record.experiment_name ?? "",
230
+ description: record.description ?? undefined,
231
+ ...(record.research_topic ? { researchTopic: record.research_topic } : {}),
232
+ ...(record.study ? { study: record.study } : {}),
233
+ ...(record.stage ? { stage: record.stage } : {}),
234
+ gpuIds: Array.isArray(record.allocated_gpu_ids) ? record.allocated_gpu_ids : undefined,
235
+ status: record.status,
236
+ progress: numberOrUndefined(record.progress),
237
+ total: numberOrUndefined(record.total_samples),
238
+ ...(record.run_kind ? { kind: record.run_kind, progressUnit: record.progress_unit } : {}),
239
+ environment: record.environment ?? undefined,
240
+ submitted: record.submitted_display || record.submitted_at || "",
241
+ };
242
+ }).sort((left, right) => Number(right.id.slice(4)) - Number(left.id.slice(4)));
243
+ }
244
+
245
+ export function parseRunDetails(text) {
246
+ const fields = {};
247
+ let key;
248
+ for (const rawLine of text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, "").split(/\r?\n/u)) {
249
+ const line = rawLine.trim();
250
+ if (!line) continue;
251
+ const match = /^([a-z][a-z_ ]*):\s*(.*)$/iu.exec(line);
252
+ if (match) {
253
+ key = match[1].trim().toLowerCase().replaceAll(" ", "_");
254
+ fields[key] = match[2];
255
+ } else if (key) {
256
+ fields[key] = `${fields[key]} ${line}`.trim();
257
+ }
258
+ }
259
+ return fields;
260
+ }
261
+
262
+ function parseProcesses(text, gpus) {
263
+ const byUuid = new Map(gpus.map((gpu) => [gpu.uuid, gpu.index]));
264
+ return text.split(/\r?\n/u).filter(Boolean).map((line) => {
265
+ const [uuid, pid, command, memory] = line.split(",").map((part) => part.trim());
266
+ const device = byUuid.get(uuid);
267
+ if (device === undefined || !/^\d+$/u.test(pid)) return null;
268
+ return { device, pid: Number(pid), command, type: "Compute", gpuMemoryMiB: numberOrUndefined(memory) };
269
+ }).filter(Boolean);
270
+ }
271
+
272
+ function enrichProcesses(text, processes) {
273
+ const byPid = new Map(processes.map((item) => [item.pid, item]));
274
+ for (const line of text.split(/\r?\n/u)) {
275
+ const match = /^\s*(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(.+)$/u.exec(line);
276
+ if (!match) continue;
277
+ const process = byPid.get(Number(match[1]));
278
+ if (!process) continue;
279
+ process.user = match[2];
280
+ process.cpuPercent = numberOrUndefined(match[3]);
281
+ process.hostMemoryMiB = Math.round(Number(match[4]) / 1024);
282
+ process.command = match[5];
283
+ }
284
+ return processes;
285
+ }
286
+
287
+ export function isWindows(osName) { return /windows/iu.test(osName || ""); }
288
+ export function gpuSamplingMode(osName) { return /^linux$/iu.test(osName || "") ? "stream" : "poll"; }
289
+
290
+ export function processDetailsCommand(osName, pids) {
291
+ if (!/^\d+(?:,\d+)*$/u.test(pids)) throw new Error("无效的进程编号");
292
+ if (!isWindows(osName)) return `ps -p ${pids} -o pid=,user:32=,pcpu=,rss=,args=`;
293
+ const filter = pids.split(",").map((pid) => `ProcessId=${pid}`).join(" OR ");
294
+ const script = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; try { $items = @(Get-CimInstance Win32_Process -Filter '${filter}' | Select-Object ProcessId,WorkingSetSize,CommandLine); ConvertTo-Json -InputObject $items -Compress; exit 0 } catch { [Console]::Error.WriteLine($_.ToString()); exit 1 }`;
295
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
296
+ }
297
+
298
+ export function enrichWindowsProcesses(text, processes) {
299
+ const payload = JSON.parse(text);
300
+ const rows = new Map((Array.isArray(payload) ? payload : [payload]).filter(Boolean).map((row) => [Number(row.ProcessId), row]));
301
+ return processes.map((process) => {
302
+ const row = rows.get(process.pid);
303
+ if (!row) return process;
304
+ return { ...process, command: row.CommandLine || process.command,
305
+ hostMemoryMiB: row.WorkingSetSize == null ? undefined : Math.round(Number(row.WorkingSetSize) / 1024 / 1024) };
306
+ });
307
+ }
308
+
309
+ async function listServers(force = false) {
310
+ const raw = await serverDirectory.list(force);
311
+ const items = raw.filter((item) => item.enabled).map((item) => {
312
+ const gpus = item.status?.gpus ?? [];
313
+ const busy = gpus.some((gpu) => (numberOrUndefined(String(gpu.usage).replace("%", "")) ?? 0) >= 20);
314
+ return {
315
+ alias: item.name,
316
+ label: item.status?.hostname || item.name,
317
+ state: serverState(item, busy),
318
+ gpuCount: Array.isArray(item.status?.gpus) ? gpus.length : undefined,
319
+ osName: item.status?.osName,
320
+ lastUpdated: item.status?.lastUpdated,
321
+ ...acceleratorFacts.get(item.name),
322
+ };
323
+ });
324
+ return items;
325
+ }
326
+
327
+ async function assertServer(alias) {
328
+ const servers = await listServers();
329
+ const server = servers.find((server) => server.alias === alias)
330
+ ?? (await listServers(true)).find((server) => server.alias === alias);
331
+ if (!server) throw new Error("未知的 SSH 服务器别名");
332
+ return server;
333
+ }
334
+
335
+ const gpuQuery = "nvidia-smi --query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,temperature.gpu,fan.speed,power.draw,power.limit,clocks.current.graphics,clocks.current.memory,pcie.link.gen.current,pcie.link.width.current --format=csv,noheader,nounits";
336
+ const processQuery = "nvidia-smi --query-compute-apps=gpu_uuid,pid,process_name,used_gpu_memory --format=csv,noheader,nounits";
337
+
338
+ function sendEvent(res, name, data) {
339
+ if (!res.writableEnded) res.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
340
+ }
341
+
342
+ function monitorEvent(res, kind, value) {
343
+ if (res.writableEnded) return;
344
+ if (kind === "transition") res.write(`id: ${value.event.id}\nevent: transition\ndata: ${JSON.stringify(value.event)}\n\n`);
345
+ else if (kind === "runs") res.write(`event: runs\ndata: ${JSON.stringify({ alias: value.alias, ...value.snapshot })}\n\n`);
346
+ else if (kind === "monitor-status") res.write(`event: monitor-status\ndata: ${JSON.stringify(value.status)}\n\n`);
347
+ }
348
+
349
+ function subscribeRunEvents(req, res, url) {
350
+ const requested = Number(url.searchParams.get("after") || runMonitor.cursor());
351
+ const lastReceived = Number(req.headers["last-event-id"] || 0);
352
+ const cursor = Math.max(requested, lastReceived);
353
+ res.writeHead(200, {
354
+ "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform",
355
+ "Connection": "keep-alive", "X-Accel-Buffering": "no",
356
+ });
357
+ res.write("retry: 5000\n\n");
358
+ monitorEvent(res, "monitor-status", { status: runMonitor.status() });
359
+ for (const event of runMonitor.eventsAfter(Number.isSafeInteger(cursor) ? cursor : runMonitor.cursor())) monitorEvent(res, "transition", { event });
360
+ const unsubscribe = runMonitor.subscribe((value) => monitorEvent(res, value.type, value));
361
+ runMonitor.start();
362
+ const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(": keep-alive\n\n"); }, 25_000);
363
+ res.on("close", () => {
364
+ clearInterval(heartbeat);
365
+ unsubscribe();
366
+ if (!runMonitor.listeners.size) runMonitor.stop();
367
+ });
368
+ }
369
+
370
+ function publish(stream, name, data) {
371
+ for (const subscriber of stream.subscribers) sendEvent(subscriber, name, data);
372
+ }
373
+
374
+ function acceptStreamChunk(stream, chunk) {
375
+ stream.carry += chunk;
376
+ const lines = stream.carry.split(/\r?\n/u);
377
+ stream.carry = lines.pop() ?? "";
378
+ for (const line of lines) {
379
+ const [gpu] = parseGpuCsv(line);
380
+ if (!gpu) continue;
381
+ if (stream.pending.size && stream.pending.has(gpu.index)) flushStreamSample(stream);
382
+ stream.pending.set(gpu.index, gpu);
383
+ if (stream.expectedCount > 0 && stream.pending.size >= stream.expectedCount) flushStreamSample(stream);
384
+ }
385
+ }
386
+
387
+ function flushStreamSample(stream) {
388
+ if (!stream.pending.size) return;
389
+ const gpus = [...stream.pending.values()].sort((a, b) => a.index - b.index);
390
+ stream.pending.clear();
391
+ publish(stream, "sample", { acceleratorBackend: "cuda", gpus, sampledAt: new Date().toISOString() });
392
+ }
393
+
394
+ async function runGpuStream(stream) {
395
+ try {
396
+ const initial = await gpuData(stream.alias, false);
397
+ publish(stream, "sample", initial);
398
+ if (initial.acceleratorBackend === "npu" || gpuSamplingMode(stream.osName) === "poll") {
399
+ while (!stream.closed && stream.subscribers.size) {
400
+ const sample = await gpuData(stream.alias, false);
401
+ if (!stream.closed && stream.subscribers.size) publish(stream, "sample", sample);
402
+ await new Promise((resolve) => setTimeout(resolve, 2000));
403
+ }
404
+ return;
405
+ }
406
+ stream.expectedCount = initial.gpus.length;
407
+ while (!stream.closed && stream.subscribers.size) {
408
+ stream.carry = "";
409
+ stream.pending.clear();
410
+ let logPath;
411
+ let runId;
412
+ try {
413
+ const command = `timeout 12s ${gpuQuery} --loop=2`;
414
+ const started = parseJsonTool(await callTool("execute-command", {
415
+ connectionName: stream.alias, cmdString: command, stream: true, timeout: 18_000, maxOutputBytes: 8192,
416
+ }, 22_000));
417
+ if (!started.runId) throw new Error("SSH-MCP 未启动 GPU 采样");
418
+ runId = started.runId;
419
+ logPath = started.logPath;
420
+ if (logPath) activeStreamLogs.add(logPath);
421
+ let status = "running";
422
+ while (status === "running" && !stream.closed) {
423
+ await new Promise((resolve) => setTimeout(resolve, stream.subscribers.size ? 900 : 1700));
424
+ const update = parseJsonTool(await callTool("command-status", { runId: started.runId, maxOutputBytes: 8192 }, 10_000));
425
+ if (update.outputChunk) acceptStreamChunk(stream, update.outputChunk);
426
+ status = update.status;
427
+ if (status === "failed" && stream.subscribers.size) throw new Error("远端 GPU 采样中断");
428
+ }
429
+ if (stream.carry) acceptStreamChunk(stream, "\n");
430
+ flushStreamSample(stream);
431
+ } finally {
432
+ if (logPath) activeStreamLogs.delete(logPath);
433
+ if (logPath && runId && path.resolve(logPath).startsWith(`${outputLogDir}${path.sep}`) && path.basename(logPath).includes(runId) && logPath.endsWith(".log")) {
434
+ try { await fs.promises.unlink(logPath); } catch { /* The log may have been removed already. */ }
435
+ }
436
+ pruneOutputLogs();
437
+ }
438
+ }
439
+ } catch (error) {
440
+ if (!stream.closed && stream.subscribers.size) {
441
+ publish(stream, "stream-error", { error: (error instanceof Error ? error.message : "流式采样失败").slice(0, 300) });
442
+ }
443
+ } finally {
444
+ for (const subscriber of stream.subscribers) subscriber.end();
445
+ stream.subscribers.clear();
446
+ if (gpuStreams.get(stream.alias) === stream) gpuStreams.delete(stream.alias);
447
+ }
448
+ }
449
+
450
+ async function subscribeGpuStream(alias, req, res) {
451
+ const server = await assertServer(alias);
452
+ res.writeHead(200, {
453
+ "Content-Type": "text/event-stream; charset=utf-8",
454
+ "Cache-Control": "no-cache, no-transform",
455
+ "Connection": "keep-alive",
456
+ "X-Accel-Buffering": "no",
457
+ });
458
+ res.flushHeaders?.();
459
+ let stream = gpuStreams.get(alias);
460
+ if (!stream) {
461
+ stream = { alias, osName: server.osName, expectedCount: server.gpuCount, pending: new Map(), carry: "", subscribers: new Set(), closed: false };
462
+ gpuStreams.set(alias, stream);
463
+ }
464
+ stream.subscribers.add(res);
465
+ sendEvent(res, "connected", { intervalSeconds: 2 });
466
+ res.on("close", () => stream.subscribers.delete(res));
467
+ if (!stream.running) {
468
+ stream.running = true;
469
+ void runGpuStream(stream);
470
+ }
471
+ }
472
+
473
+ async function npuData(alias) {
474
+ const cached = npuSamples.get(alias);
475
+ if (cached && Date.now() - cached.at < 2000) return cached.pending;
476
+ const pending = (async () => {
477
+ const output = readCommandOutput(await callTool("execute-command", {
478
+ connectionName: alias, cmdString: npuQuery, stream: false, timeout: 15_000,
479
+ }, 25_000));
480
+ const data = parseNpuSnapshot(output);
481
+ acceleratorFacts.set(alias, { acceleratorBackend: "npu", gpuCount: data.gpus.length });
482
+ return data;
483
+ })();
484
+ const sample = { at: Infinity, pending };
485
+ npuSamples.set(alias, sample);
486
+ try { const data = await pending; sample.at = Date.now(); return data; }
487
+ catch (error) { npuSamples.delete(alias); throw error; }
488
+ }
489
+
490
+ async function gpuData(alias, detail) {
491
+ const server = await assertServer(alias);
492
+ if (acceleratorFacts.get(alias)?.acceleratorBackend === "npu") return npuData(alias);
493
+ let raw;
494
+ try {
495
+ raw = readCommandOutput(await callTool("execute-command", { connectionName: alias, cmdString: gpuQuery, stream: false, timeout: 15_000 }, 25_000));
496
+ } catch (error) {
497
+ if (/nvidia-smi.*(?:not found|command not found|not recognized)|\[EXIT CODE: 127\]/iu.test(String(error))) {
498
+ if (!isWindows(server.osName)) {
499
+ try { return await npuData(alias); }
500
+ catch (npuError) {
501
+ if (!/npu-smi.*(?:not found|command not found)|\[EXIT CODE: 127\]/iu.test(String(npuError))) throw npuError;
502
+ }
503
+ }
504
+ return { gpus: [], processes: [], sampledAt: new Date().toISOString() };
505
+ }
506
+ throw error;
507
+ }
508
+ const gpus = parseGpuCsv(raw);
509
+ if (!gpus.length && raw) throw new Error(raw.slice(0, 300));
510
+ let processes = [];
511
+ let processError;
512
+ if (detail && gpus.length) {
513
+ try {
514
+ const output = readCommandOutput(await callTool("execute-command", { connectionName: alias, cmdString: processQuery, stream: false, timeout: 15_000 }, 25_000));
515
+ processes = parseProcesses(output, gpus);
516
+ } catch (error) { processError = error instanceof Error ? error.message : "进程信息不可用"; }
517
+ if (processes.length) {
518
+ const pids = [...new Set(processes.map((process) => process.pid))].join(",");
519
+ try {
520
+ const output = readCommandOutput(await callTool("execute-command", { connectionName: alias, cmdString: processDetailsCommand(server.osName, pids), stream: false, timeout: 10_000 }, 15_000));
521
+ processes = isWindows(server.osName) ? enrichWindowsProcesses(output, processes) : enrichProcesses(output, processes);
522
+ } catch { /* GPU process records remain valid without host metrics. */ }
523
+ }
524
+ }
525
+ acceleratorFacts.set(alias, { acceleratorBackend: "cuda", gpuCount: gpus.length });
526
+ return { acceleratorBackend: "cuda", gpus, processes, processError, sampledAt: new Date().toISOString() };
527
+ }
528
+
529
+ export async function readPluginRuns(alias, invoke = callTool) {
530
+ try {
531
+ const envelope = parseJsonTool(await invoke("plugin-run", { plugin: "rexp", connectionName: alias, args: ["runs", "--json"] }, 45_000));
532
+ const rows = parseRunsJson(envelope.output || "");
533
+ return { available: true, runs: rows, sampledAt: new Date().toISOString() };
534
+ } catch (error) {
535
+ const info = errorInfo(error);
536
+ if (info.state !== "error") {
537
+ return { available: false, platformState: info.state, message: info.message, diagnostic: info.details, runs: [], sampledAt: new Date().toISOString() };
538
+ }
539
+ throw error;
540
+ }
541
+ }
542
+
543
+ export function parseSearchJson(text, runId) {
544
+ const payload = JSON.parse(text);
545
+ if (payload?.schema_version !== 1) throw new Error("不支持的参数搜索数据格式");
546
+ const rows = runId ? [payload] : payload.searches;
547
+ if (!Array.isArray(rows) || rows.some((row) => !/^run_\d+$/u.test(row.run_id) || typeof row.status !== "string")) {
548
+ throw new Error("参数搜索数据包含无效记录");
549
+ }
550
+ if (runId && (payload.run_id !== runId || payload.query_version !== 1 || !payload.search?.metrics)) {
551
+ throw new Error("平台返回的搜索详情与请求不一致");
552
+ }
553
+ return payload;
554
+ }
555
+
556
+ export async function readPluginSearches(alias, invoke = callTool, runId) {
557
+ try {
558
+ const args = runId ? ["tune", "show", runId, "--summary", "--json"] : ["tune", "list", "--json"];
559
+ const envelope = parseJsonTool(await invoke("plugin-run", { plugin: "rexp", connectionName: alias, args }, 30_000));
560
+ const data = parseSearchJson(envelope.output || "", runId);
561
+ return { available: true, supported: true, ...data, sampledAt: new Date().toISOString() };
562
+ } catch (error) {
563
+ if(runId && /No such option.*--summary|No such command.*(?:trials|trial|charts)/iu.test(String(error)))
564
+ throw new Error('此节点的平台需要更新以支持按需读取;不会回退到全量搜索详情。');
565
+ if (/No such command ['"‘]?tune|invalid choice: ['"]tune/iu.test(String(error))) {
566
+ return { available: true, supported: false, searches: [], message: "此节点的平台版本暂不支持参数搜索。", sampledAt: new Date().toISOString() };
567
+ }
568
+ const info = errorInfo(error);
569
+ if (info.state !== "error") return { available: false, supported: false, platformState: info.state, searches: [], message: info.message, diagnostic: info.details, sampledAt: new Date().toISOString() };
570
+ throw error;
571
+ }
572
+ }
573
+
574
+ export function tuneQueryArgs(runId, kind, params, trialId) {
575
+ if (!/^run_\d+$/u.test(runId)) throw new Error('无效的搜索编号');
576
+ if (kind === 'trial') {
577
+ if (!/^[A-Za-z0-9_-]{1,128}$/u.test(trialId || '')) throw new Error('无效的尝试编号');
578
+ return ['tune', 'trial', runId, trialId, '--json'];
579
+ }
580
+ if (kind === 'charts') {
581
+ const metric=params.get('metric');
582
+ if (!metric || metric.length>200) throw new Error('请选择有效的目标指标');
583
+ return ['tune','charts',runId,'--metric',metric,'--json'];
584
+ }
585
+ const page=Number(params.get('page') || 1), size=Number(params.get('pageSize') || 50);
586
+ if (!Number.isInteger(page)||page<1||!Number.isInteger(size)||size<1||size>100) throw new Error('无效的分页参数');
587
+ const sort=params.get('sort') || 'default', status=params.get('status') || 'all', query=params.get('query') || '';
588
+ if (!(sort==='default'||/^time:(newest|oldest)$/.test(sort)||/^metric:.{1,200}$/.test(sort)) ||
589
+ !['all','pareto','PENDING','RUNNING','PAUSING','PAUSED','TERMINATED','ERROR','CANCELLED'].includes(status)||query.length>500) throw new Error('无效的排序或筛选条件');
590
+ return ['tune','trials',runId,'--page',String(page),'--page-size',String(size),'--sort',sort,'--status',status,'--query',query,
591
+ ...(params.get('active')==='1'?['--active']:[]),'--json'];
592
+ }
593
+
594
+ export async function readPluginTuneView(alias, runId, kind, params, trialId, invoke=callTool) {
595
+ const args=tuneQueryArgs(runId,kind,params,trialId);
596
+ const envelope=parseJsonTool(await invoke('plugin-run',{plugin:'rexp',connectionName:alias,args},30_000));
597
+ const data=JSON.parse(envelope.output || '');
598
+ if(data.schema_version!==1||data.run_id!==runId) throw new Error('平台返回的搜索视图与请求不一致');
599
+ if(kind==='trials' && (!Array.isArray(data.trials)||data.trials.length>Number(params.get('pageSize')||50))) throw new Error('平台未返回请求的分页数据');
600
+ if(kind==='trial' && data.trial?.trial_id!==trialId) throw new Error('平台返回的尝试与请求不一致');
601
+ if(kind==='charts' && data.analytics?.metric!==params.get('metric')) throw new Error('平台返回的图表与请求不一致');
602
+ return data;
603
+ }
604
+
605
+ export function sameOrigin(req) {
606
+ try { const origin = new URL(req.headers.origin); return origin.host === req.headers.host && ['http:', 'https:'].includes(origin.protocol); }
607
+ catch { return false; }
608
+ }
609
+
610
+ export async function setPluginSearchPolicy(alias, runId, enabled, invoke = callTool) {
611
+ if (typeof enabled !== 'boolean') throw new Error('让位开关必须是布尔值');
612
+ const envelope = parseJsonTool(await invoke('plugin-run', {
613
+ plugin: 'rexp', connectionName: alias,
614
+ args: ['tune', 'policy', runId, enabled ? '--yield-to-normal' : '--no-yield-to-normal', '--json'],
615
+ }, 30_000));
616
+ const result = JSON.parse(envelope.output || '');
617
+ if (result.run_id !== runId || result.yield_to_normal !== enabled || result.trial_priority !== Number(enabled)) {
618
+ throw new Error('平台未确认调度策略,请刷新后检查');
619
+ }
620
+ return result;
621
+ }
622
+
623
+ export async function readPolicyBody(req) {
624
+ let body = '';
625
+ for await (const chunk of req) {
626
+ body += chunk;
627
+ if (Buffer.byteLength(body) > 1024) throw new Error('请求过大');
628
+ }
629
+ const value = JSON.parse(body);
630
+ if (typeof value?.yield_to_normal !== 'boolean') throw new Error('让位开关必须是布尔值');
631
+ return value.yield_to_normal;
632
+ }
633
+
634
+ async function runsData(alias) {
635
+ await assertServer(alias);
636
+ return readPluginRuns(alias);
637
+ }
638
+
639
+ export function parseRunDetailsJson(text) {
640
+ const record = JSON.parse(text);
641
+ if (record?.schema_version !== 1 || !/^run_\d+$/u.test(record.run_id)) throw new Error("不支持的平台实验详情格式");
642
+ const fields = Object.fromEntries(Object.entries(record).filter(([key]) => !["schema_version", "config_snapshot", "management", "scheduler"].includes(key))
643
+ .map(([key, value]) => [key, value == null ? "—" : typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)]));
644
+ if (record.total_samples != null && record.progress != null) fields[record.progress_unit === "trials" ? "remaining_trials" : "remaining_samples"] = String(Math.max(0, record.total_samples - record.progress));
645
+ return fields;
646
+ }
647
+
648
+ export async function readPluginRunDetails(alias, runId, invoke = callTool) {
649
+ if (!/^run_\d+$/u.test(runId)) throw new Error("无效的实验编号");
650
+ const envelope = parseJsonTool(await invoke("plugin-run", { plugin: "rexp", connectionName: alias, args: ["run", "show", runId, "--json"] }, 30_000));
651
+ const record = JSON.parse(envelope.output || "");
652
+ const fields = parseRunDetailsJson(envelope.output || "");
653
+ if (fields.run_id !== runId) throw new Error("平台返回的实验详情与请求不一致");
654
+ const terminal = ['COMPLETED', 'FAILED', 'CANCELLED'].includes(record.status);
655
+ const management = record.management || { schema_version: 0, actions: {
656
+ pause: { enabled: false, reason: '需更新节点平台后使用暂停' },
657
+ resume: { enabled: record.checkpoint?.can_resume === true, reason: record.checkpoint?.resume_blocked_reason },
658
+ cancel: { enabled: !terminal, reason: '实验已结束' },
659
+ clean: { enabled: terminal, reason: '只允许清理已结束的实验' },
660
+ } };
661
+ return { fields, management, scheduler: record.scheduler, sampledAt: new Date().toISOString() };
662
+ }
663
+
664
+ export async function readPluginCleanup(alias, runId, invoke = callTool, confirm = false) {
665
+ if (!/^run_\d+$/u.test(runId)) throw new Error('无效的实验编号');
666
+ const envelope = parseJsonTool(await invoke('plugin-run', { plugin: 'rexp', connectionName: alias,
667
+ args: ['run', 'clean', runId, ...(confirm ? ['--yes'] : []), '--json'],
668
+ }, 90_000));
669
+ const data = JSON.parse(envelope.output || '');
670
+ if (data.run_id !== runId || data.dry_run !== !confirm || !Array.isArray(data.entries)) throw new Error('平台未确认清理结果');
671
+ return data;
672
+ }
673
+
674
+ export async function managePluginRun(alias, runId, action, body, invoke = callTool) {
675
+ if (!['pause', 'resume', 'cancel', 'clean'].includes(action)) throw new Error('不支持此实验操作');
676
+ if (['cancel', 'clean'].includes(action) && body?.confirmRunId !== runId) throw new Error('请确认要操作的实验编号');
677
+ const before = await readPluginRunDetails(alias, runId, invoke);
678
+ if (!before.management.actions[action]?.enabled) throw new Error(before.management.actions[action]?.reason || '当前状态无法执行此操作');
679
+ let cleanup;
680
+ if (action === 'clean') cleanup = await readPluginCleanup(alias, runId, invoke, true);
681
+ else {
682
+ // Legacy resume has no --json option. Verify state with run show after every operation.
683
+ const args = ['run', action, runId, ...(action === 'resume' ? [] : ['--json'])];
684
+ await invoke('plugin-run', { plugin: 'rexp', connectionName: alias, args }, 30_000);
685
+ }
686
+ let details;
687
+ try { details = await readPluginRunDetails(alias, runId, invoke); }
688
+ catch { return { cleanup, message: '请求已发送,但状态暂时无法读取。请刷新核实后再操作。' }; }
689
+ return { details, cleanup, message: action === 'pause' ? '已请求暂停;等待实验在安全边界保存检查点。'
690
+ : action === 'resume' ? (details.scheduler && !details.scheduler.accepting_work ? '已加入续跑队列;调度器未启动,请先启动节点调度器。' : '已加入续跑队列,等待调度和可用资源。')
691
+ : action === 'cancel' ? '已取消后续调度;运行中的进程需要时间响应退出请求。' : '清理完成,实验记录与分析结果已保留。' };
692
+ }
693
+
694
+ export async function readRunActionBody(req) {
695
+ let body = '';
696
+ for await (const chunk of req) {
697
+ body += chunk;
698
+ if (Buffer.byteLength(body) > 1024) throw new Error('请求过大');
699
+ }
700
+ const value = JSON.parse(body);
701
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => k !== 'confirmRunId') ||
702
+ (value.confirmRunId !== undefined && !/^run_\d+$/u.test(value.confirmRunId))) throw new Error('实验操作参数不正确');
703
+ return value;
704
+ }
705
+
706
+ function send(res, status, value) {
707
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
708
+ res.end(JSON.stringify(value));
709
+ }
710
+
711
+ export async function pluginDevicePolicy(alias, policy, invoke = callTool) {
712
+ const args = ['node', 'devices', '--json'];
713
+ if (policy) {
714
+ if (!['all','selected'].includes(policy.mode) || !Array.isArray(policy.allowed_device_uuids) ||
715
+ policy.allowed_device_uuids.length > 128 || policy.allowed_device_uuids.some(id => typeof id !== 'string' || !/^(?:GPU|MIG)-[A-Za-z0-9/._-]{1,160}$/u.test(id)))
716
+ throw new Error('算力卡范围设置不正确');
717
+ if (policy.mode === 'all') args.push('--all');
718
+ else if (!policy.allowed_device_uuids.length) args.push('--none');
719
+ else for (const uuid of policy.allowed_device_uuids) args.push('--allow', uuid);
720
+ }
721
+ try {
722
+ const envelope = parseJsonTool(await invoke('plugin-run', {plugin:'rexp',connectionName:alias,args}, 30_000));
723
+ const data = JSON.parse(envelope.output);
724
+ if(data.schema_version !== 1 || !Array.isArray(data.devices)) throw new Error('算力卡范围数据格式不正确');
725
+ return {available:true,...data};
726
+ } catch(error) {
727
+ if (/No such command ['"‘]?devices/iu.test(String(error))) {
728
+ if(policy) throw new Error('请先更新此节点的 rexprun,以支持算力卡范围设置');
729
+ return {available:false,message:'此节点的平台需要更新后,才能设置允许使用的算力卡。'};
730
+ }
731
+ const info=errorInfo(error);
732
+ if(!policy && info.state !== 'error') return {available:false,message:info.message,platformState:info.state};
733
+ throw error;
734
+ }
735
+ }
736
+
737
+ export async function pluginSchedulerAutostart(alias, enabled, invoke = callTool) {
738
+ if (enabled !== undefined && typeof enabled !== 'boolean') throw new Error('自启动设置参数不正确');
739
+ const args = ['autostart', '--json'];
740
+ if (enabled !== undefined) args.push(enabled ? '--enable' : '--disable');
741
+ try {
742
+ const envelope = parseJsonTool(await invoke('plugin-run', {plugin:'rexp',connectionName:alias,args}, 60_000));
743
+ const data = JSON.parse(envelope.output);
744
+ if (typeof data.supported !== 'boolean' || typeof data.enabled !== 'boolean' || typeof data.boot_ready !== 'boolean') throw new Error('自启动数据格式不正确');
745
+ return {available:true,...data};
746
+ } catch(error) {
747
+ if (/No such command ['"‘]?autostart/iu.test(String(error))) {
748
+ if(enabled !== undefined) throw new Error('请先更新此节点的 rexprun,以支持调度器开机自启动');
749
+ return {available:false,message:'此节点的平台需要更新后,才能设置调度器开机自启动。'};
750
+ }
751
+ const info=errorInfo(error);
752
+ if(enabled === undefined && info.state !== 'error') return {available:false,message:info.message};
753
+ throw error;
754
+ }
755
+ }
756
+
757
+ export async function handleApi(req, res, next) {
758
+ const url = new URL(req.url || "/", "http://127.0.0.1");
759
+ if (!url.pathname.startsWith("/api/")) return next();
760
+ if (url.pathname === '/api/web/autostart') {
761
+ if(req.method === 'GET') {
762
+ try {return send(res,200,autostartStatus());}
763
+ catch(error){return send(res,500,{error:'自启动设置读取失败',details:error.message});}
764
+ }
765
+ if(req.method !== 'POST') return send(res,405,{error:'Method not allowed'});
766
+ if(!sameOrigin(req)) return send(res,403,{error:'只接受来自当前网页的请求'});
767
+ let enabled;
768
+ try {
769
+ let body=''; for await(const chunk of req) {body+=chunk; if(Buffer.byteLength(body)>1024) throw new Error('Too large');}
770
+ const data=JSON.parse(body);
771
+ if(!data || Object.keys(data).length!==1 || typeof data.enabled!=='boolean') throw new Error('Invalid settings');
772
+ enabled=data.enabled;
773
+ } catch{return send(res,400,{error:'自启动设置参数不正确'});}
774
+ try{return send(res,200,setAutostart(enabled));}
775
+ catch(error){return send(res,500,{error:'自启动设置未能保存',details:error.message});}
776
+ }
777
+ const schedulerAutostartMatch = /^\/api\/servers\/([^/]+)\/scheduler-autostart$/u.exec(url.pathname);
778
+ if (schedulerAutostartMatch) {
779
+ if(!['GET','POST'].includes(req.method)) return send(res,405,{error:'Method not allowed'});
780
+ if(req.method === 'POST' && !sameOrigin(req)) return send(res,403,{error:'只接受来自当前网页的请求'});
781
+ let enabled;
782
+ if(req.method === 'POST') {
783
+ try {
784
+ let raw=''; for await(const chunk of req) {raw+=chunk;if(Buffer.byteLength(raw)>1024)throw new Error('Too large');}
785
+ const data=JSON.parse(raw);
786
+ if(!data || Object.keys(data).length!==1 || typeof data.enabled!=='boolean') throw new Error('Invalid settings');
787
+ enabled=data.enabled;
788
+ } catch {return send(res,400,{error:'自启动设置参数不正确'});}
789
+ }
790
+ try {
791
+ const alias=decodeURIComponent(schedulerAutostartMatch[1]);await assertServer(alias);
792
+ return send(res,200,await pluginSchedulerAutostart(alias,enabled));
793
+ } catch(error) {return send(res,502,{error:req.method === 'POST'?'调度器自启动未能保存':'调度器自启动暂时无法读取',details:String(error.message || error)});}
794
+ }
795
+ const devicesMatch = /^\/api\/servers\/([^/]+)\/device-policy$/u.exec(url.pathname);
796
+ if (devicesMatch && ['GET','POST'].includes(req.method)) {
797
+ if(req.method === 'POST' && !sameOrigin(req)) return send(res,403,{error:'只接受来自当前网页的请求'});
798
+ let policy;
799
+ if(req.method === 'POST') {
800
+ try {
801
+ let raw=''; for await(const chunk of req) {raw+=chunk;if(Buffer.byteLength(raw)>24_000)throw new Error('Too large');}
802
+ policy=JSON.parse(raw);
803
+ if(!policy || Object.keys(policy).some(k=>!['mode','allowed_device_uuids'].includes(k))) throw new Error('Invalid policy');
804
+ } catch {return send(res,400,{error:'算力卡范围设置不正确'});}
805
+ }
806
+ try {
807
+ const alias=decodeURIComponent(devicesMatch[1]);await assertServer(alias);
808
+ return send(res,200,await pluginDevicePolicy(alias,policy));
809
+ } catch(error) {return send(res,502,{error:req.method === 'POST'?'算力卡范围未能保存':'算力卡范围暂时无法读取',details:String(error.message || error)});}
810
+ }
811
+ const actionMatch = /^\/api\/servers\/([^/]+)\/runs\/(run_\d+)\/actions\/(pause|resume|cancel|clean)$/u.exec(url.pathname);
812
+ if (actionMatch && req.method === 'POST') {
813
+ if (!sameOrigin(req)) return send(res, 403, { error: '只接受来自当前网页的请求' });
814
+ let body;
815
+ try {
816
+ body = await readRunActionBody(req);
817
+ if (['cancel', 'clean'].includes(actionMatch[3]) && body.confirmRunId !== actionMatch[2]) throw new Error('confirmation');
818
+ } catch { return send(res, 400, { error: '实验操作参数不正确或尚未确认' }); }
819
+ try {
820
+ const alias = decodeURIComponent(actionMatch[1]);
821
+ await assertServer(alias);
822
+ const result = await managePluginRun(alias, actionMatch[2], actionMatch[3], body);
823
+ runMonitor.snapshots.delete(alias);
824
+ return send(res, 200, result);
825
+ } catch (error) {
826
+ return send(res, 502, { error: '操作未完成,请刷新状态后检查', details: String(error.message || error) });
827
+ }
828
+ }
829
+ const claimMatch = /^\/api\/notifications\/(\d+)\/claim$/u.exec(url.pathname);
830
+ if (claimMatch && req.method === "POST") {
831
+ if (!sameOrigin(req)) return send(res, 403, { error: "只接受来自当前网页的请求" });
832
+ return send(res, 200, { claimed: runMonitor.claim(Number(claimMatch[1])) });
833
+ }
834
+ const policyMatch = /^\/api\/servers\/([^/]+)\/tune\/(run_\d+)\/policy$/u.exec(url.pathname);
835
+ if (policyMatch && req.method === 'POST') {
836
+ if (!sameOrigin(req)) return send(res, 403, { error: '只接受来自当前网页的请求' });
837
+ let enabled;
838
+ try { enabled = await readPolicyBody(req); }
839
+ catch { return send(res, 400, { error: '调度策略格式不正确' }); }
840
+ try {
841
+ const alias = decodeURIComponent(policyMatch[1]);
842
+ await assertServer(alias);
843
+ return send(res, 200, await setPluginSearchPolicy(alias, policyMatch[2], enabled));
844
+ } catch (error) {
845
+ const info = errorInfo(error);
846
+ return send(res, 502, { error: '调度策略更新失败,请刷新后检查', details: info.details });
847
+ }
848
+ }
849
+ if (req.method !== "GET") return send(res, 405, { error: "不支持此操作" });
850
+ try {
851
+ if (url.pathname === "/api/notifications/state") return send(res, 200, runMonitor.status());
852
+ if (url.pathname === "/api/notifications/events") return subscribeRunEvents(req, res, url);
853
+ if (url.pathname === "/api/servers") return send(res, 200, { servers: await listServers() });
854
+ const cleanupMatch = /^\/api\/servers\/([^/]+)\/runs\/(run_\d+)\/storage$/u.exec(url.pathname);
855
+ if (cleanupMatch) {
856
+ const alias = decodeURIComponent(cleanupMatch[1]);
857
+ await assertServer(alias);
858
+ return send(res, 200, await readPluginCleanup(alias, cleanupMatch[2]));
859
+ }
860
+ const tuneViewMatch=/^\/api\/servers\/([^/]+)\/tune\/(run_\d+)\/(trials|charts|trial)(?:\/([A-Za-z0-9_-]+))?$/u.exec(url.pathname);
861
+ if(tuneViewMatch) {
862
+ try {tuneQueryArgs(tuneViewMatch[2],tuneViewMatch[3],url.searchParams,tuneViewMatch[4]);}
863
+ catch(error){return send(res,400,{error:error.message});}
864
+ const alias=decodeURIComponent(tuneViewMatch[1]);
865
+ await assertServer(alias);
866
+ return send(res,200,await readPluginTuneView(alias,tuneViewMatch[2],tuneViewMatch[3],url.searchParams,tuneViewMatch[4]));
867
+ }
868
+ const tuneMatch = /^\/api\/servers\/([^/]+)\/tune(?:\/(run_\d+))?$/u.exec(url.pathname);
869
+ if (tuneMatch) {
870
+ const alias = decodeURIComponent(tuneMatch[1]);
871
+ await assertServer(alias);
872
+ return send(res, 200, await readPluginSearches(alias, callTool, tuneMatch[2]));
873
+ }
874
+ const runDetailMatch = /^\/api\/servers\/([^/]+)\/runs\/(run_\d+)$/u.exec(url.pathname);
875
+ if (runDetailMatch) {
876
+ const alias = decodeURIComponent(runDetailMatch[1]);
877
+ await assertServer(alias);
878
+ return send(res, 200, await readPluginRunDetails(alias, runDetailMatch[2]));
879
+ }
880
+ const streamMatch = /^\/api\/servers\/([^/]+)\/gpu\/stream$/u.exec(url.pathname);
881
+ if (streamMatch) return await subscribeGpuStream(decodeURIComponent(streamMatch[1]), req, res);
882
+ const match = /^\/api\/servers\/([^/]+)\/(gpu|runs)$/u.exec(url.pathname);
883
+ if (!match) return send(res, 404, { error: "接口不存在" });
884
+ const alias = decodeURIComponent(match[1]);
885
+ const body = match[2] === "gpu" ? await gpuData(alias, url.searchParams.get("detail") === "1") : (!runMonitor.stopped && runMonitor.snapshot(alias)) || await runsData(alias);
886
+ return send(res, 200, body);
887
+ } catch (error) {
888
+ const info = errorInfo(error);
889
+ return send(res, 502, { error: info.message, details: info.details });
890
+ }
891
+ }