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:
LiangshouX 2026-10-27 18:35:55 +08:00
parent 6f1ecf06aa
commit ad9a4c2888
10 changed files with 1357 additions and 31 deletions

View file

@ -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>

View file

@ -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}}
`;

View file

@ -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 ?? "");
}
}

View file

@ -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);
}
}

View file

@ -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,

View 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),
};
}
}

View 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: "",
};
}

View file

@ -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 {

View file

@ -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),

View 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);
});