@evandu/super-pi 0.1.85 → 0.1.87
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/agents/super-pi-task/index.ts +2 -2
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +10 -8
- package/dist/client/assets/index-BfK8kaCv.css +1 -0
- package/dist/client/assets/index-Dh3QFthH.js +73 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.js +48 -9
- package/dist/server/http-server.js +159 -7
- package/dist/server/index.js +8 -1
- package/dist/server/runtime.d.ts +34 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/store.d.ts +19 -0
- package/dist/server/store.js +31 -0
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/package.json +4 -1
- package/dist/client/assets/index-B_jOwjyp.js +0 -72
- package/dist/client/assets/index-CNNGl4eF.css +0 -1
|
@@ -1,23 +1,163 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
const MAX_LOG_BYTES = 512_000;
|
|
4
7
|
const MAX_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
/** The environment build gets its OWN budget: a cold `pip install` must not eat the tool's timeout. */
|
|
9
|
+
const MAX_ENV_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
const isWindows = process.platform === "win32";
|
|
5
11
|
/** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
|
|
6
12
|
export class ToolRunner {
|
|
13
|
+
dataDir;
|
|
7
14
|
runs = new Map();
|
|
15
|
+
/** Optional so the existing unit tests can build a runner with no data dir; a tool that needs no venv never touches it. */
|
|
16
|
+
constructor(dataDir) {
|
|
17
|
+
this.dataDir = dataDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* THE RESOLUTION ORDER, IN ONE PLACE, AND IT IS AN ORDER ON PURPOSE.
|
|
21
|
+
*
|
|
22
|
+
* 1. the tool's own venv - a tool's dependencies belong to that tool and to nothing else;
|
|
23
|
+
* 2. `SUPER_PI_PYTHON_COMMAND` - the user's explicit choice, already honoured before this existed;
|
|
24
|
+
* 3. the system interpreter - today's behaviour;
|
|
25
|
+
* 4. nothing usable, which is REPORTED as a sentence rather than discovered as an ENOENT.
|
|
26
|
+
*
|
|
27
|
+
* Bundling an interpreter later is a fourth rung on this chain, which is why nothing in a tool's
|
|
28
|
+
* files has to know about it.
|
|
29
|
+
*/
|
|
30
|
+
resolvePython(toolId, needsVenv) {
|
|
31
|
+
if (needsVenv && this.dataDir) {
|
|
32
|
+
const envPython = this.envPython(toolId);
|
|
33
|
+
if (existsSync(envPython))
|
|
34
|
+
return { path: envPython, kind: "tool-env" };
|
|
35
|
+
}
|
|
36
|
+
const override = process.env.SUPER_PI_PYTHON_COMMAND?.trim();
|
|
37
|
+
if (override)
|
|
38
|
+
return { path: override, kind: "override" };
|
|
39
|
+
return { path: isWindows ? "python.exe" : "python3", kind: "system" };
|
|
40
|
+
}
|
|
41
|
+
/** The venv lives BESIDE the tools, not inside one: export walks the tool directory, and the mount route serves everything under it. */
|
|
42
|
+
envDir(toolId) { return join(this.dataDir ?? ".", "tool-envs", toolId); }
|
|
43
|
+
envPython(toolId) { return join(this.envDir(toolId), ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"); }
|
|
44
|
+
/** MEASURE this machine's Python. One spawn; the answer is what every fingerprint is compared to. */
|
|
45
|
+
async describeLocalPython() {
|
|
46
|
+
const choice = this.resolvePython("", false);
|
|
47
|
+
const probe = await runCapture(choice.path, ["-I", "-c", "import json,platform,sys;print(json.dumps({'python':sys.version.split()[0],'platform':platform.system().lower()+'-'+platform.machine()}))"], 10_000);
|
|
48
|
+
if (!probe.ok)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(probe.stdout);
|
|
52
|
+
if (typeof parsed?.python !== "string")
|
|
53
|
+
return undefined;
|
|
54
|
+
return { ...choice, python: parsed.python, platform: parsed.platform };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async environmentState(tool) {
|
|
61
|
+
const environment = tool.environment;
|
|
62
|
+
const requires = environment?.requires ?? [];
|
|
63
|
+
const wants = requires.length > 0 || Boolean(environment?.requiresPython);
|
|
64
|
+
const local = await this.describeLocalPython();
|
|
65
|
+
const base = { python: local?.python, platform: local?.platform, requires };
|
|
66
|
+
if (!wants)
|
|
67
|
+
return { ...base, state: "zero-deps" };
|
|
68
|
+
if (!local)
|
|
69
|
+
return { ...base, state: "needs-python", detail: "这台机器上找不到可用的 Python 解释器。" };
|
|
70
|
+
if (environment?.requiresPython && !satisfiesRange(local.python ?? "", environment.requiresPython))
|
|
71
|
+
return { ...base, state: "needs-python", detail: `这个工具需要 Python ${environment.requiresPython},本机是 ${local.python}。` };
|
|
72
|
+
if (requires.length > 0 && this.dataDir && !existsSync(this.envPython(tool.id)))
|
|
73
|
+
return { ...base, state: "needs-env", detail: "首次运行这个工具时会自动安装这些依赖。" };
|
|
74
|
+
const known = environment?.fingerprint;
|
|
75
|
+
if (known?.python && known.platform && (majorMinor(known.python) !== majorMinor(local.python ?? "") || known.platform !== local.platform))
|
|
76
|
+
return { ...base, state: "differs", detail: `导出方是 Python ${known.python} / ${known.platform},本机是 ${local.python} / ${local.platform}。` };
|
|
77
|
+
return { ...base, state: "ready" };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Starts a run. It RETURNS SYNCHRONOUSLY - so bad parameters still throw at the call site, exactly
|
|
81
|
+
* as before - while the interpreter and, if the tool needs one, its virtual environment are prepared
|
|
82
|
+
* in the background. The run is already `running` at that point and the build's output lands in the
|
|
83
|
+
* run's own `logs`, which is why the tool's existing dialog shows the progress without knowing
|
|
84
|
+
* anything about environments.
|
|
85
|
+
*/
|
|
8
86
|
start(input) {
|
|
9
87
|
validateParams(input.definition.parameters ?? {}, input.params);
|
|
10
|
-
const python = process.env.SUPER_PI_PYTHON_COMMAND?.trim() || (process.platform === "win32" ? "python.exe" : "python3");
|
|
11
88
|
const id = `toolrun_${randomUUID()}`;
|
|
12
89
|
const run = { id, toolId: input.toolId, status: "running", startedAt: new Date().toISOString(), logs: "" };
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const active = { run,
|
|
16
|
-
active.timer.unref();
|
|
90
|
+
const timer = setTimeout(() => this.finish(id, "timed_out", `准备运行环境超过 ${MAX_ENV_TIMEOUT_MS}ms 时限`), MAX_ENV_TIMEOUT_MS);
|
|
91
|
+
timer.unref();
|
|
92
|
+
const active = { run, stdout: "", cancelled: false, timer };
|
|
17
93
|
this.runs.set(id, active);
|
|
18
|
-
|
|
94
|
+
void this.prepare(id, active, input);
|
|
95
|
+
return copy(run);
|
|
96
|
+
}
|
|
97
|
+
async prepare(id, active, input) {
|
|
98
|
+
try {
|
|
99
|
+
const requires = input.environment?.requires ?? [];
|
|
100
|
+
let python = this.resolvePython(input.toolId, false);
|
|
101
|
+
if (requires.length > 0) {
|
|
102
|
+
const built = await this.ensureEnvironment(input.toolId, python, requires, active);
|
|
103
|
+
if (!built.ok) {
|
|
104
|
+
this.finish(id, "failed", built.error);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
python = { path: this.envPython(input.toolId), kind: "tool-env" };
|
|
108
|
+
}
|
|
109
|
+
if (active.cancelled)
|
|
110
|
+
return;
|
|
111
|
+
if (active.timer)
|
|
112
|
+
clearTimeout(active.timer);
|
|
113
|
+
const scriptTimer = setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs));
|
|
114
|
+
scriptTimer.unref();
|
|
115
|
+
active.timer = scriptTimer;
|
|
116
|
+
this.launch(id, active, python, input);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.finish(id, "failed", error instanceof Error ? error.message : "tool environment could not be prepared");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* THE VENV IS PYTHON'S OWN, ON PURPOSE. `python -m venv` finds its site-packages through the
|
|
124
|
+
* pyvenv.cfg next to the interpreter, which is why it survives the `-I` a tool runs under - a
|
|
125
|
+
* PYTHONPATH-based vendor directory does not (verified: `-I` implies `-E`, so PYTHONPATH is
|
|
126
|
+
* ignored, and dropping `-I` would give up the isolation that keeps a hostile PYTHON* environment
|
|
127
|
+
* from influencing a shared tool's script).
|
|
128
|
+
*/
|
|
129
|
+
async ensureEnvironment(toolId, base, requires, active) {
|
|
130
|
+
if (!this.dataDir)
|
|
131
|
+
return { ok: false, error: "这个工具需要依赖,但没有可用的数据目录来存放它的虚拟环境。" };
|
|
132
|
+
const envDir = this.envDir(toolId), envPython = this.envPython(toolId);
|
|
133
|
+
const recorded = await readFile(join(envDir, "installed.json"), "utf8").then((text) => { try {
|
|
134
|
+
return JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
} }).catch(() => undefined);
|
|
139
|
+
if (existsSync(envPython) && Array.isArray(recorded?.requires) && recorded.requires.length === requires.length && recorded.requires.every((item, index) => item === requires[index]))
|
|
140
|
+
return { ok: true };
|
|
141
|
+
await rm(envDir, { recursive: true, force: true });
|
|
142
|
+
await mkdir(envDir, { recursive: true });
|
|
143
|
+
const requirements = join(envDir, "requirements.txt");
|
|
144
|
+
await writeFile(requirements, `${requires.join("\n")}\n`, "utf8");
|
|
145
|
+
logLine(active, `正在准备 Python 虚拟环境(${requires.join("、")})…\n`);
|
|
146
|
+
const created = await runCapture(base.path, ["-m", "venv", join(envDir, ".venv")], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
|
|
147
|
+
if (!created.ok)
|
|
148
|
+
return { ok: false, error: `创建 Python 虚拟环境失败:${created.error || `解释器 ${base.path} 不可用`}` };
|
|
149
|
+
const installed = await runCapture(envPython, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "-r", requirements], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
|
|
150
|
+
if (!installed.ok)
|
|
151
|
+
return { ok: false, error: `安装工具依赖失败:${installed.error || `pip 退出码 ${installed.code}`}(依赖:${requires.join("、")})` };
|
|
152
|
+
await writeFile(join(envDir, "installed.json"), JSON.stringify({ requires, python: base.path, builtAt: new Date().toISOString() }, null, 2), "utf8");
|
|
153
|
+
return { ok: true };
|
|
154
|
+
}
|
|
155
|
+
launch(id, active, python, input) {
|
|
156
|
+
// -I ignores user Python configuration and user site packages. No shell means params never become code.
|
|
157
|
+
const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
|
|
158
|
+
active.child = child;
|
|
19
159
|
child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
|
|
20
|
-
child.stderr.on("data",
|
|
160
|
+
child.stderr.on("data", (data) => logLine(active, data.toString("utf8")));
|
|
21
161
|
child.once("error", (error) => this.finish(id, "failed", error.message));
|
|
22
162
|
child.once("close", (code) => {
|
|
23
163
|
if (active.run.status !== "running")
|
|
@@ -38,7 +178,6 @@ export class ToolRunner {
|
|
|
38
178
|
}
|
|
39
179
|
});
|
|
40
180
|
child.stdin.end(JSON.stringify(input.params));
|
|
41
|
-
return copy(run);
|
|
42
181
|
}
|
|
43
182
|
get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
|
|
44
183
|
cancel(id, toolId) { const active = this.runs.get(id); if (!active || active.run.toolId !== toolId)
|
|
@@ -47,17 +186,78 @@ export class ToolRunner {
|
|
|
47
186
|
this.finish(id, "cancelled");
|
|
48
187
|
} return copy(active.run); }
|
|
49
188
|
finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
|
|
50
|
-
return;
|
|
189
|
+
return; if (active.timer)
|
|
190
|
+
clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
|
|
51
191
|
active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
|
|
52
|
-
active.run.error = error; if (!active.child.killed) {
|
|
192
|
+
active.run.error = error; if (active.child && !active.child.killed) {
|
|
53
193
|
try {
|
|
54
194
|
active.child.kill();
|
|
55
195
|
}
|
|
56
196
|
catch { }
|
|
57
197
|
} }
|
|
58
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Runs a Python process with NO SHELL - the same rule as a tool run, because what is being installed
|
|
201
|
+
* is code too. There is no command string anywhere on this path, only an argv.
|
|
202
|
+
*/
|
|
203
|
+
function runCapture(python, args, timeoutMs, onOutput) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
let stdout = "", stderr = "", settled = false;
|
|
206
|
+
const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1" } });
|
|
207
|
+
const timer = setTimeout(() => { if (settled)
|
|
208
|
+
return; settled = true; try {
|
|
209
|
+
child.kill();
|
|
210
|
+
}
|
|
211
|
+
catch { } resolve({ ok: false, code: null, stdout, error: `timed out after ${timeoutMs}ms` }); }, timeoutMs);
|
|
212
|
+
timer.unref();
|
|
213
|
+
child.stdout.on("data", (data) => { const text = data.toString("utf8"); stdout += text; onOutput?.(text); });
|
|
214
|
+
child.stderr.on("data", (data) => { const text = data.toString("utf8"); stderr += text; onOutput?.(text); });
|
|
215
|
+
child.once("error", (error) => { if (settled)
|
|
216
|
+
return; settled = true; clearTimeout(timer); resolve({ ok: false, code: null, stdout, error: error.message }); });
|
|
217
|
+
child.once("close", (code) => { if (settled)
|
|
218
|
+
return; settled = true; clearTimeout(timer); if (code === 0)
|
|
219
|
+
resolve({ ok: true, stdout, code });
|
|
220
|
+
else
|
|
221
|
+
resolve({ ok: false, code, stdout, error: stderr.split("\n").filter((line) => line.trim()).slice(-4).join("\n") }); });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A SMALL, DELIBERATELY SMALL PEP 440 READER: `>=`, `>`, `<=`, `<`, `==`, comma-separated. Anything it
|
|
226
|
+
* cannot parse counts as NOT satisfied, because a version check that guesses is worse than one that
|
|
227
|
+
* refuses. A tool needing something exotic declares no range at all and relies on the venv, not here.
|
|
228
|
+
*/
|
|
229
|
+
export function satisfiesRange(version, range) {
|
|
230
|
+
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
231
|
+
if (actual.some((part) => Number.isNaN(part)))
|
|
232
|
+
return false;
|
|
233
|
+
for (const term of range.split(",")) {
|
|
234
|
+
const match = /^(>=|<=|==|>|<)?\s*(\d+(?:\.\d+)*)$/.exec(term.trim());
|
|
235
|
+
if (!match)
|
|
236
|
+
return false;
|
|
237
|
+
const comparison = compareVersions(actual, match[2].split(".").map((part) => Number.parseInt(part, 10)));
|
|
238
|
+
const operator = match[1] ?? "==";
|
|
239
|
+
if (operator === ">=" && comparison < 0)
|
|
240
|
+
return false;
|
|
241
|
+
if (operator === ">" && comparison <= 0)
|
|
242
|
+
return false;
|
|
243
|
+
if (operator === "<=" && comparison > 0)
|
|
244
|
+
return false;
|
|
245
|
+
if (operator === "<" && comparison >= 0)
|
|
246
|
+
return false;
|
|
247
|
+
if (operator === "==" && comparison !== 0)
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
function compareVersions(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
253
|
+
const a = left[index] ?? 0, b = right[index] ?? 0;
|
|
254
|
+
if (a !== b)
|
|
255
|
+
return a < b ? -1 : 1;
|
|
256
|
+
} return 0; }
|
|
257
|
+
function majorMinor(version) { return version.split(".").slice(0, 2).join("."); }
|
|
59
258
|
function boundedTimeout(value) { return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Number.isFinite(value) ? Math.floor(value) : 60_000)); }
|
|
60
259
|
function clipped(text) { return text.length > MAX_LOG_BYTES ? text.slice(-MAX_LOG_BYTES) : text; }
|
|
260
|
+
function logLine(active, text) { active.run.logs = clipped(`${active.run.logs}${text}`); }
|
|
61
261
|
function copy(run) { return { ...run, result: run.result ? { ...run.result } : undefined }; }
|
|
62
262
|
export function validateParams(schema, params) {
|
|
63
263
|
if (!params || Array.isArray(params) || typeof params !== "object")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.87",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -90,6 +90,9 @@
|
|
|
90
90
|
"scripts/knowledge-base.test.mjs",
|
|
91
91
|
"scripts/tool-runner.test.mjs",
|
|
92
92
|
"scripts/tool-runtime-api.test.mjs",
|
|
93
|
+
"scripts/tool-sharing.test.mjs",
|
|
94
|
+
"scripts/tool-env.test.mjs",
|
|
95
|
+
"scripts/tool-llm.test.mjs",
|
|
93
96
|
"scripts/extension-load.test.mjs",
|
|
94
97
|
"scripts/static-client-assets.test.mjs",
|
|
95
98
|
"scripts/cli.test.mjs",
|