mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-13 08:55:14 +00:00
feat(dsh): check and install reme-ai from the Environment tab
- check reme-ai version, the [core] AgentScope import and the reme CLI
for the selected interpreter; an outdated install is flagged instead
of treated as ready
- one-click pip install of reme-ai[core] as a Host job: per-job log file,
2s log-tail polling, cancel, and running-job restore after a page
reload; the optional mirror overrides pip only for that run
- interpreter detection now probes reme-ai per environment, so the
suggestion prefers a named conda env with a usable reme (an outdated
reme no longer outranks a ready environment)
- fix: DSH exposes these Remote methods in SRC mode, which parses the
method source; every method takes one plain `input` identifier
parameter (no defaults or destructuring) and the client sends
`{ args: { input } }`
This commit is contained in:
parent
6f1ecf06aa
commit
ad9a4c2888
10 changed files with 1357 additions and 31 deletions
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
||||
|
||||
import { safeError } from "../endpoint.js";
|
||||
import type { InstallStatus } from "../pyenv/install.js";
|
||||
import type { RemeCheck } from "../pyenv/reme.js";
|
||||
import type {
|
||||
PythonDetection,
|
||||
PythonEnv,
|
||||
|
|
@ -43,6 +45,44 @@ export const envEn = {
|
|||
originUnknown: "Unknown origin",
|
||||
errorsTitle: "Probe details",
|
||||
dependencyTitle: "ReMe dependency",
|
||||
dependencyHint:
|
||||
"The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
|
||||
interpreterLabel: "Interpreter",
|
||||
noInterpreter: "No interpreter selected",
|
||||
selectInterpreterFirst: "Select or install a Python interpreter above first.",
|
||||
recheck: "Check",
|
||||
checkingReme: "Checking ReMe installation…",
|
||||
checkRemeFailed: "ReMe check failed",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] extras",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "Not installed",
|
||||
upgradeRecommended: "upgrade recommended",
|
||||
coreOk: "Available",
|
||||
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
||||
cliFallback: "python wrapper fallback",
|
||||
installButton: "Install reme-ai[core]",
|
||||
upgradeButton: "Upgrade reme-ai",
|
||||
reinstall: "Reinstall reme-ai[core]",
|
||||
installConfirm:
|
||||
"Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
|
||||
confirmInstall: "Confirm install",
|
||||
cancel: "Cancel",
|
||||
installing: "Installing",
|
||||
cancelInstall: "Cancel install",
|
||||
waitingForOutput: "Waiting for pip output…",
|
||||
installSucceeded:
|
||||
"ReMe installed. Search, automatic memory and memory consolidation are now available.",
|
||||
installFailed: "Installation failed. See the log below.",
|
||||
installCancelled: "Installation cancelled.",
|
||||
dismiss: "Dismiss",
|
||||
mirrorLabel: "Package index",
|
||||
mirrorDefault: "Default (pip config)",
|
||||
mirrorPypi: "PyPI",
|
||||
mirrorTsinghua: "Tsinghua",
|
||||
mirrorAliyun: "Aliyun",
|
||||
condaHint:
|
||||
"In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
|
||||
configTitle: "ReMe parameters",
|
||||
controlTitle: "Service control",
|
||||
upcoming:
|
||||
|
|
@ -78,6 +118,43 @@ export const envZh: typeof envEn = {
|
|||
originUnknown: "来源未知",
|
||||
errorsTitle: "探测细节",
|
||||
dependencyTitle: "ReMe 依赖",
|
||||
dependencyHint:
|
||||
"本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
|
||||
interpreterLabel: "解释器",
|
||||
noInterpreter: "尚未选择解释器",
|
||||
selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
|
||||
recheck: "检测",
|
||||
checkingReme: "正在检测 ReMe 安装…",
|
||||
checkRemeFailed: "ReMe 检测失败",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] 依赖",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "未安装",
|
||||
upgradeRecommended: "建议升级",
|
||||
coreOk: "可用",
|
||||
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
||||
cliFallback: "回退到 python 包装",
|
||||
installButton: "安装 reme-ai[core]",
|
||||
upgradeButton: "升级 reme-ai",
|
||||
reinstall: "重装 reme-ai[core]",
|
||||
installConfirm:
|
||||
"将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
|
||||
confirmInstall: "确认安装",
|
||||
cancel: "取消",
|
||||
installing: "正在安装",
|
||||
cancelInstall: "取消安装",
|
||||
waitingForOutput: "等待 pip 输出…",
|
||||
installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
|
||||
installFailed: "安装失败,请查看下方日志。",
|
||||
installCancelled: "安装已取消。",
|
||||
dismiss: "关闭",
|
||||
mirrorLabel: "软件源",
|
||||
mirrorDefault: "默认(pip 配置)",
|
||||
mirrorPypi: "PyPI 官方",
|
||||
mirrorTsinghua: "清华镜像",
|
||||
mirrorAliyun: "阿里云镜像",
|
||||
condaHint:
|
||||
"conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
|
||||
configTitle: "ReMe 参数",
|
||||
controlTitle: "服务控制",
|
||||
upcoming: "该分区将随环境管理特性的后续里程碑提供。",
|
||||
|
|
@ -105,6 +182,20 @@ interface HealthState {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
interface CheckState {
|
||||
phase: "idle" | "ready" | "error";
|
||||
data?: RemeCheck;
|
||||
error?: string;
|
||||
/** The interpreter this result belongs to. */
|
||||
forPath: string;
|
||||
}
|
||||
|
||||
interface InstallState {
|
||||
phase: "idle" | "running" | "done";
|
||||
job?: InstallStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isLocalEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
|
|
@ -124,6 +215,20 @@ function parseDetection(value: unknown): PythonDetection {
|
|||
return value as unknown as PythonDetection;
|
||||
}
|
||||
|
||||
function parseRemeCheck(value: unknown): RemeCheck {
|
||||
if (typeof value !== "object" || value === null)
|
||||
throw new Error("Invalid ReMe check response");
|
||||
return value as unknown as RemeCheck;
|
||||
}
|
||||
|
||||
function parseInstallStatus(value: unknown): InstallStatus | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.id === "string" && typeof record.phase === "string"
|
||||
? (value as unknown as InstallStatus)
|
||||
: null;
|
||||
}
|
||||
|
||||
function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
|
||||
switch (origin.kind) {
|
||||
case "conda":
|
||||
|
|
@ -141,7 +246,15 @@ function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** Environment management panel: local service connection, Python, service. */
|
||||
function elapsedText(job: InstallStatus, now: number): string {
|
||||
const started = Date.parse(job.startedAt);
|
||||
if (!Number.isFinite(started) || now < started) return "";
|
||||
const seconds = Math.floor((now - started) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Environment management panel: connection, Python, dependency, service. */
|
||||
export function ReMeEnvironmentTab({
|
||||
scope,
|
||||
rpc,
|
||||
|
|
@ -160,9 +273,30 @@ export function ReMeEnvironmentTab({
|
|||
phase: "loading",
|
||||
endpoint: "",
|
||||
});
|
||||
const [check, setCheck] = useState<CheckState>({
|
||||
phase: "idle",
|
||||
forPath: "",
|
||||
});
|
||||
const [install, setInstall] = useState<InstallState>({ phase: "idle" });
|
||||
const [detectTick, setDetectTick] = useState(0);
|
||||
const [healthTick, setHealthTick] = useState(0);
|
||||
const [checkTick, setCheckTick] = useState(0);
|
||||
const [savingExe, setSavingExe] = useState("");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mirror, setMirror] = useState("");
|
||||
const [now, setNow] = useState(0);
|
||||
|
||||
// Derived values must precede hook dependency arrays.
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
const selectedExe = value?.pythonPath ?? "";
|
||||
const suggestedExe = detected?.suggested ?? "";
|
||||
const selectedPath = selectedExe || suggestedExe;
|
||||
const checking = Boolean(selectedPath) && check.forPath !== selectedPath;
|
||||
const checkData = check.forPath === selectedPath ? check.data : undefined;
|
||||
const checkError = check.forPath === selectedPath ? check.error : undefined;
|
||||
const local = isLocalEndpoint(endpoint);
|
||||
const healthCurrent =
|
||||
health.phase !== "loading" && health.endpoint === endpoint;
|
||||
|
||||
const loadDetection = useCallback(
|
||||
async (signal: AbortSignal, refresh: boolean) => {
|
||||
|
|
@ -170,7 +304,7 @@ export function ReMeEnvironmentTab({
|
|||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/python",
|
||||
{ args: { refresh } },
|
||||
{ args: { input: { refresh } } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
|
|
@ -231,9 +365,140 @@ export function ReMeEnvironmentTab({
|
|||
healthTick,
|
||||
]);
|
||||
|
||||
const ensureCheck = useCallback(
|
||||
async (signal: AbortSignal, pythonPath: string) => {
|
||||
if (!pythonPath) return;
|
||||
try {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/checkReme",
|
||||
{ args: { input: { pythonPath } } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
setCheck((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
data: parseRemeCheck(result.value),
|
||||
error: undefined,
|
||||
forPath: pythonPath,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!signal.aborted)
|
||||
setCheck((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
forPath: pythonPath,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[rpc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void ensureCheck(controller.signal, selectedPath);
|
||||
return () => controller.abort();
|
||||
}, [ensureCheck, selectedPath, checkTick]);
|
||||
|
||||
const restoreInstall = useCallback(
|
||||
async (signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/installStatus",
|
||||
{ args: {} },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok) return;
|
||||
const status = parseInstallStatus(result.value);
|
||||
if (status?.phase === "running")
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "running",
|
||||
job: status,
|
||||
}));
|
||||
} catch {
|
||||
// Restoring is best-effort; a later action reports real failures.
|
||||
}
|
||||
},
|
||||
[rpc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void restoreInstall(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [restoreInstall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (install.phase !== "running") return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/installStatus",
|
||||
{ args: {} },
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!result.ok) {
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "done",
|
||||
error: result.error.message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const status = parseInstallStatus(result.value);
|
||||
if (!status) {
|
||||
setInstall({ phase: "idle" });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "running") {
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "running",
|
||||
job: status,
|
||||
}));
|
||||
timer = setTimeout(() => void poll(), 2000);
|
||||
return;
|
||||
}
|
||||
setInstall((current) => ({ ...current, phase: "done", job: status }));
|
||||
if (status.phase === "succeeded") {
|
||||
setCheckTick((tick) => tick + 1);
|
||||
setDetectTick((tick) => tick + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "done",
|
||||
error: safeError(error),
|
||||
}));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [install.phase, rpc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (install.phase !== "running") return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [install.phase]);
|
||||
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
const select = (env: PythonEnv): void => {
|
||||
if (!value || !snapshot.writable || savingExe) return;
|
||||
setSavingExe(env.exe);
|
||||
|
|
@ -246,10 +511,36 @@ export function ReMeEnvironmentTab({
|
|||
.finally(() => setSavingExe(""));
|
||||
};
|
||||
|
||||
const local = isLocalEndpoint(endpoint);
|
||||
const selectedExe = value?.pythonPath ?? "";
|
||||
const healthCurrent =
|
||||
health.phase !== "loading" && health.endpoint === endpoint;
|
||||
const startInstall = async (upgrade: boolean): Promise<void> => {
|
||||
if (!selectedPath || install.phase === "running") return;
|
||||
setConfirming(false);
|
||||
try {
|
||||
const result = await rpc.call("/api", "remeEnv/install", {
|
||||
args: {
|
||||
input: { pythonPath: selectedPath, upgrade, indexUrl: mirror },
|
||||
},
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
const status = parseInstallStatus(result.value);
|
||||
setInstall(
|
||||
status ? { phase: "running", job: status } : { phase: "idle" },
|
||||
);
|
||||
} catch (error) {
|
||||
setInstall({ phase: "done", error: safeError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const cancelInstall = async (): Promise<void> => {
|
||||
const jobId = install.job?.id ?? "";
|
||||
try {
|
||||
await rpc.call("/api", "remeEnv/installCancel", {
|
||||
args: { input: { jobId } },
|
||||
});
|
||||
} catch {
|
||||
// Polling reflects the final phase either way.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reme-env">
|
||||
|
|
@ -339,13 +630,13 @@ export function ReMeEnvironmentTab({
|
|||
<label
|
||||
key={env.exe}
|
||||
className={`reme-env-python${
|
||||
selectedExe === env.exe ? " active" : ""
|
||||
selectedPath === env.exe ? " active" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="reme-python-interpreter"
|
||||
checked={selectedExe === env.exe}
|
||||
checked={selectedPath === env.exe}
|
||||
disabled={
|
||||
!env.ok || savingExe !== "" || !snapshot.writable
|
||||
}
|
||||
|
|
@ -354,14 +645,17 @@ export function ReMeEnvironmentTab({
|
|||
<span className="reme-env-python-main">
|
||||
<strong>
|
||||
{`Python ${env.version || "?"}`}
|
||||
{selectedExe !== env.exe &&
|
||||
{selectedPath !== env.exe &&
|
||||
env.exe === detected.suggested
|
||||
? ` · ${t("recommended")}`
|
||||
: ""}
|
||||
</strong>
|
||||
<small>{`${originLabel(t, env.origin)} · ${
|
||||
env.exe
|
||||
}`}</small>
|
||||
<small>
|
||||
{`${originLabel(t, env.origin)} · ${env.exe}`}
|
||||
{env.reme?.installed
|
||||
? ` · reme ${env.reme.version}`
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
className={`reme-status-badge ${env.ok ? "ok" : "bad"}`}
|
||||
|
|
@ -382,9 +676,222 @@ export function ReMeEnvironmentTab({
|
|||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("dependencyTitle")}</h4>
|
||||
<p className="reme-settings-muted reme-env-upcoming">
|
||||
{t("upcoming")}
|
||||
</p>
|
||||
<p className="reme-settings-muted">{t("dependencyHint")}</p>
|
||||
<div className="reme-env-row">
|
||||
<span className="reme-env-label">{t("interpreterLabel")}</span>
|
||||
<code className="reme-env-endpoint">
|
||||
{selectedPath || t("noInterpreter")}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!selectedPath}
|
||||
onClick={() => {
|
||||
setCheck((current) => ({ ...current, phase: "idle" }));
|
||||
setCheckTick((tick) => tick + 1);
|
||||
}}
|
||||
>
|
||||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{!selectedPath ? (
|
||||
<p className="reme-settings-muted">
|
||||
{t("selectInterpreterFirst")}
|
||||
</p>
|
||||
) : checking ? (
|
||||
<p className="reme-settings-muted">{t("checkingReme")}</p>
|
||||
) : null}
|
||||
{checkError ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("checkRemeFailed")}
|
||||
{checkError ? `: ${checkError}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{checkData ? (
|
||||
<div className="reme-env-checks">
|
||||
<div className="reme-env-check">
|
||||
<span>{t("remeLabel")}</span>
|
||||
{checkData.installed ? (
|
||||
<span
|
||||
className={`reme-status-badge ${
|
||||
checkData.versionOk ? "ok" : "progress"
|
||||
}`}
|
||||
>
|
||||
<i />
|
||||
{`reme-ai ${checkData.version}`}
|
||||
{checkData.versionOk
|
||||
? ""
|
||||
: ` · ${t("upgradeRecommended")}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="reme-status-badge bad">
|
||||
<i />
|
||||
{t("notInstalled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="reme-env-check">
|
||||
<span>{t("coreLabel")}</span>
|
||||
{checkData.installed ? (
|
||||
<span
|
||||
className={`reme-status-badge ${
|
||||
checkData.hasCore ? "ok" : "progress"
|
||||
}`}
|
||||
>
|
||||
<i />
|
||||
{checkData.hasCore ? t("coreOk") : t("coreMissing")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="reme-env-check">
|
||||
<span>{t("cliLabel")}</span>
|
||||
{checkData.installed ? (
|
||||
<span
|
||||
className={`reme-status-badge ${
|
||||
checkData.cliPath ? "ok" : "progress"
|
||||
}`}
|
||||
>
|
||||
<i />
|
||||
{checkData.cliPath || t("cliFallback")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{checkData && install.phase !== "running" ? (
|
||||
<div className="reme-env-actions">
|
||||
<label className="reme-env-mirror">
|
||||
<span>{t("mirrorLabel")}</span>
|
||||
<select
|
||||
value={mirror}
|
||||
onChange={(event) => setMirror(event.target.value)}
|
||||
>
|
||||
<option value="">{t("mirrorDefault")}</option>
|
||||
<option value="https://pypi.org/simple">
|
||||
{t("mirrorPypi")}
|
||||
</option>
|
||||
<option value="https://pypi.tuna.tsinghua.edu.cn/simple">
|
||||
{t("mirrorTsinghua")}
|
||||
</option>
|
||||
<option value="https://mirrors.aliyun.com/pypi/simple">
|
||||
{t("mirrorAliyun")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>
|
||||
{t("installConfirm").replace(
|
||||
"{path}",
|
||||
selectedPath || "",
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => void startInstall(!checkData.versionOk)}
|
||||
>
|
||||
{t("confirmInstall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : checkData.installed && checkData.versionOk ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("reinstall")}
|
||||
</button>
|
||||
) : checkData.installed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("upgradeButton")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("installButton")}
|
||||
</button>
|
||||
)}
|
||||
<p className="reme-settings-muted reme-env-hint-inline">
|
||||
{t("condaHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "running" && install.job ? (
|
||||
<div className="reme-env-progress">
|
||||
<div className="reme-env-row">
|
||||
<span className="reme-status-badge progress">
|
||||
<i />
|
||||
{t("installing")}
|
||||
{elapsedText(install.job, now || Date.now())
|
||||
? ` · ${elapsedText(install.job, now || Date.now())}`
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
{t("cancelInstall")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="reme-env-log">
|
||||
{install.job.logTail || t("waitingForOutput")}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "done" ? (
|
||||
<div className="reme-env-result">
|
||||
{install.job?.phase === "succeeded" ? (
|
||||
<p className="reme-settings-success">
|
||||
{t("installSucceeded")}
|
||||
</p>
|
||||
) : null}
|
||||
{install.job?.phase === "cancelled" ? (
|
||||
<p className="reme-settings-muted">{t("installCancelled")}</p>
|
||||
) : null}
|
||||
{install.job?.phase === "failed" ? (
|
||||
<>
|
||||
<p className="reme-settings-error">{t("installFailed")}</p>
|
||||
{install.job.logTail ? (
|
||||
<pre className="reme-env-log">{install.job.logTail}</pre>
|
||||
) : null}
|
||||
<p className="reme-settings-muted">{install.job.logPath}</p>
|
||||
</>
|
||||
) : null}
|
||||
{install.error ? (
|
||||
<p className="reme-settings-error">{install.error}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setInstall({ phase: "idle" });
|
||||
setConfirming(false);
|
||||
}}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("configTitle")}</h4>
|
||||
|
|
|
|||
|
|
@ -53,5 +53,18 @@ export const styles = `
|
|||
.reme-env-hint strong{color:var(--dsw-alias-label-primary)}
|
||||
.reme-env-upcoming{margin:0;padding:4px 0 12px}
|
||||
.reme-env .reme-settings-details{margin-top:8px}
|
||||
.reme-env-checks{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px}
|
||||
.reme-env-check{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-check>span:first-child{flex:none;font-weight:500;color:var(--dsw-alias-label-primary)}
|
||||
.reme-env-check .reme-status-badge{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
|
||||
.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
|
||||
.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
|
||||
.reme-env-hint-inline{flex:1 0 100%;margin:0}
|
||||
.reme-env-progress{padding-top:10px}
|
||||
.reme-env-log{max-height:220px;overflow:auto;margin:6px 0 0;border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.reme-env-result{padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
||||
|
||||
import type { InstallStatus } from "./pyenv/install.js";
|
||||
import type { RemeCheck } from "./pyenv/reme.js";
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
import type { ReMeEnvManager } from "./env-manager.js";
|
||||
|
||||
|
|
@ -11,7 +13,14 @@ declare module "@deepseek-ai/cordis" {
|
|||
}
|
||||
}
|
||||
|
||||
/** Read-only Host projection consumed by the environment management tab. */
|
||||
/**
|
||||
* Host projection for the environment management tab.
|
||||
*
|
||||
* DSH exposes these methods in SRC mode (no generated typert artifact), which
|
||||
* parses the method source: every method takes exactly one plain identifier
|
||||
* parameter named `input`, with no destructuring, defaults, or rest, and the
|
||||
* client sends `{ args: { input: ... } }`.
|
||||
*/
|
||||
export class ReMeEnvGateway extends TypertRemoteService {
|
||||
static inject = ["remeEnv"];
|
||||
|
||||
|
|
@ -21,8 +30,46 @@ export class ReMeEnvGateway extends TypertRemoteService {
|
|||
|
||||
/** Detect local Python interpreters; cached briefly unless refreshed. */
|
||||
@Remote("python")
|
||||
async python(input: { refresh?: boolean } = {}): Promise<PythonDetection> {
|
||||
return this.ctx.remeEnv.detectPython(input);
|
||||
async python(
|
||||
input: { refresh?: boolean } | undefined,
|
||||
): Promise<PythonDetection> {
|
||||
return this.ctx.remeEnv.detectPython(input ?? {});
|
||||
}
|
||||
|
||||
/** Inspect reme-ai, the [core] extras and the CLI for one interpreter. */
|
||||
@Remote("checkReme")
|
||||
async checkReme(
|
||||
input: { pythonPath?: string } | undefined,
|
||||
): Promise<RemeCheck> {
|
||||
return this.ctx.remeEnv.checkReme(input?.pythonPath ?? "");
|
||||
}
|
||||
|
||||
/** Start the one-click pip install and return the running job. */
|
||||
@Remote("install")
|
||||
async install(
|
||||
input:
|
||||
| { pythonPath?: string; upgrade?: boolean; indexUrl?: string }
|
||||
| undefined,
|
||||
): Promise<InstallStatus> {
|
||||
return this.ctx.remeEnv.install({
|
||||
pythonPath: input?.pythonPath ?? "",
|
||||
upgrade: input?.upgrade === true,
|
||||
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest (or named) install job progress, including the log tail. */
|
||||
@Remote("installStatus")
|
||||
async installStatus(
|
||||
input: { jobId?: string } | undefined,
|
||||
): Promise<InstallStatus | null> {
|
||||
return this.ctx.remeEnv.installStatus(input?.jobId ?? "");
|
||||
}
|
||||
|
||||
/** Ask a running install to stop; polling observes the cancelled phase. */
|
||||
@Remote("installCancel")
|
||||
async installCancel(input: { jobId?: string } | undefined): Promise<boolean> {
|
||||
return this.ctx.remeEnv.installCancel(input?.jobId ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,37 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { detectPythonEnvs } from "./pyenv/detect.js";
|
||||
import {
|
||||
createInstallSpawner,
|
||||
InstallJobs,
|
||||
type InstallRequest,
|
||||
type InstallStatus,
|
||||
} from "./pyenv/install.js";
|
||||
import { checkReme, type RemeCheck } from "./pyenv/reme.js";
|
||||
import { createNodeRunner } from "./pyenv/runner.js";
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Host-side local environment manager. Read-only in this milestone: it
|
||||
* discovers Python interpreters and caches the answer briefly so opening
|
||||
* the environment tab does not re-run probes on every render.
|
||||
* Host-side local environment manager: read-only Python detection plus the
|
||||
* reme-ai dependency check and the one-click pip install job.
|
||||
*/
|
||||
export class ReMeEnvManager {
|
||||
private cache?: { at: number; detection: PythonDetection };
|
||||
private readonly installs: InstallJobs;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(env: NodeJS.ProcessEnv = process.env) {
|
||||
this.env = env;
|
||||
const home = env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
||||
this.installs = new InstallJobs({
|
||||
spawner: createInstallSpawner(env),
|
||||
logDir: join(home, "reme-memory"),
|
||||
});
|
||||
}
|
||||
|
||||
async detectPython(
|
||||
options: { refresh?: boolean } = {},
|
||||
|
|
@ -21,13 +39,36 @@ export class ReMeEnvManager {
|
|||
if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS)
|
||||
return this.cache.detection;
|
||||
const detection = await detectPythonEnvs({
|
||||
run: createNodeRunner(process.env),
|
||||
run: createNodeRunner(this.env),
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
env: this.env,
|
||||
homedir: homedir(),
|
||||
exists: existsSync,
|
||||
});
|
||||
this.cache = { at: Date.now(), detection };
|
||||
return detection;
|
||||
}
|
||||
|
||||
async checkReme(pythonPath: string): Promise<RemeCheck> {
|
||||
return checkReme(
|
||||
{
|
||||
run: createNodeRunner(this.env),
|
||||
platform: process.platform,
|
||||
exists: existsSync,
|
||||
},
|
||||
pythonPath,
|
||||
);
|
||||
}
|
||||
|
||||
install(request: InstallRequest): InstallStatus {
|
||||
return this.installs.start(request);
|
||||
}
|
||||
|
||||
installStatus(jobId?: string): InstallStatus | null {
|
||||
return this.installs.status(jobId);
|
||||
}
|
||||
|
||||
installCancel(jobId?: string): boolean {
|
||||
return this.installs.cancel(jobId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { join as posixJoin } from "node:path/posix";
|
|||
|
||||
import { safeError } from "../endpoint.js";
|
||||
import type { RunCommand, RunResult } from "./runner.js";
|
||||
import { probeReme, remeVersionAtLeast } from "./reme.js";
|
||||
import type { PythonDetection, PythonEnv, PythonOrigin } from "./types.js";
|
||||
|
||||
/** ReMe requires Python 3.11 or newer. */
|
||||
|
|
@ -340,13 +341,36 @@ function compareEnvs(a: PythonEnv, b: PythonEnv): number {
|
|||
return a.exe.localeCompare(b.exe);
|
||||
}
|
||||
|
||||
/** Prefer a named conda env, then any usable interpreter. */
|
||||
/** Prefer a named conda env with a usable reme, then any reme env. */
|
||||
export function suggestInterpreter(envs: PythonEnv[]): string {
|
||||
const usable = envs.filter((env) => env.ok);
|
||||
const named = usable.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
|
||||
// An installed-but-outdated reme does not make an interpreter ready.
|
||||
const withReme = usable.filter(
|
||||
(env) =>
|
||||
env.reme?.installed === true && remeVersionAtLeast(env.reme.version),
|
||||
);
|
||||
const named = (list: PythonEnv[]): PythonEnv | undefined =>
|
||||
list.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
|
||||
);
|
||||
return (
|
||||
(named(withReme) ?? withReme[0] ?? named(usable) ?? usable[0])?.exe ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
/** Probe reme-ai in every interpreter so the list shows setup state. */
|
||||
async function enrichWithReme(envs: PythonEnv[], io: DetectIO): Promise<void> {
|
||||
const queue = [...envs];
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const env = queue.shift();
|
||||
if (!env) return;
|
||||
env.reme = await probeReme(io, env.exe);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(6, envs.length) }, () => worker()),
|
||||
);
|
||||
return (named ?? usable[0])?.exe ?? "";
|
||||
}
|
||||
|
||||
async function verifyCandidates(
|
||||
|
|
@ -512,6 +536,7 @@ export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
|
|||
});
|
||||
}
|
||||
const envs = [...merged.values()];
|
||||
await enrichWithReme(envs, io);
|
||||
envs.sort(compareEnvs);
|
||||
return {
|
||||
platform: io.platform,
|
||||
|
|
|
|||
241
integrations/dsh/src/pyenv/install.ts
Normal file
241
integrations/dsh/src/pyenv/install.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
|
||||
export type InstallPhase = "running" | "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface InstallRequest {
|
||||
/** Absolute interpreter that receives the packages. */
|
||||
pythonPath: string;
|
||||
/** Upgrade an existing installation to the newest satisfying version. */
|
||||
upgrade?: boolean;
|
||||
/** Optional package index URL (mirror); empty keeps the user's pip config. */
|
||||
indexUrl?: string;
|
||||
}
|
||||
|
||||
export interface InstallStatus {
|
||||
id: string;
|
||||
phase: InstallPhase;
|
||||
pythonPath: string;
|
||||
/** Effective command line, for display and diagnostics. */
|
||||
command: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
logPath: string;
|
||||
/** Tail of the install log; safe to poll. */
|
||||
logTail: string;
|
||||
}
|
||||
|
||||
export interface InstallChild {
|
||||
pid: number | undefined;
|
||||
onExit(callback: (code: number | null) => void): void;
|
||||
kill(): void;
|
||||
}
|
||||
|
||||
export interface InstallSpawner {
|
||||
start(options: {
|
||||
exe: string;
|
||||
args: string[];
|
||||
logPath: string;
|
||||
}): InstallChild;
|
||||
}
|
||||
|
||||
/** Compose the pip invocation for `reme-ai[core]`. */
|
||||
export function pipArgs(request: InstallRequest): string[] {
|
||||
const args = [
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
];
|
||||
if (request.upgrade) args.push("-U");
|
||||
const indexUrl = (request.indexUrl ?? "").trim();
|
||||
if (indexUrl) {
|
||||
if (!/^https?:\/\/\S+$/i.test(indexUrl))
|
||||
throw new TypeError("Package index must be an http(s) URL");
|
||||
args.push("--index-url", indexUrl);
|
||||
}
|
||||
args.push("reme-ai[core]");
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Last `maxLines` non-empty lines of a log file, normalized to "\n". */
|
||||
export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
|
||||
try {
|
||||
const buffer = readFileSync(path);
|
||||
const text = buffer
|
||||
.subarray(Math.max(0, buffer.length - maxBytes))
|
||||
.toString("utf8")
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n");
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.slice(-maxLines)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn pip with stdout/stderr appended to a per-job log file. */
|
||||
export function createInstallSpawner(
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): InstallSpawner {
|
||||
return {
|
||||
start({ exe, args, logPath }) {
|
||||
const fd = openSync(logPath, "a");
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...base,
|
||||
PYTHONIOENCODING: "utf-8",
|
||||
PYTHONUTF8: "1",
|
||||
PYTHONUNBUFFERED: "1",
|
||||
},
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
// The child owns its inherited handle; the parent copy is no longer needed.
|
||||
closeSync(fd);
|
||||
}
|
||||
return {
|
||||
pid: child.pid,
|
||||
onExit(callback) {
|
||||
let settled = false;
|
||||
const finish = (code: number | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
callback(code);
|
||||
};
|
||||
child.on("close", finish);
|
||||
child.on("error", () => finish(null));
|
||||
},
|
||||
kill() {
|
||||
if (child.pid !== undefined && process.platform === "win32") {
|
||||
// Tree-kill: pip's build backends are child processes.
|
||||
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
phase: InstallPhase;
|
||||
pythonPath: string;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
logPath: string;
|
||||
child: InstallChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-running pip install at a time per interpreter. Jobs live in Host
|
||||
* memory and write a log file, so closing the browser keeps them running and
|
||||
* a running job can be restored by polling the latest status.
|
||||
*/
|
||||
export class InstallJobs {
|
||||
private readonly jobs = new Map<string, Job>();
|
||||
private latest: string | undefined;
|
||||
private sequence = 0;
|
||||
|
||||
constructor(
|
||||
private readonly options: { spawner: InstallSpawner; logDir: string },
|
||||
) {}
|
||||
|
||||
start(request: InstallRequest): InstallStatus {
|
||||
const pythonPath = (request.pythonPath ?? "").trim();
|
||||
if (!pythonPath || !isAbsolute(pythonPath))
|
||||
throw new TypeError(
|
||||
"Install requires an absolute Python interpreter path",
|
||||
);
|
||||
const running = [...this.jobs.values()].find(
|
||||
(job) => job.phase === "running" && job.pythonPath === pythonPath,
|
||||
);
|
||||
if (running) return this.snapshot(running);
|
||||
const args = pipArgs(request);
|
||||
const id = `install-${Date.now().toString(
|
||||
36,
|
||||
)}-${(this.sequence += 1).toString(36)}`;
|
||||
mkdirSync(this.options.logDir, { recursive: true });
|
||||
const logPath = join(this.options.logDir, `${id}.log`);
|
||||
const child = this.options.spawner.start({
|
||||
exe: pythonPath,
|
||||
args,
|
||||
logPath,
|
||||
});
|
||||
const job: Job = {
|
||||
id,
|
||||
phase: "running",
|
||||
pythonPath,
|
||||
command: [pythonPath, ...args].join(" "),
|
||||
startedAt: new Date().toISOString(),
|
||||
logPath,
|
||||
child,
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
this.latest = id;
|
||||
child.onExit((code) => {
|
||||
if (job.phase !== "running") return;
|
||||
job.phase = code === 0 ? "succeeded" : "failed";
|
||||
job.exitCode = code;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
});
|
||||
return this.snapshot(job);
|
||||
}
|
||||
|
||||
/** Status of one job, defaulting to the most recent; null when unknown. */
|
||||
status(jobId?: string): InstallStatus | null {
|
||||
const job = this.jobs.get(this.key(jobId));
|
||||
return job ? this.snapshot(job) : null;
|
||||
}
|
||||
|
||||
/** Ask pip to stop; the polling client observes the cancelled phase. */
|
||||
cancel(jobId?: string): boolean {
|
||||
const job = this.jobs.get(this.key(jobId));
|
||||
if (!job || job.phase !== "running") return false;
|
||||
job.phase = "cancelled";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
job.child.kill();
|
||||
return true;
|
||||
}
|
||||
|
||||
private key(jobId?: string): string {
|
||||
const trimmed = (jobId ?? "").trim();
|
||||
return trimmed || this.latest || "";
|
||||
}
|
||||
|
||||
/** Serialize only JSON-safe values; optional fields are omitted when unset. */
|
||||
private snapshot(job: Job): InstallStatus {
|
||||
return {
|
||||
id: job.id,
|
||||
phase: job.phase,
|
||||
pythonPath: job.pythonPath,
|
||||
command: job.command,
|
||||
startedAt: job.startedAt,
|
||||
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
|
||||
...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
|
||||
logPath: job.logPath,
|
||||
logTail: tailLog(job.logPath),
|
||||
};
|
||||
}
|
||||
}
|
||||
141
integrations/dsh/src/pyenv/reme.ts
Normal file
141
integrations/dsh/src/pyenv/reme.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { dirname, isAbsolute, join } from "node:path";
|
||||
|
||||
import type { RunCommand } from "./runner.js";
|
||||
|
||||
/** Minimum reme-ai version this DSH integration is tested against. */
|
||||
export const MINIMUM_REME = "0.4.0";
|
||||
|
||||
const REME_VERSION_SNIPPET =
|
||||
"import importlib.metadata as m;print(m.version('reme-ai'))";
|
||||
|
||||
export interface RemeIO {
|
||||
run: RunCommand;
|
||||
platform: string;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface RemeProbe {
|
||||
installed: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RemeCheck extends RemeProbe {
|
||||
/** True when the installed version satisfies {@link MINIMUM_REME}. */
|
||||
versionOk: boolean;
|
||||
/** True when the AgentScope `[core]` extras import (agentscope) works. */
|
||||
hasCore: boolean;
|
||||
/** Absolute reme CLI path, or "" when only the python wrapper is available. */
|
||||
cliPath: string;
|
||||
/** Non-empty when the interpreter itself could not be probed. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Numeric dotted-version comparison; returns -1, 0 or 1. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const right = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (left[index] ?? 0) - (right[index] ?? 0);
|
||||
if (diff !== 0) return Math.sign(diff);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Strict numeric version gate; empty or prefixed versions never pass. */
|
||||
export function remeVersionAtLeast(
|
||||
version: string,
|
||||
minimum: string = MINIMUM_REME,
|
||||
): boolean {
|
||||
const normalized = version.trim();
|
||||
if (!/^\d+(?:\.\d+)+$/.test(normalized)) return false;
|
||||
return compareVersions(normalized, minimum) >= 0;
|
||||
}
|
||||
|
||||
/** Read a bare version line out of pip/import noise. */
|
||||
export function parseRemeVersion(text: string): string {
|
||||
const match = /^\s*(\d+(?:\.\d+)+)\s*$/m.exec(text);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
/** Ask one interpreter for its installed reme-ai metadata version. */
|
||||
export async function probeReme(
|
||||
io: RemeIO,
|
||||
pythonPath: string,
|
||||
): Promise<RemeProbe> {
|
||||
try {
|
||||
const result = await io.run(pythonPath, ["-c", REME_VERSION_SNIPPET], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (result.error || result.code !== 0)
|
||||
return { installed: false, version: "" };
|
||||
const version = parseRemeVersion(result.stdout);
|
||||
return { installed: version !== "", version };
|
||||
} catch {
|
||||
return { installed: false, version: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Locate the console script installed beside the interpreter. */
|
||||
export function remeCliPath(io: RemeIO, pythonPath: string): string {
|
||||
const directory = dirname(pythonPath);
|
||||
// venv interpreters live inside the scripts/bin directory; conda ones do not.
|
||||
const scriptsDir =
|
||||
io.platform === "win32"
|
||||
? /[\\/]scripts$/i.test(directory)
|
||||
? directory
|
||||
: join(directory, "Scripts")
|
||||
: directory;
|
||||
const candidate =
|
||||
io.platform === "win32"
|
||||
? join(scriptsDir, "reme.exe")
|
||||
: join(scriptsDir, "reme");
|
||||
return io.exists(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full dependency check for one interpreter: reme-ai presence/version, the
|
||||
* `[core]` AgentScope import, and the CLI entry used by service management.
|
||||
*/
|
||||
export async function checkReme(
|
||||
io: RemeIO,
|
||||
pythonPath: string,
|
||||
): Promise<RemeCheck> {
|
||||
const path = pythonPath.trim();
|
||||
if (!path || !isAbsolute(path)) {
|
||||
return {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: "Python interpreter path must be absolute",
|
||||
};
|
||||
}
|
||||
const probe = await probeReme(io, path);
|
||||
if (!probe.installed) {
|
||||
return {
|
||||
...probe,
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
let hasCore = false;
|
||||
try {
|
||||
const core = await io.run(path, ["-c", "import agentscope"], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
hasCore = !core.error && core.code === 0;
|
||||
} catch {
|
||||
hasCore = false;
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
versionOk: remeVersionAtLeast(probe.version),
|
||||
hasCore,
|
||||
cliPath: remeCliPath(io, path),
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ export interface PythonEnv {
|
|||
origin: PythonOrigin;
|
||||
/** Human-readable discovery channels, e.g. "conda env list; PATH". */
|
||||
source: string;
|
||||
/** reme-ai installation facts, probed for the interpreter recommendation. */
|
||||
reme?: { installed: boolean; version: string };
|
||||
}
|
||||
|
||||
export interface PythonDetection {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ const VERIFY_ARG0 = "-c";
|
|||
function verifyRule(exe, version, base) {
|
||||
return {
|
||||
match: (actual, args) =>
|
||||
actual.toLowerCase() === exe.toLowerCase() && args[0] === VERIFY_ARG0,
|
||||
actual.toLowerCase() === exe.toLowerCase() &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("import sys"),
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: `note\n{"exe":"${exe.replaceAll(
|
||||
|
|
@ -45,6 +47,19 @@ function verifyRule(exe, version, base) {
|
|||
};
|
||||
}
|
||||
|
||||
/** reme-ai metadata probe; version null means "not installed". */
|
||||
function remeRule(exe, version) {
|
||||
return {
|
||||
match: (actual, args) =>
|
||||
actual.toLowerCase() === exe.toLowerCase() &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("importlib.metadata"),
|
||||
result: version
|
||||
? { code: 0, stdout: `${version}\n`, stderr: "" }
|
||||
: { code: 1, stdout: "", stderr: "PackageNotFoundError" },
|
||||
};
|
||||
}
|
||||
|
||||
function windowsFixture() {
|
||||
const miniconda = "D:\\ProgramFils\\Miniconda";
|
||||
const remeServe = `${miniconda}\\envs\\reme-serve`;
|
||||
|
|
@ -152,6 +167,12 @@ function windowsFixture() {
|
|||
verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"),
|
||||
verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"),
|
||||
verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage),
|
||||
remeRule(`${miniconda}\\python.exe`, "0.4.0.9"),
|
||||
remeRule(`${remeServe}\\python.exe`, "0.4.0.9"),
|
||||
remeRule(`${old38}\\python.exe`, null),
|
||||
remeRule("C:\\Python312\\python.exe", "0.4.1.11"),
|
||||
remeRule("C:\\Python38\\python.exe", null),
|
||||
remeRule(`${storePackage}\\python.exe`, null),
|
||||
];
|
||||
return {
|
||||
rules,
|
||||
|
|
@ -372,9 +393,14 @@ test("detects, classifies and suggests Windows interpreters end to end", async (
|
|||
assert.ok(store);
|
||||
assert.equal(store.origin.kind, "windows-store");
|
||||
assert.equal(store.ok, true);
|
||||
|
||||
assert.deepEqual(base.reme, { installed: true, version: "0.4.0.9" });
|
||||
assert.deepEqual(named.reme, { installed: true, version: "0.4.0.9" });
|
||||
assert.deepEqual(pythonOrg.reme, { installed: true, version: "0.4.1.11" });
|
||||
assert.deepEqual(store.reme, { installed: false, version: "" });
|
||||
});
|
||||
|
||||
test("suggests nothing usable by preferring named conda envs then any usable interpreter", () => {
|
||||
test("suggests nothing usable by preferring reme installs, then named conda envs", () => {
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
|
|
@ -413,6 +439,50 @@ test("suggests nothing usable by preferring named conda envs then any usable int
|
|||
]),
|
||||
"c",
|
||||
);
|
||||
// A reme-enabled interpreter outranks a bare named conda env.
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "tools", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: false, version: "" },
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "python.org" },
|
||||
source: "",
|
||||
reme: { installed: true, version: "0.4.0.9" },
|
||||
},
|
||||
]),
|
||||
"b",
|
||||
);
|
||||
// An outdated reme does not make an interpreter ready.
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "tools", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: true, version: "0.3.1.8" },
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "other", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: false, version: "" },
|
||||
},
|
||||
]),
|
||||
"a",
|
||||
);
|
||||
assert.equal(suggestInterpreter([]), "");
|
||||
});
|
||||
|
||||
|
|
@ -457,7 +527,9 @@ test("detects Unix interpreters and merges paths that resolve together", async (
|
|||
// /usr/bin/python is a shim whose sys.executable reports python3.
|
||||
{
|
||||
match: (actual, args) =>
|
||||
actual === "/usr/bin/python" && args[0] === VERIFY_ARG0,
|
||||
actual === "/usr/bin/python" &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("import sys"),
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n',
|
||||
|
|
@ -474,6 +546,10 @@ test("detects Unix interpreters and merges paths that resolve together", async (
|
|||
"3.11.9",
|
||||
"/home/u/miniconda3/envs/reme",
|
||||
),
|
||||
remeRule("/usr/bin/python3", null),
|
||||
remeRule("/usr/bin/python", null),
|
||||
remeRule("/home/u/miniconda3/bin/python3", "0.4.1.11"),
|
||||
remeRule("/home/u/miniconda3/envs/reme/bin/python3", "0.4.1.11"),
|
||||
];
|
||||
const detection = await detectPythonEnvs({
|
||||
run: fakeRun(rules),
|
||||
|
|
|
|||
233
integrations/dsh/tests/env-reme.test.mjs
Normal file
233
integrations/dsh/tests/env-reme.test.mjs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
checkReme,
|
||||
compareVersions,
|
||||
parseRemeVersion,
|
||||
remeVersionAtLeast,
|
||||
} from "../dist/pyenv/reme.js";
|
||||
import { InstallJobs, pipArgs, tailLog } from "../dist/pyenv/install.js";
|
||||
|
||||
const SNIPPETS = {
|
||||
version: "importlib.metadata",
|
||||
core: "import agentscope",
|
||||
};
|
||||
|
||||
function remeIO(handlers, platform = "win32") {
|
||||
return {
|
||||
run: async (exe, args) => {
|
||||
const handler = handlers[exe];
|
||||
if (!handler) return { code: 1, stdout: "", stderr: "no handler" };
|
||||
return handler(args);
|
||||
},
|
||||
platform,
|
||||
exists: (path) =>
|
||||
path.endsWith("Scripts\\reme.exe") || path.endsWith("bin\\reme"),
|
||||
};
|
||||
}
|
||||
|
||||
test("compares dotted versions across ReMe's four-segment scheme", () => {
|
||||
assert.equal(compareVersions("0.4.0.9", "0.4.0"), 1);
|
||||
assert.equal(compareVersions("0.4.0", "0.4.0"), 0);
|
||||
assert.equal(compareVersions("0.3.9.9", "0.4.0"), -1);
|
||||
assert.equal(compareVersions("1.0", "0.4.0.9"), 1);
|
||||
assert.equal(remeVersionAtLeast("0.4.0.9"), true);
|
||||
assert.equal(remeVersionAtLeast("0.4.0"), true);
|
||||
assert.equal(remeVersionAtLeast("0.4.1.11"), true);
|
||||
assert.equal(remeVersionAtLeast("0.3.2"), false);
|
||||
assert.equal(remeVersionAtLeast(""), false);
|
||||
assert.equal(remeVersionAtLeast("v0.4.0"), false);
|
||||
});
|
||||
|
||||
test("parses a version line out of noisy metadata output", () => {
|
||||
assert.equal(parseRemeVersion("0.4.0.9\n"), "0.4.0.9");
|
||||
assert.equal(parseRemeVersion("warning\n0.4.0.9\ntrailer"), "0.4.0.9");
|
||||
assert.equal(parseRemeVersion("PackageNotFoundError"), "");
|
||||
assert.equal(parseRemeVersion(""), "");
|
||||
});
|
||||
|
||||
test("checks an installed interpreter including [core] and the CLI", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.4.0.9\n", stderr: "" }
|
||||
: { code: 0, stdout: "", stderr: "" },
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, true);
|
||||
assert.equal(check.version, "0.4.0.9");
|
||||
assert.equal(check.versionOk, true);
|
||||
assert.equal(check.hasCore, true);
|
||||
assert.equal(check.cliPath, "C:\\env\\Scripts\\reme.exe");
|
||||
assert.equal(check.error, "");
|
||||
});
|
||||
|
||||
test("finds the CLI for venv interpreters that live inside Scripts", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\venv\\Scripts\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.4.1.1\n", stderr: "" }
|
||||
: { code: 0, stdout: "", stderr: "" },
|
||||
});
|
||||
io.exists = (path) => path.toLowerCase() === "c:\\venv\\scripts\\reme.exe";
|
||||
const check = await checkReme(io, "C:\\venv\\Scripts\\python.exe");
|
||||
assert.equal(check.cliPath, "C:\\venv\\Scripts\\reme.exe");
|
||||
});
|
||||
|
||||
test("flags old versions and missing [core] extras", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.3.2\n", stderr: "" }
|
||||
: { code: 1, stdout: "", stderr: "ModuleNotFoundError" },
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, true);
|
||||
assert.equal(check.versionOk, false);
|
||||
assert.equal(check.hasCore, false);
|
||||
});
|
||||
|
||||
test("reports a missing installation without core or CLI", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": () => ({ code: 1, stdout: "", stderr: "not found" }),
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, false);
|
||||
assert.equal(check.versionOk, false);
|
||||
assert.equal(check.hasCore, false);
|
||||
assert.equal(check.cliPath, "");
|
||||
});
|
||||
|
||||
test("rejects relative interpreter paths", async () => {
|
||||
const check = await checkReme(remeIO({}), "python.exe");
|
||||
assert.equal(check.installed, false);
|
||||
assert.match(check.error, /absolute/);
|
||||
});
|
||||
|
||||
test("composes pip arguments for install, upgrade and mirrors", () => {
|
||||
assert.deepEqual(pipArgs({ pythonPath: "C:\\env\\python.exe" }), [
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
"reme-ai[core]",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
pipArgs({ pythonPath: "C:\\env\\python.exe", upgrade: true }),
|
||||
[
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
"-U",
|
||||
"reme-ai[core]",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
pipArgs({
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
}).slice(-3),
|
||||
[
|
||||
"--index-url",
|
||||
"https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
"reme-ai[core]",
|
||||
],
|
||||
);
|
||||
assert.throws(() =>
|
||||
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("tails a log with CRLF and progress carriage returns", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-install-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, "install.log");
|
||||
writeFileSync(path, "line1\r\nline2\r\nprogress 40%\rprogress 80%\rline3\n");
|
||||
const tail = tailLog(path);
|
||||
assert.ok(tail.includes("line1"));
|
||||
assert.ok(tail.includes("line3"));
|
||||
assert.ok(tail.includes("progress 80%"));
|
||||
assert.equal(tailLog(join(directory, "missing.log")), "");
|
||||
});
|
||||
|
||||
function fakeSpawner() {
|
||||
const children = [];
|
||||
const spawner = {
|
||||
start() {
|
||||
const child = {
|
||||
pid: 4000 + children.length,
|
||||
killed: false,
|
||||
onExit(callback) {
|
||||
child.exit = callback;
|
||||
},
|
||||
kill() {
|
||||
child.killed = true;
|
||||
},
|
||||
};
|
||||
children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return { spawner, children };
|
||||
}
|
||||
|
||||
function makeJobs(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-jobs-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { spawner, children } = fakeSpawner();
|
||||
const jobs = new InstallJobs({ spawner, logDir: directory });
|
||||
return { jobs, children, directory };
|
||||
}
|
||||
|
||||
test("tracks a successful pip job through exit", (t) => {
|
||||
const { jobs, children, directory } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(started.phase, "running");
|
||||
assert.ok(started.logPath.startsWith(directory));
|
||||
assert.match(started.command, /reme-ai\[core\]/);
|
||||
assert.equal(jobs.status()?.id, started.id);
|
||||
|
||||
children[0].exit(0);
|
||||
const done = jobs.status(started.id);
|
||||
assert.equal(done?.phase, "succeeded");
|
||||
assert.equal(done?.exitCode, 0);
|
||||
assert.ok(done?.finishedAt);
|
||||
});
|
||||
|
||||
test("tracks a failed pip job and keeps the log tail", (t) => {
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
writeFileSync(started.logPath, "ERROR: Could not find a version\n");
|
||||
children[0].exit(1);
|
||||
const done = jobs.status(started.id);
|
||||
assert.equal(done?.phase, "failed");
|
||||
assert.equal(done?.exitCode, 1);
|
||||
assert.match(done?.logTail ?? "", /Could not find a version/);
|
||||
});
|
||||
|
||||
test("cancels a running job without reporting a failure", (t) => {
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(jobs.cancel(started.id), true);
|
||||
assert.equal(children[0].killed, true);
|
||||
assert.equal(jobs.status(started.id)?.phase, "cancelled");
|
||||
children[0].exit(1); // Late exits must not overwrite the cancelled phase.
|
||||
assert.equal(jobs.status(started.id)?.phase, "cancelled");
|
||||
assert.equal(jobs.cancel(started.id), false);
|
||||
});
|
||||
|
||||
test("reuses the running job for the same interpreter and validates paths", (t) => {
|
||||
const { jobs } = makeJobs(t);
|
||||
const first = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
const second = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(first.id, second.id);
|
||||
assert.throws(() => jobs.start({ pythonPath: "relative\\python.exe" }));
|
||||
assert.equal(jobs.status("unknown-job"), null);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue