mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
fix(dsh): finish the environment review follow-ups
- match escaped wrapper command lines when proving service ownership - reset aborted LLM probes and consume stopped/conflict start signals - abort start polling on endpoint switch and guard stale service state - force --force-reinstall for Reinstall; localize start/stop reasons - validate rendered RPC fields, prune install logs and polish UI states
This commit is contained in:
parent
a91022a5bc
commit
603ea41fba
19 changed files with 736 additions and 75 deletions
|
|
@ -59,9 +59,10 @@ The check asks the selected interpreter three questions and reports them as metr
|
|||
- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable.
|
||||
- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time.
|
||||
|
||||
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with an upgrade and a package
|
||||
index (PyPI, Tsinghua, Aliyun). Large wheels are downloaded on first use; expect 5–20 minutes depending on the
|
||||
network.
|
||||
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
|
||||
Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
|
||||
present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes
|
||||
depending on the network.
|
||||
|
||||
The installation is a Host-owned job:
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ The installation is a Host-owned job:
|
|||
- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually
|
||||
exited.
|
||||
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place; install and
|
||||
service logs are rotated to `.log.1` once they pass 5 MB.
|
||||
service logs are rotated to `.log.1` once they pass 5 MB, and only the newest 20 install logs are kept.
|
||||
- When the plugin unloads, running jobs are stopped.
|
||||
|
||||
## Workspace `.env`
|
||||
|
|
|
|||
|
|
@ -52,15 +52,17 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
|
|||
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
|
||||
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
|
||||
|
||||
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加升级与软件源(PyPI、清华、阿里云)。首次使用需要
|
||||
下载较大的依赖,视网络情况通常 5–20 分钟。
|
||||
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
|
||||
`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
|
||||
依赖,视网络情况通常 5–20 分钟。
|
||||
|
||||
安装是 Host 侧的任务:
|
||||
|
||||
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
|
||||
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
|
||||
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
|
||||
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`。
|
||||
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`,
|
||||
并且目录只保留最近 20 个安装日志。
|
||||
- 插件卸载时,仍在运行的安装会被停止。
|
||||
|
||||
## Workspace `.env`
|
||||
|
|
|
|||
|
|
@ -31,7 +31,11 @@ export interface DependencyState {
|
|||
/** Wall clock tick while an installation runs, for the elapsed label. */
|
||||
now: number;
|
||||
recheck(): void;
|
||||
start(upgrade: boolean, indexUrl?: string): Promise<void>;
|
||||
start(
|
||||
upgrade: boolean,
|
||||
indexUrl?: string,
|
||||
forceReinstall?: boolean,
|
||||
): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
|
@ -177,7 +181,7 @@ export function useDependency(
|
|||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (upgrade: boolean, indexUrl?: string) => {
|
||||
async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
|
||||
if (!pythonPath || install.phase === "running") return;
|
||||
setInstall({ phase: "idle" });
|
||||
try {
|
||||
|
|
@ -186,6 +190,7 @@ export function useDependency(
|
|||
pythonPath,
|
||||
upgrade,
|
||||
...(indexUrl ? { indexUrl } : {}),
|
||||
...(forceReinstall ? { forceReinstall } : {}),
|
||||
}),
|
||||
);
|
||||
setInstall(
|
||||
|
|
@ -256,7 +261,7 @@ export function DependencySection({
|
|||
: checkData.versionOk && checkData.hasCore
|
||||
? { tone: "ok" as const, text: t("depReady") }
|
||||
: checkData.versionOk
|
||||
? { tone: "progress" as const, text: t("coreMissing") }
|
||||
? { tone: "progress" as const, text: t("coreMissingBadge") }
|
||||
: { tone: "progress" as const, text: t("depUpgrade") };
|
||||
|
||||
return (
|
||||
|
|
@ -302,7 +307,9 @@ export function DependencySection({
|
|||
</p>
|
||||
) : null}
|
||||
{checkData?.error ? (
|
||||
<p className="reme-settings-error">{checkData.error}</p>
|
||||
<p className="reme-settings-error">
|
||||
{`${t("checkRemeFailed")}: ${checkData.error}`}
|
||||
</p>
|
||||
) : null}
|
||||
{checkData ? (
|
||||
<div className="reme-env-metrics">
|
||||
|
|
@ -386,11 +393,16 @@ export function DependencySection({
|
|||
className="reme-settings-save"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
// Fresh install: no upgrade. Outdated: upgrade. Current
|
||||
// but suspect (Reinstall): force pip to reinstall.
|
||||
const reinstall =
|
||||
checkData.installed && checkData.versionOk;
|
||||
// The mirror choice reaches pip as `--index-url`; without
|
||||
// it the selector would be decorative.
|
||||
void dependency.start(
|
||||
!checkData.versionOk,
|
||||
checkData.installed && !checkData.versionOk,
|
||||
mirror || undefined,
|
||||
reinstall,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const envEn = {
|
|||
upgradeRecommended: "upgrade recommended",
|
||||
coreOk: "Available",
|
||||
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
||||
coreMissingBadge: "core missing",
|
||||
cliFallback: "python wrapper fallback",
|
||||
installButton: "Install reme-ai[core]",
|
||||
upgradeButton: "Upgrade reme-ai",
|
||||
|
|
@ -97,17 +98,41 @@ export const envEn = {
|
|||
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
|
||||
serviceBadgeStarting: "Starting…",
|
||||
serviceBadgeConflict: "Port occupied",
|
||||
serviceBadgeUnhealthy: "Not healthy",
|
||||
serviceBadgeStopped: "Stopped",
|
||||
serviceRunning: "Running",
|
||||
startService: "Start service",
|
||||
stopService: "Stop service",
|
||||
startTimeout: "The service did not become healthy within 120 seconds",
|
||||
startFailedStopped:
|
||||
"The service exited before it became healthy. Open the log below.",
|
||||
startErrorOtherEndpoint:
|
||||
"A service start for another endpoint is still in progress.",
|
||||
startErrorConflict: "Another process is already listening on this endpoint.",
|
||||
startErrorOtherEndpointRunning:
|
||||
"A service for another endpoint is still running; stop it there first.",
|
||||
startErrorPreviousNotStopped:
|
||||
"The previous service could not be stopped; check the log.",
|
||||
startErrorNoPython: "Select a Python interpreter first.",
|
||||
startErrorNotInstalled:
|
||||
"reme-ai is not installed in the selected interpreter.",
|
||||
startErrorVersionTooOld:
|
||||
"The installed reme-ai is too old; upgrade it first.",
|
||||
startErrorFailed: "The service could not be started; check the log.",
|
||||
stopNoRecord: "No service was started by this plugin.",
|
||||
stopForeign: "The recorded service does not belong to this endpoint.",
|
||||
stopUnknownIdentity:
|
||||
"The recorded process could not be verified, so it was left running.",
|
||||
stopNotExited:
|
||||
"The service did not exit; its record was kept so you can retry.",
|
||||
stopConfirm:
|
||||
"Stop the ReMe service? Automatic memory in flight may need a retry.",
|
||||
foreignNote:
|
||||
"This service was started outside DSH; its state is shown but it is not managed here.",
|
||||
conflictNote:
|
||||
"Another program occupies this port. Change the service URL port in Memory settings.",
|
||||
conflictManagedNote:
|
||||
"This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
|
||||
viewLogs: "View log",
|
||||
logEmpty: "The service log is empty.",
|
||||
autoStartLabel: "Start the service automatically with DSH",
|
||||
|
|
@ -192,6 +217,7 @@ export const envZh: typeof envEn = {
|
|||
upgradeRecommended: "建议升级",
|
||||
coreOk: "可用",
|
||||
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
||||
coreMissingBadge: "缺少 core",
|
||||
cliFallback: "回退到 python 包装",
|
||||
installButton: "安装 reme-ai[core]",
|
||||
upgradeButton: "升级 reme-ai",
|
||||
|
|
@ -244,14 +270,31 @@ export const envZh: typeof envEn = {
|
|||
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
|
||||
serviceBadgeStarting: "启动中…",
|
||||
serviceBadgeConflict: "端口被占用",
|
||||
serviceBadgeUnhealthy: "未就绪",
|
||||
serviceBadgeStopped: "已停止",
|
||||
serviceRunning: "运行中",
|
||||
startService: "启动服务",
|
||||
stopService: "停止服务",
|
||||
startTimeout: "服务在 120 秒内未就绪",
|
||||
startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
|
||||
startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
|
||||
startErrorConflict: "该端口已被其他程序占用。",
|
||||
startErrorOtherEndpointRunning:
|
||||
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
|
||||
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
|
||||
startErrorNoPython: "请先选择一个 Python 解释器。",
|
||||
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
|
||||
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
|
||||
startErrorFailed: "服务未能启动,请查看日志。",
|
||||
stopNoRecord: "本插件没有启动过该服务。",
|
||||
stopForeign: "记录中的服务不属于当前服务地址。",
|
||||
stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
|
||||
stopNotExited: "服务未退出;已保留记录,可稍后重试。",
|
||||
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
|
||||
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
|
||||
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
||||
conflictManagedNote:
|
||||
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
|
||||
viewLogs: "查看日志",
|
||||
logEmpty: "服务日志为空。",
|
||||
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
||||
|
|
|
|||
|
|
@ -67,18 +67,28 @@ export function usePython(
|
|||
setDetection({ phase: "ready", data: parseDetection(value) });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setDetection({ phase: "error", error: safeError(error) });
|
||||
setDetection((current) => ({
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
...(current.data ? { data: current.data } : {}),
|
||||
}));
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, tick, enabled]);
|
||||
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
// A refresh keeps the last detection data, so the selected interpreter (and
|
||||
// the install/progress panels keyed off it) does not flash away while the
|
||||
// new probe is in flight.
|
||||
const detected = detection.data;
|
||||
const suggested = detected?.suggested ?? "";
|
||||
const selectedPath = pythonPath || suggested;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setDetection({ phase: "loading" });
|
||||
setDetection((current) => ({
|
||||
phase: "loading",
|
||||
...(current.data ? { data: current.data } : {}),
|
||||
}));
|
||||
setTick((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@ function booleanField(
|
|||
return value;
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): void {
|
||||
if (body[key] !== undefined && typeof body[key] !== "string") fail(what);
|
||||
}
|
||||
|
||||
function arrayField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
|
|
@ -105,7 +113,29 @@ export function parseDetection(value: unknown): PythonDetection {
|
|||
stringField(env, "exe", what);
|
||||
stringField(env, "version", what);
|
||||
booleanField(env, "ok", what);
|
||||
record(env.origin, what);
|
||||
const origin = record(env.origin, what);
|
||||
const kind = stringField(origin, "kind", what);
|
||||
if (
|
||||
![
|
||||
"conda",
|
||||
"python.org",
|
||||
"windows-store",
|
||||
"venv",
|
||||
"system",
|
||||
"unknown",
|
||||
].includes(kind)
|
||||
)
|
||||
fail(what);
|
||||
if (kind === "conda") {
|
||||
stringField(origin, "envName", what);
|
||||
stringField(origin, "root", what);
|
||||
}
|
||||
if (kind === "venv") stringField(origin, "baseExe", what);
|
||||
if (env.reme !== undefined) {
|
||||
const reme = record(env.reme, what);
|
||||
booleanField(reme, "installed", what);
|
||||
stringField(reme, "version", what);
|
||||
}
|
||||
}
|
||||
return value as PythonDetection;
|
||||
}
|
||||
|
|
@ -118,6 +148,7 @@ export function parseRemeCheck(value: unknown): RemeCheck {
|
|||
booleanField(body, "hasCore", what);
|
||||
stringField(body, "version", what);
|
||||
stringField(body, "cliPath", what);
|
||||
stringField(body, "error", what);
|
||||
return value as unknown as RemeCheck;
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +161,8 @@ export function parseInstallStatus(value: unknown): InstallStatus | null {
|
|||
stringField(body, "pythonPath", what);
|
||||
stringField(body, "startedAt", what);
|
||||
stringField(body, "logPath", what);
|
||||
stringField(body, "logTail", what);
|
||||
if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
|
||||
const phase = stringField(body, "phase", what);
|
||||
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
|
||||
fail(what);
|
||||
|
|
@ -190,6 +223,8 @@ export function parseServiceStart(value: unknown): ServiceStartResult {
|
|||
stringField(body, "command", what);
|
||||
stringField(body, "logPath", what);
|
||||
stringField(body, "startedAt", what);
|
||||
optionalStringField(body, "error", what);
|
||||
optionalStringField(body, "code", what);
|
||||
return value as unknown as ServiceStartResult;
|
||||
}
|
||||
|
||||
|
|
@ -197,12 +232,15 @@ export interface ServiceStopView {
|
|||
stopped: boolean;
|
||||
pid?: number;
|
||||
reason?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export function parseServiceStop(value: unknown): ServiceStopView {
|
||||
const what = "service stop";
|
||||
const body = record(value, what);
|
||||
booleanField(body, "stopped", what);
|
||||
optionalStringField(body, "reason", what);
|
||||
optionalStringField(body, "code", what);
|
||||
return value as ServiceStopView;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { ServiceState } from "../types.js";
|
||||
import type { EnvTranslator } from "./locales.js";
|
||||
import { fill } from "./locales.js";
|
||||
import type { ServiceStartResult, ServiceState } from "../types.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import {
|
||||
envCall,
|
||||
|
|
@ -21,6 +20,38 @@ import {
|
|||
*/
|
||||
const START_DEADLINE_MS = 120_000;
|
||||
|
||||
/** Host start errors carry a stable code; older/unknown codes fall back. */
|
||||
const START_ERROR_KEYS: Record<string, EnvKey> = {
|
||||
"other-endpoint-starting": "startErrorOtherEndpoint",
|
||||
conflict: "startErrorConflict",
|
||||
"other-endpoint-running": "startErrorOtherEndpointRunning",
|
||||
"previous-not-stopped": "startErrorPreviousNotStopped",
|
||||
"no-python": "startErrorNoPython",
|
||||
"not-installed": "startErrorNotInstalled",
|
||||
"version-too-old": "startErrorVersionTooOld",
|
||||
"start-failed": "startErrorFailed",
|
||||
};
|
||||
|
||||
function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
|
||||
const key = result.code ? START_ERROR_KEYS[result.code] : undefined;
|
||||
return key ? t(key) : result.error || t("serviceError");
|
||||
}
|
||||
|
||||
const STOP_REASON_KEYS: Record<string, EnvKey> = {
|
||||
"no-record": "stopNoRecord",
|
||||
foreign: "stopForeign",
|
||||
unverified: "stopUnknownIdentity",
|
||||
"not-exited": "stopNotExited",
|
||||
};
|
||||
|
||||
function stopReasonText(
|
||||
t: EnvTranslator,
|
||||
result: { code?: string; reason?: string },
|
||||
): string {
|
||||
const key = result.code ? STOP_REASON_KEYS[result.code] : undefined;
|
||||
return key ? t(key) : result.reason || t("serviceError");
|
||||
}
|
||||
|
||||
export interface ServiceViewState {
|
||||
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
|
||||
data?: ServiceState;
|
||||
|
|
@ -75,9 +106,15 @@ export function useService(
|
|||
const startController = useRef<AbortController | undefined>(undefined);
|
||||
|
||||
// A new endpoint has no known phase yet; keeping the previous endpoint's
|
||||
// "running + old pid/version" on screen would mislabel the new one.
|
||||
// "running + old pid/version" on screen would mislabel the new one. A start
|
||||
// poll for the old endpoint must stop before it can paint that old data.
|
||||
useEffect(() => {
|
||||
startController.current?.abort();
|
||||
// Resetting the view when the endpoint prop changes is intentional; the
|
||||
// hook has no key, so this is the reset path.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState({ phase: "checking" });
|
||||
setStopError("");
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => () => startController.current?.abort(), []);
|
||||
|
|
@ -96,18 +133,19 @@ export function useService(
|
|||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setState((current) =>
|
||||
current.phase === "starting"
|
||||
? current
|
||||
: { phase: next.phase, data: next },
|
||||
);
|
||||
// Recheck must not be swallowed while a start is in flight: the
|
||||
// response belongs to the endpoint on screen. `stopped` with a live pid
|
||||
// is the Host's "launch still booting" signal.
|
||||
setState({
|
||||
phase:
|
||||
next.phase === "stopped" && next.pid !== undefined
|
||||
? "starting"
|
||||
: next.phase,
|
||||
data: next,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setState((current) =>
|
||||
current.phase === "starting"
|
||||
? current
|
||||
: { phase: "error", error: safeError(error) },
|
||||
);
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
|
|
@ -134,7 +172,7 @@ export function useService(
|
|||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (started.error) {
|
||||
setState({ phase: "error", error: started.error });
|
||||
setState({ phase: "error", error: startErrorText(t, started) });
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + START_DEADLINE_MS;
|
||||
|
|
@ -154,10 +192,23 @@ export function useService(
|
|||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (status.phase === "running") {
|
||||
setState({ phase: "running", data: status });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "conflict") {
|
||||
// Someone (possibly our own booting service) owns the port.
|
||||
setState({ phase: "conflict", data: status });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "stopped" && status.pid === undefined) {
|
||||
// The Host already dropped the start marker: the launch died before
|
||||
// it ever listened, so waiting out the 120 s deadline is wrong.
|
||||
setState({ phase: "error", error: t("startFailedStopped") });
|
||||
return;
|
||||
}
|
||||
// stopped with a live pid: the process is still booting.
|
||||
} catch {
|
||||
// Keep polling: a starting service answers once it is listening.
|
||||
}
|
||||
|
|
@ -175,7 +226,7 @@ export function useService(
|
|||
const result = parseServiceStop(
|
||||
await envCall(rpc, "serviceStop", { endpoint }),
|
||||
);
|
||||
setStopError(result.stopped ? "" : result.reason || t("serviceError"));
|
||||
setStopError(result.stopped ? "" : stopReasonText(t, result));
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
|
|
@ -276,6 +327,7 @@ export function ServiceSection({
|
|||
remeInstalled,
|
||||
autoStart,
|
||||
writable,
|
||||
current,
|
||||
onAutoStart,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
|
|
@ -285,6 +337,8 @@ export function ServiceSection({
|
|||
remeInstalled: boolean;
|
||||
autoStart: boolean;
|
||||
writable: boolean;
|
||||
/** False while the state on screen belongs to another endpoint. */
|
||||
current: boolean;
|
||||
onAutoStart(checked: boolean): void;
|
||||
}): JSX.Element {
|
||||
const { state, stopping, stopError, log } = control;
|
||||
|
|
@ -293,8 +347,9 @@ export function ServiceSection({
|
|||
// offer the same Stop affordance instead of leaving the tab without a way
|
||||
// out other than editing the record by hand.
|
||||
const canStop =
|
||||
state.phase === "running" ||
|
||||
(state.phase === "conflict" && state.data?.managed === true);
|
||||
current &&
|
||||
(state.phase === "running" ||
|
||||
(state.phase === "conflict" && state.data?.managed === true));
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
|
|
@ -307,10 +362,14 @@ export function ServiceSection({
|
|||
<p>{t("serviceHint")}</p>
|
||||
</div>
|
||||
<aside>
|
||||
{state.phase === "checking" || state.phase === "starting" ? (
|
||||
{!current ||
|
||||
state.phase === "checking" ||
|
||||
state.phase === "starting" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t(
|
||||
state.phase === "starting"
|
||||
!current
|
||||
? "checking"
|
||||
: state.phase === "starting"
|
||||
? "serviceBadgeStarting"
|
||||
: "checking",
|
||||
)}
|
||||
|
|
@ -323,7 +382,11 @@ export function ServiceSection({
|
|||
</StatusBadge>
|
||||
) : state.phase === "conflict" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t("serviceBadgeConflict")}
|
||||
{t(
|
||||
state.data?.managed
|
||||
? "serviceBadgeUnhealthy"
|
||||
: "serviceBadgeConflict",
|
||||
)}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
||||
|
|
@ -344,7 +407,8 @@ export function ServiceSection({
|
|||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{state.phase === "running" || state.phase === "conflict" ? (
|
||||
{current &&
|
||||
(state.phase === "running" || state.phase === "conflict") ? (
|
||||
<p className="reme-settings-muted">
|
||||
{state.data?.managed
|
||||
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
|
||||
|
|
@ -353,8 +417,10 @@ export function ServiceSection({
|
|||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
{state.phase === "conflict" ? (
|
||||
<p className="reme-settings-muted">{t("conflictNote")}</p>
|
||||
{current && state.phase === "conflict" ? (
|
||||
<p className="reme-settings-muted">
|
||||
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
|
||||
</p>
|
||||
) : null}
|
||||
{state.phase === "error" && state.error ? (
|
||||
<p className="reme-settings-error">
|
||||
|
|
@ -405,7 +471,11 @@ export function ServiceSection({
|
|||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
state.phase === "starting" || !remeInstalled || !pythonPath
|
||||
!current ||
|
||||
state.phase === "starting" ||
|
||||
state.phase === "conflict" ||
|
||||
!remeInstalled ||
|
||||
!pythonPath
|
||||
}
|
||||
onClick={() => void control.start()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -47,9 +47,13 @@ function endpointKey(endpoint: string): string {
|
|||
try {
|
||||
const url = new URL(endpoint);
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
return `${
|
||||
url.protocol
|
||||
}//${url.hostname.toLowerCase()}:${port}${url.pathname.replace(
|
||||
// Match the Host's loopback alias rule so `localhost` and `127.0.0.1`
|
||||
// answers compare equal.
|
||||
const host =
|
||||
url.hostname.toLowerCase() === "localhost"
|
||||
? "127.0.0.1"
|
||||
: url.hostname.toLowerCase();
|
||||
return `${url.protocol}//${host}:${port}${url.pathname.replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
)}`;
|
||||
|
|
@ -133,8 +137,12 @@ export function ReMeEnvironmentTab({
|
|||
const authToken = value?.authToken ?? "";
|
||||
const [heroTick, setHeroTick] = useState(0);
|
||||
// Settings writes are invisible to the sections otherwise: a rejected
|
||||
// revision or a transport error would silently roll the control back.
|
||||
const [settingsError, setSettingsError] = useState("");
|
||||
// revision or a transport error would silently roll the control back. The
|
||||
// message carries its endpoint so a change of URL drops it without an effect.
|
||||
const [settingsError, setSettingsError] = useState<{
|
||||
endpoint: string;
|
||||
message: string;
|
||||
}>({ endpoint: "", message: "" });
|
||||
|
||||
const writeSetting = useCallback(
|
||||
async (field: string, next: unknown): Promise<boolean> => {
|
||||
|
|
@ -145,18 +153,20 @@ export function ReMeEnvironmentTab({
|
|||
snapshot.revision,
|
||||
);
|
||||
if (applied === false) {
|
||||
setSettingsError(t("settingsWriteFailed"));
|
||||
setSettingsError({ endpoint, message: t("settingsWriteFailed") });
|
||||
return false;
|
||||
}
|
||||
setSettingsError("");
|
||||
setSettingsError({ endpoint, message: "" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setSettingsError(safeError(error));
|
||||
setSettingsError({ endpoint, message: safeError(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[scope, snapshot.revision, snapshot.writable, t],
|
||||
[endpoint, scope, snapshot.revision, snapshot.writable, t],
|
||||
);
|
||||
const settingsErrorText =
|
||||
settingsError.endpoint === endpoint ? settingsError.message : "";
|
||||
|
||||
const python = usePython(rpc, {
|
||||
pythonPath: value?.pythonPath ?? "",
|
||||
|
|
@ -198,6 +208,7 @@ export function ReMeEnvironmentTab({
|
|||
// The Host names the endpoint its answer belongs to; comparing that with the
|
||||
// settings endpoint is what makes a stale answer fail the `current` test.
|
||||
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
|
||||
const serviceCurrent = endpointKey(serviceEndpoint) === endpointKey(endpoint);
|
||||
const connection: ConnectionState = local
|
||||
? service.state.phase === "running"
|
||||
? {
|
||||
|
|
@ -222,8 +233,10 @@ export function ReMeEnvironmentTab({
|
|||
const ready =
|
||||
current &&
|
||||
connection.phase === "ok" &&
|
||||
dependency.checkData?.installed === true &&
|
||||
dependency.checkData.versionOk;
|
||||
(!local ||
|
||||
(dependency.checkData?.installed === true &&
|
||||
dependency.checkData.versionOk &&
|
||||
dependency.checkData.hasCore));
|
||||
const scannedAt = python.detection.data?.scannedAt
|
||||
? Date.parse(python.detection.data.scannedAt)
|
||||
: Number.NaN;
|
||||
|
|
@ -259,8 +272,12 @@ export function ReMeEnvironmentTab({
|
|||
</div>
|
||||
<div className="reme-env-hero-chips">
|
||||
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
|
||||
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
|
||||
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
|
||||
{local ? (
|
||||
<>
|
||||
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
|
||||
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="reme-env-hero-actions">
|
||||
<button
|
||||
|
|
@ -294,9 +311,9 @@ export function ReMeEnvironmentTab({
|
|||
{connection.error}
|
||||
</p>
|
||||
) : null}
|
||||
{settingsError ? (
|
||||
{settingsErrorText ? (
|
||||
<p className="reme-settings-error reme-env-hero-error" role="alert">
|
||||
{settingsError}
|
||||
{settingsErrorText}
|
||||
</p>
|
||||
) : null}
|
||||
{!local ? (
|
||||
|
|
@ -329,6 +346,7 @@ export function ReMeEnvironmentTab({
|
|||
remeInstalled={dependency.checkData?.installed === true}
|
||||
autoStart={value?.autoStart === true}
|
||||
writable={snapshot.writable}
|
||||
current={serviceCurrent}
|
||||
onAutoStart={(checked) => {
|
||||
void writeSetting("autoStart", checked);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,10 @@ export function useWorkspace(
|
|||
error: safeError(error),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// An aborted probe never settles into a result; without this the
|
||||
// button would stay disabled on "Testing…" until the tab remounts.
|
||||
if (controller.signal.aborted) setLlmTest({ phase: "idle" });
|
||||
}
|
||||
},
|
||||
[llmTest.phase, rpc, workspaceDir],
|
||||
|
|
|
|||
|
|
@ -61,13 +61,19 @@ export class ReMeEnvGateway extends TypertRemoteService {
|
|||
@Remote("install")
|
||||
async install(
|
||||
input:
|
||||
| { pythonPath?: string; upgrade?: boolean; indexUrl?: string }
|
||||
| {
|
||||
pythonPath?: string;
|
||||
upgrade?: boolean;
|
||||
indexUrl?: string;
|
||||
forceReinstall?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): Promise<InstallStatus> {
|
||||
return this.ctx.remeEnv.install({
|
||||
pythonPath: input?.pythonPath ?? "",
|
||||
upgrade: input?.upgrade === true,
|
||||
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
|
||||
forceReinstall: input?.forceReinstall === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
|
||||
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
|
||||
import { openAppendLog } from "./log.js";
|
||||
import { openAppendLog, pruneInstallLogs } from "./log.js";
|
||||
import { isAbsolutePath } from "./paths.js";
|
||||
|
||||
export interface InstallChild {
|
||||
|
|
@ -31,6 +31,9 @@ export function pipArgs(request: InstallRequest): string[] {
|
|||
"--no-input",
|
||||
];
|
||||
if (request.upgrade) args.push("-U");
|
||||
// `pip install reme-ai[core]` reports "already satisfied" for a broken but
|
||||
// present install, so an explicit Reinstall must force it.
|
||||
if (request.forceReinstall) args.push("--force-reinstall");
|
||||
const indexUrl = (request.indexUrl ?? "").trim();
|
||||
if (indexUrl) {
|
||||
if (!/^https?:\/\/\S+$/i.test(indexUrl))
|
||||
|
|
@ -66,7 +69,10 @@ export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
|
|||
// The cut may be mid-line and even mid-character; drop that partial line
|
||||
// so the tail never starts with a replacement character.
|
||||
const firstBreak = text.indexOf("\n");
|
||||
text = firstBreak >= 0 ? text.slice(firstBreak + 1) : text;
|
||||
text =
|
||||
firstBreak >= 0
|
||||
? text.slice(firstBreak + 1)
|
||||
: text.replace(/^\uFFFD+/, "");
|
||||
}
|
||||
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||
return text
|
||||
|
|
@ -206,6 +212,7 @@ export class InstallJobs {
|
|||
36,
|
||||
)}-${(this.sequence += 1).toString(36)}`;
|
||||
mkdirSync(this.options.logDir, { recursive: true });
|
||||
pruneInstallLogs(this.options.logDir);
|
||||
const logPath = join(this.options.logDir, `${id}.log`);
|
||||
const child = this.options.spawner.start({
|
||||
exe: pythonPath,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { mkdirSync, openSync, renameSync, statSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/** Rotate a log once it grows past this size; only one backup is kept. */
|
||||
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
||||
/** Number of per-job install logs kept on disk. */
|
||||
export const INSTALL_LOG_KEEP = 20;
|
||||
|
||||
/**
|
||||
* Open a log for appending, rotating an oversized one to `<path>.1` first.
|
||||
|
|
@ -22,3 +31,32 @@ export function openAppendLog(
|
|||
}
|
||||
return openSync(path, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the newest `keep` per-job logs. Each install writes a new file, so
|
||||
* size-based rotation alone never bounds the directory.
|
||||
*/
|
||||
export function pruneInstallLogs(
|
||||
directory: string,
|
||||
keep = INSTALL_LOG_KEEP,
|
||||
): void {
|
||||
try {
|
||||
const entries = readdirSync(directory).filter(
|
||||
(name) => name.startsWith("install-") && name.endsWith(".log"),
|
||||
);
|
||||
if (entries.length <= keep) return;
|
||||
const byAge = entries
|
||||
.map((name) => {
|
||||
try {
|
||||
return { name, mtime: statSync(join(directory, name)).mtimeMs };
|
||||
} catch {
|
||||
return { name, mtime: 0 };
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.mtime - left.mtime);
|
||||
for (const old of byAge.slice(keep))
|
||||
rmSync(join(directory, old.name), { force: true });
|
||||
} catch {
|
||||
// Pruning is housekeeping; a missing directory is not an error.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: `A service start for ${starting.endpoint} is still in progress`,
|
||||
code: "other-endpoint-starting",
|
||||
};
|
||||
}
|
||||
this.starting = undefined;
|
||||
|
|
@ -374,6 +375,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: "Another process is already listening on this endpoint",
|
||||
code: "conflict",
|
||||
};
|
||||
const previous = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
|
|
@ -390,6 +392,7 @@ export class ReMeEnvManager {
|
|||
error: `A previous launch could not be stopped: ${
|
||||
previous.reason ?? "unknown reason"
|
||||
}`,
|
||||
code: "previous-not-stopped",
|
||||
};
|
||||
}
|
||||
// A record for a *different* endpoint that is still alive would be
|
||||
|
|
@ -417,6 +420,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
|
||||
code: "other-endpoint-running",
|
||||
};
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
|
|
@ -445,6 +449,7 @@ export class ReMeEnvManager {
|
|||
error: `A previous launch could not be stopped: ${
|
||||
previous.reason ?? "unknown reason"
|
||||
}`,
|
||||
code: "previous-not-stopped",
|
||||
};
|
||||
} else {
|
||||
removeServiceState(this.serviceStatePath);
|
||||
|
|
@ -459,6 +464,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: "Select a Python interpreter first",
|
||||
code: "no-python",
|
||||
};
|
||||
const check = await this.checkReme(path);
|
||||
if (!check.installed)
|
||||
|
|
@ -469,6 +475,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: "reme-ai is not installed in the selected interpreter",
|
||||
code: "not-installed",
|
||||
};
|
||||
if (!check.versionOk)
|
||||
return {
|
||||
|
|
@ -480,6 +487,7 @@ export class ReMeEnvManager {
|
|||
error: `reme-ai ${
|
||||
check.version || "(unknown version)"
|
||||
} is older than ${MINIMUM_REME}; upgrade it first`,
|
||||
code: "version-too-old",
|
||||
};
|
||||
// Set before the spawn so a second request arriving while the service is
|
||||
// booting observes the first launch instead of spawning another one. It is
|
||||
|
|
@ -508,6 +516,7 @@ export class ReMeEnvManager {
|
|||
logPath,
|
||||
startedAt: now,
|
||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@ import { createConnection } from "node:net";
|
|||
import { basename, dirname } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { ServiceStartResult, ServiceState } from "../types.js";
|
||||
import type {
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopReasonCode,
|
||||
} from "../types.js";
|
||||
import { openAppendLog } from "./log.js";
|
||||
import { isWindowsPath } from "./paths.js";
|
||||
|
||||
/**
|
||||
* The local ReMe service is a *detached* process: it outlives DSH, which is the
|
||||
|
|
@ -45,6 +50,8 @@ export interface ServiceStopResult {
|
|||
pid?: number;
|
||||
/** Why an unmanaged or missing service was left alone. */
|
||||
reason?: string;
|
||||
/** Stable reason the client localizes; `reason` stays for diagnostics. */
|
||||
code?: ServiceStopReasonCode;
|
||||
}
|
||||
|
||||
export interface ServiceIO {
|
||||
|
|
@ -256,9 +263,17 @@ export function isServiceImage(image: string): boolean {
|
|||
*/
|
||||
export type ServiceOwnership = "owned" | "foreign" | "unknown";
|
||||
|
||||
/** Compare paths and ports with separators and case normalized. */
|
||||
function normalizeCommandText(value: string): string {
|
||||
return value.replaceAll("\\\\", "\\").replaceAll("/", "\\").toLowerCase();
|
||||
/**
|
||||
* Compare paths and ports with separators normalized. Windows command lines
|
||||
* escape quotes as `\"`, so that sequence is folded back before matching; case
|
||||
* is only folded for Windows paths, because POSIX paths are case-sensitive.
|
||||
*/
|
||||
function normalizeCommandText(value: string, foldCase: boolean): string {
|
||||
const text = value
|
||||
.replaceAll('\\"', '"')
|
||||
.replaceAll("\\\\", "\\")
|
||||
.replaceAll("/", "\\");
|
||||
return foldCase ? text.toLowerCase() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -276,13 +291,15 @@ function commandMatches(command: string, persisted: PersistedService): boolean {
|
|||
}
|
||||
const workspace = persisted.workspaceDir.trim();
|
||||
if (!workspace) return false;
|
||||
const text = normalizeCommandText(command);
|
||||
const marker = `workspace_dir=${normalizeCommandText(workspace)}`;
|
||||
const foldCase = isWindowsPath(workspace);
|
||||
const text = normalizeCommandText(command, foldCase);
|
||||
const marker = `workspace_dir=${normalizeCommandText(workspace, foldCase)}`;
|
||||
const index = text.indexOf(marker);
|
||||
if (index < 0) return false;
|
||||
// Require a boundary so `C:\a` does not match another service using `C:\ab`.
|
||||
// A backslash remains a boundary for the `\"` form older shells produce.
|
||||
const next = text[index + marker.length] ?? "";
|
||||
if (next && !/[\s"',\]]/.test(next)) return false;
|
||||
if (next && !/[\s"',\]\\]/.test(next)) return false;
|
||||
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
|
||||
}
|
||||
|
||||
|
|
@ -444,6 +461,7 @@ export async function startService(
|
|||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
let child: { pid: number | undefined };
|
||||
|
|
@ -468,6 +486,7 @@ export async function startService(
|
|||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
try {
|
||||
|
|
@ -494,6 +513,7 @@ export async function startService(
|
|||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not record the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -556,7 +576,11 @@ export async function stopService(
|
|||
const persisted = readServiceState(options.statePath);
|
||||
if (!persisted || !isValidPid(persisted.pid)) {
|
||||
if (persisted) clearServiceState(options.statePath);
|
||||
return { stopped: false, reason: "No service started by this plugin" };
|
||||
return {
|
||||
stopped: false,
|
||||
reason: "No service started by this plugin",
|
||||
code: "no-record",
|
||||
};
|
||||
}
|
||||
const ownership =
|
||||
options.ownership ??
|
||||
|
|
@ -566,11 +590,13 @@ export async function stopService(
|
|||
stopped: false,
|
||||
reason:
|
||||
"The recorded process identity could not be verified; refusing to stop it",
|
||||
code: "unverified",
|
||||
};
|
||||
if (ownership !== "owned")
|
||||
return {
|
||||
stopped: false,
|
||||
reason: "The recorded service does not belong to this endpoint",
|
||||
code: "foreign",
|
||||
};
|
||||
const pid = persisted.pid;
|
||||
const pollMs = options.pollMs ?? STOP_POLL_MS;
|
||||
|
|
@ -607,6 +633,7 @@ export async function stopService(
|
|||
stopped: false,
|
||||
pid,
|
||||
reason: "The service did not exit after the force-kill",
|
||||
code: "not-exited",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -681,8 +708,25 @@ export function createServiceIO(): ServiceIO {
|
|||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("spawn", () => resolve());
|
||||
child.once("error", (error: Error) => reject(error));
|
||||
// A spawn acknowledgement that never arrives must not block the
|
||||
// start queue (and therefore a queued Stop) forever.
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
reject(new Error("spawn did not acknowledge within 10 s"));
|
||||
}, 10_000);
|
||||
timer.unref();
|
||||
child.once("spawn", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.once("error", (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
// The child owns its inherited handle; the parent copy is no longer
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ export interface InstallRequest {
|
|||
pythonPath: string;
|
||||
/** Upgrade an existing installation to the newest satisfying version. */
|
||||
upgrade?: boolean;
|
||||
/** Reinstall even when pip already considers the requirement satisfied. */
|
||||
forceReinstall?: boolean;
|
||||
/** Optional package index URL (mirror); empty keeps the user's pip config. */
|
||||
indexUrl?: string;
|
||||
}
|
||||
|
|
@ -133,6 +135,24 @@ export interface ServiceState {
|
|||
logPath: string;
|
||||
}
|
||||
|
||||
/** Stable reason for a failed start, so the client can localize it. */
|
||||
export type ServiceStartErrorCode =
|
||||
| "other-endpoint-starting"
|
||||
| "conflict"
|
||||
| "other-endpoint-running"
|
||||
| "previous-not-stopped"
|
||||
| "no-python"
|
||||
| "not-installed"
|
||||
| "version-too-old"
|
||||
| "start-failed";
|
||||
|
||||
/** Stable reason for a refused/unsuccessful stop, so the client can localize it. */
|
||||
export type ServiceStopReasonCode =
|
||||
| "no-record"
|
||||
| "foreign"
|
||||
| "unverified"
|
||||
| "not-exited";
|
||||
|
||||
export interface ServiceStartResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
|
|
@ -141,7 +161,9 @@ export interface ServiceStartResult {
|
|||
command: string;
|
||||
logPath: string;
|
||||
startedAt: string;
|
||||
/** English diagnostic; shown only when no known `code` applies. */
|
||||
error?: string;
|
||||
code?: ServiceStartErrorCode;
|
||||
}
|
||||
|
||||
export interface ServiceLog {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,12 @@ test("composes pip arguments for install, upgrade and mirrors", () => {
|
|||
"reme-ai[core]",
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
pipArgs({
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
forceReinstall: true,
|
||||
}).includes("--force-reinstall"),
|
||||
);
|
||||
assert.throws(() =>
|
||||
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
closeSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
|
|
@ -12,7 +13,10 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
|
||||
import { createInstallSpawner } from "../dist/environment/host/install.js";
|
||||
import { openAppendLog } from "../dist/environment/host/log.js";
|
||||
import {
|
||||
openAppendLog,
|
||||
pruneInstallLogs,
|
||||
} from "../dist/environment/host/log.js";
|
||||
import { createNodeRunner } from "../dist/environment/host/runner.js";
|
||||
|
||||
test("a timeout settles with an explicit error instead of hanging", async () => {
|
||||
|
|
@ -74,3 +78,16 @@ test("an oversized log is rotated before the next append", (t) => {
|
|||
assert.equal(readFileSync(`${path}.1`, "utf8").length, 200);
|
||||
assert.equal(statSync(path).size, 0);
|
||||
});
|
||||
|
||||
test("per-job install logs are pruned to the newest files", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-prune-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
for (let index = 0; index < 25; index += 1)
|
||||
writeFileSync(join(directory, `install-${index}.log`), "x");
|
||||
|
||||
pruneInstallLogs(directory, 20);
|
||||
const remaining = readdirSync(directory).filter((name) =>
|
||||
name.endsWith(".log"),
|
||||
);
|
||||
assert.equal(remaining.length, 20);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -283,6 +283,18 @@ test("a recycled pid that is no longer the service is not managed", async (t) =>
|
|||
}
|
||||
});
|
||||
|
||||
test("recognizes the Windows wrapper command line with escaped quotes", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-wrapper-");
|
||||
record(statePath);
|
||||
const state = readServiceState(statePath);
|
||||
// What Windows shows for `python -c <wrapper>`: the JSON quotes are escaped
|
||||
// as `\"` and each path separator appears doubled.
|
||||
const escaped = state.workspaceDir.replaceAll("\\", "\\\\");
|
||||
const command = `python -c "import sys;sys.argv=[\\"reme\\",\\"start\\",\\"workspace_dir=${escaped}\\",\\"service.port=2334\\"];from reme.reme import main"`;
|
||||
const { io } = fakeIO({ processCommandLine: async () => command });
|
||||
assert.equal(await serviceOwnership(io, state, ENDPOINT), "owned");
|
||||
});
|
||||
|
||||
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-dead-");
|
||||
record(statePath);
|
||||
|
|
@ -373,6 +385,7 @@ test("stops the managed pid and clears the state file after it exits", async (t)
|
|||
assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), {
|
||||
stopped: false,
|
||||
reason: "No service started by this plugin",
|
||||
code: "no-record",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const RESPONSES = {
|
|||
versionOk: true,
|
||||
hasCore: true,
|
||||
cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`,
|
||||
error: "",
|
||||
},
|
||||
"remeEnv/installStatus": null,
|
||||
"remeEnv/readEnv": {
|
||||
|
|
@ -147,9 +148,24 @@ async function mount(overrides = {}, options = {}) {
|
|||
},
|
||||
};
|
||||
const rpc = {
|
||||
call: async (_path, method, payload) => {
|
||||
call: async (_path, method, payload, signal) => {
|
||||
calls.rpc.push({ method, input: payload?.args?.input });
|
||||
return { ok: true, value: responses[method] ?? null };
|
||||
if (options.pending?.includes(method))
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const value = responses[method];
|
||||
return {
|
||||
ok: true,
|
||||
value:
|
||||
typeof value === "function"
|
||||
? value(payload?.args?.input)
|
||||
: value ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -289,6 +305,7 @@ test("sends the chosen package mirror with the install request", async (t) => {
|
|||
pythonPath: SUGGESTED,
|
||||
upgrade: false,
|
||||
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
forceReinstall: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -406,6 +423,7 @@ test("shows why Stop left the service alone", async (t) => {
|
|||
"remeEnv/serviceStop": {
|
||||
stopped: false,
|
||||
reason: "The recorded process identity could not be verified",
|
||||
code: "unverified",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -420,7 +438,10 @@ test("shows why Stop left the service alone", async (t) => {
|
|||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
assert.match(texts(renderer.toJSON()).join(" "), /could not be verified/);
|
||||
// The Host code is localized in the UI instead of leaking its English text.
|
||||
assert.ok(
|
||||
texts(renderer.toJSON()).join(" ").includes(envZh.stopUnknownIdentity),
|
||||
);
|
||||
});
|
||||
|
||||
test("surfaces a rejected settings write", async (t) => {
|
||||
|
|
@ -438,3 +459,283 @@ test("surfaces a rejected settings write", async (t) => {
|
|||
});
|
||||
assert.match(texts(renderer.toJSON()).join(" "), /设置未保存/);
|
||||
});
|
||||
|
||||
test("an aborted LLM test releases its button", async (t) => {
|
||||
const { renderer, setValue, restore } = await mount(
|
||||
{},
|
||||
{ pending: ["remeEnv/testLlm"] },
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.expand).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.testLlmButton).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
assert.ok(texts(renderer.toJSON()).join(" ").includes(envZh.testingLlm));
|
||||
|
||||
await act(async () => {
|
||||
setValue({ remeWorkspaceDir: "/tmp/second-workspace" });
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.equal(copy.includes(envZh.testingLlm), false);
|
||||
assert.ok(copy.includes(envZh.testLlmButton), "the button is usable again");
|
||||
});
|
||||
|
||||
test("a start that died before listening reports immediately", async (t) => {
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": stopped,
|
||||
"remeEnv/serviceStart": {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: stopped.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.startFailedStopped));
|
||||
// Ready to retry without waiting out the 120 s deadline.
|
||||
assert.ok(buttonByText(renderer, envZh.startService));
|
||||
});
|
||||
|
||||
test("a start that lands on a foreign conflict shows the conflict state", async (t) => {
|
||||
let calls = 0;
|
||||
const states = [
|
||||
{
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
},
|
||||
{
|
||||
phase: "conflict",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
},
|
||||
];
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": () =>
|
||||
states[Math.min(calls++, states.length - 1)],
|
||||
"remeEnv/serviceStart": {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: states[0].logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
});
|
||||
assert.ok(texts(renderer.toJSON()).join(" ").includes(envZh.conflictNote));
|
||||
});
|
||||
|
||||
test("Reinstall forces a reinstall while install and upgrade use the right flags", async () => {
|
||||
const upgrade = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/checkReme": {
|
||||
installed: true,
|
||||
version: "0.3.1.8",
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
buttonByText(upgrade.renderer, envZh.upgradeButton).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(upgrade.renderer, envZh.confirmInstall).props.onClick();
|
||||
});
|
||||
const upgradeCall = upgrade.calls.rpc
|
||||
.filter((call) => call.method === "remeEnv/install")
|
||||
.at(-1);
|
||||
assert.deepEqual(upgradeCall.input, {
|
||||
pythonPath: SUGGESTED,
|
||||
upgrade: true,
|
||||
});
|
||||
upgrade.restore();
|
||||
|
||||
const fresh = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/checkReme": {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
buttonByText(fresh.renderer, envZh.installButton).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(fresh.renderer, envZh.confirmInstall).props.onClick();
|
||||
});
|
||||
const freshCall = fresh.calls.rpc
|
||||
.filter((call) => call.method === "remeEnv/install")
|
||||
.at(-1);
|
||||
assert.deepEqual(freshCall.input, {
|
||||
pythonPath: SUGGESTED,
|
||||
upgrade: false,
|
||||
});
|
||||
fresh.restore();
|
||||
|
||||
const reinstall = await mount();
|
||||
await act(async () => {
|
||||
buttonByText(reinstall.renderer, envZh.reinstall).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(reinstall.renderer, envZh.confirmInstall).props.onClick();
|
||||
});
|
||||
const reinstallCall = reinstall.calls.rpc
|
||||
.filter((call) => call.method === "remeEnv/install")
|
||||
.at(-1);
|
||||
assert.deepEqual(reinstallCall.input, {
|
||||
pythonPath: SUGGESTED,
|
||||
upgrade: false,
|
||||
forceReinstall: true,
|
||||
});
|
||||
reinstall.restore();
|
||||
});
|
||||
|
||||
test("localizes a coded start error instead of showing Host English", async (t) => {
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": stopped,
|
||||
"remeEnv/serviceStart": {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
error: "Another process is already listening on this endpoint",
|
||||
code: "conflict",
|
||||
command: "",
|
||||
logPath: stopped.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.startErrorConflict));
|
||||
assert.equal(copy.includes("Another process is already listening"), false);
|
||||
});
|
||||
|
||||
test("switching the endpoint aborts the previous start poll", async (t) => {
|
||||
const OLD = "http://127.0.0.1:2333";
|
||||
const NEW = "http://127.0.0.1:2444";
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: OLD,
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const newRunning = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 1111,
|
||||
endpoint: NEW,
|
||||
version: "9.9.9",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const oldRunning = {
|
||||
...newRunning,
|
||||
pid: 5000,
|
||||
endpoint: OLD,
|
||||
version: "0.4.0.9",
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, setValue, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": (input) => {
|
||||
if (input?.endpoint === NEW) return newRunning;
|
||||
statusCalls += 1;
|
||||
return statusCalls === 1 ? stopped : oldRunning;
|
||||
},
|
||||
"remeEnv/serviceStart": {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: stopped.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
// Switch before the poll's 1 s tick: it must not paint the old endpoint's
|
||||
// running data under the new URL.
|
||||
await act(async () => {
|
||||
setValue({ endpoint: NEW });
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("PID 1111"), "the new endpoint's pid is shown");
|
||||
assert.equal(copy.includes("PID 5000"), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue