mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-11 08:38:48 +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.
|
- `[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.
|
- `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
|
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
|
||||||
index (PyPI, Tsinghua, Aliyun). Large wheels are downloaded on first use; expect 5–20 minutes depending on the
|
Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
|
||||||
network.
|
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:
|
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
|
- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually
|
||||||
exited.
|
exited.
|
||||||
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place; install and
|
- 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.
|
- When the plugin unloads, running jobs are stopped.
|
||||||
|
|
||||||
## Workspace `.env`
|
## Workspace `.env`
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,17 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
|
||||||
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
|
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
|
||||||
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
|
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
|
||||||
|
|
||||||
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加升级与软件源(PyPI、清华、阿里云)。首次使用需要
|
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
|
||||||
下载较大的依赖,视网络情况通常 5–20 分钟。
|
`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
|
||||||
|
依赖,视网络情况通常 5–20 分钟。
|
||||||
|
|
||||||
安装是 Host 侧的任务:
|
安装是 Host 侧的任务:
|
||||||
|
|
||||||
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
|
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
|
||||||
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
|
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
|
||||||
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
|
- **取消**会同时终止 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`
|
## Workspace `.env`
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,11 @@ export interface DependencyState {
|
||||||
/** Wall clock tick while an installation runs, for the elapsed label. */
|
/** Wall clock tick while an installation runs, for the elapsed label. */
|
||||||
now: number;
|
now: number;
|
||||||
recheck(): void;
|
recheck(): void;
|
||||||
start(upgrade: boolean, indexUrl?: string): Promise<void>;
|
start(
|
||||||
|
upgrade: boolean,
|
||||||
|
indexUrl?: string,
|
||||||
|
forceReinstall?: boolean,
|
||||||
|
): Promise<void>;
|
||||||
cancel(): Promise<void>;
|
cancel(): Promise<void>;
|
||||||
dismiss(): void;
|
dismiss(): void;
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +181,7 @@ export function useDependency(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
async (upgrade: boolean, indexUrl?: string) => {
|
async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
|
||||||
if (!pythonPath || install.phase === "running") return;
|
if (!pythonPath || install.phase === "running") return;
|
||||||
setInstall({ phase: "idle" });
|
setInstall({ phase: "idle" });
|
||||||
try {
|
try {
|
||||||
|
|
@ -186,6 +190,7 @@ export function useDependency(
|
||||||
pythonPath,
|
pythonPath,
|
||||||
upgrade,
|
upgrade,
|
||||||
...(indexUrl ? { indexUrl } : {}),
|
...(indexUrl ? { indexUrl } : {}),
|
||||||
|
...(forceReinstall ? { forceReinstall } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setInstall(
|
setInstall(
|
||||||
|
|
@ -256,7 +261,7 @@ export function DependencySection({
|
||||||
: checkData.versionOk && checkData.hasCore
|
: checkData.versionOk && checkData.hasCore
|
||||||
? { tone: "ok" as const, text: t("depReady") }
|
? { tone: "ok" as const, text: t("depReady") }
|
||||||
: checkData.versionOk
|
: checkData.versionOk
|
||||||
? { tone: "progress" as const, text: t("coreMissing") }
|
? { tone: "progress" as const, text: t("coreMissingBadge") }
|
||||||
: { tone: "progress" as const, text: t("depUpgrade") };
|
: { tone: "progress" as const, text: t("depUpgrade") };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -302,7 +307,9 @@ export function DependencySection({
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{checkData?.error ? (
|
{checkData?.error ? (
|
||||||
<p className="reme-settings-error">{checkData.error}</p>
|
<p className="reme-settings-error">
|
||||||
|
{`${t("checkRemeFailed")}: ${checkData.error}`}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{checkData ? (
|
{checkData ? (
|
||||||
<div className="reme-env-metrics">
|
<div className="reme-env-metrics">
|
||||||
|
|
@ -386,11 +393,16 @@ export function DependencySection({
|
||||||
className="reme-settings-save"
|
className="reme-settings-save"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirming(false);
|
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
|
// The mirror choice reaches pip as `--index-url`; without
|
||||||
// it the selector would be decorative.
|
// it the selector would be decorative.
|
||||||
void dependency.start(
|
void dependency.start(
|
||||||
!checkData.versionOk,
|
checkData.installed && !checkData.versionOk,
|
||||||
mirror || undefined,
|
mirror || undefined,
|
||||||
|
reinstall,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export const envEn = {
|
||||||
upgradeRecommended: "upgrade recommended",
|
upgradeRecommended: "upgrade recommended",
|
||||||
coreOk: "Available",
|
coreOk: "Available",
|
||||||
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
||||||
|
coreMissingBadge: "core missing",
|
||||||
cliFallback: "python wrapper fallback",
|
cliFallback: "python wrapper fallback",
|
||||||
installButton: "Install reme-ai[core]",
|
installButton: "Install reme-ai[core]",
|
||||||
upgradeButton: "Upgrade reme-ai",
|
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.",
|
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
|
||||||
serviceBadgeStarting: "Starting…",
|
serviceBadgeStarting: "Starting…",
|
||||||
serviceBadgeConflict: "Port occupied",
|
serviceBadgeConflict: "Port occupied",
|
||||||
|
serviceBadgeUnhealthy: "Not healthy",
|
||||||
serviceBadgeStopped: "Stopped",
|
serviceBadgeStopped: "Stopped",
|
||||||
serviceRunning: "Running",
|
serviceRunning: "Running",
|
||||||
startService: "Start service",
|
startService: "Start service",
|
||||||
stopService: "Stop service",
|
stopService: "Stop service",
|
||||||
startTimeout: "The service did not become healthy within 120 seconds",
|
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:
|
stopConfirm:
|
||||||
"Stop the ReMe service? Automatic memory in flight may need a retry.",
|
"Stop the ReMe service? Automatic memory in flight may need a retry.",
|
||||||
foreignNote:
|
foreignNote:
|
||||||
"This service was started outside DSH; its state is shown but it is not managed here.",
|
"This service was started outside DSH; its state is shown but it is not managed here.",
|
||||||
conflictNote:
|
conflictNote:
|
||||||
"Another program occupies this port. Change the service URL port in Memory settings.",
|
"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",
|
viewLogs: "View log",
|
||||||
logEmpty: "The service log is empty.",
|
logEmpty: "The service log is empty.",
|
||||||
autoStartLabel: "Start the service automatically with DSH",
|
autoStartLabel: "Start the service automatically with DSH",
|
||||||
|
|
@ -192,6 +217,7 @@ export const envZh: typeof envEn = {
|
||||||
upgradeRecommended: "建议升级",
|
upgradeRecommended: "建议升级",
|
||||||
coreOk: "可用",
|
coreOk: "可用",
|
||||||
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
||||||
|
coreMissingBadge: "缺少 core",
|
||||||
cliFallback: "回退到 python 包装",
|
cliFallback: "回退到 python 包装",
|
||||||
installButton: "安装 reme-ai[core]",
|
installButton: "安装 reme-ai[core]",
|
||||||
upgradeButton: "升级 reme-ai",
|
upgradeButton: "升级 reme-ai",
|
||||||
|
|
@ -244,14 +270,31 @@ export const envZh: typeof envEn = {
|
||||||
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
|
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
|
||||||
serviceBadgeStarting: "启动中…",
|
serviceBadgeStarting: "启动中…",
|
||||||
serviceBadgeConflict: "端口被占用",
|
serviceBadgeConflict: "端口被占用",
|
||||||
|
serviceBadgeUnhealthy: "未就绪",
|
||||||
serviceBadgeStopped: "已停止",
|
serviceBadgeStopped: "已停止",
|
||||||
serviceRunning: "运行中",
|
serviceRunning: "运行中",
|
||||||
startService: "启动服务",
|
startService: "启动服务",
|
||||||
stopService: "停止服务",
|
stopService: "停止服务",
|
||||||
startTimeout: "服务在 120 秒内未就绪",
|
startTimeout: "服务在 120 秒内未就绪",
|
||||||
|
startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
|
||||||
|
startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
|
||||||
|
startErrorConflict: "该端口已被其他程序占用。",
|
||||||
|
startErrorOtherEndpointRunning:
|
||||||
|
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
|
||||||
|
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
|
||||||
|
startErrorNoPython: "请先选择一个 Python 解释器。",
|
||||||
|
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
|
||||||
|
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
|
||||||
|
startErrorFailed: "服务未能启动,请查看日志。",
|
||||||
|
stopNoRecord: "本插件没有启动过该服务。",
|
||||||
|
stopForeign: "记录中的服务不属于当前服务地址。",
|
||||||
|
stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
|
||||||
|
stopNotExited: "服务未退出;已保留记录,可稍后重试。",
|
||||||
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
|
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
|
||||||
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
|
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
|
||||||
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
||||||
|
conflictManagedNote:
|
||||||
|
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
|
||||||
viewLogs: "查看日志",
|
viewLogs: "查看日志",
|
||||||
logEmpty: "服务日志为空。",
|
logEmpty: "服务日志为空。",
|
||||||
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
||||||
|
|
|
||||||
|
|
@ -67,18 +67,28 @@ export function usePython(
|
||||||
setDetection({ phase: "ready", data: parseDetection(value) });
|
setDetection({ phase: "ready", data: parseDetection(value) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted)
|
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();
|
return () => controller.abort();
|
||||||
}, [rpc, tick, enabled]);
|
}, [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 suggested = detected?.suggested ?? "";
|
||||||
const selectedPath = pythonPath || suggested;
|
const selectedPath = pythonPath || suggested;
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setDetection({ phase: "loading" });
|
setDetection((current) => ({
|
||||||
|
phase: "loading",
|
||||||
|
...(current.data ? { data: current.data } : {}),
|
||||||
|
}));
|
||||||
setTick((current) => current + 1);
|
setTick((current) => current + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,14 @@ function booleanField(
|
||||||
return value;
|
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(
|
function arrayField(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
key: string,
|
key: string,
|
||||||
|
|
@ -105,7 +113,29 @@ export function parseDetection(value: unknown): PythonDetection {
|
||||||
stringField(env, "exe", what);
|
stringField(env, "exe", what);
|
||||||
stringField(env, "version", what);
|
stringField(env, "version", what);
|
||||||
booleanField(env, "ok", 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;
|
return value as PythonDetection;
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +148,7 @@ export function parseRemeCheck(value: unknown): RemeCheck {
|
||||||
booleanField(body, "hasCore", what);
|
booleanField(body, "hasCore", what);
|
||||||
stringField(body, "version", what);
|
stringField(body, "version", what);
|
||||||
stringField(body, "cliPath", what);
|
stringField(body, "cliPath", what);
|
||||||
|
stringField(body, "error", what);
|
||||||
return value as unknown as RemeCheck;
|
return value as unknown as RemeCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,6 +161,8 @@ export function parseInstallStatus(value: unknown): InstallStatus | null {
|
||||||
stringField(body, "pythonPath", what);
|
stringField(body, "pythonPath", what);
|
||||||
stringField(body, "startedAt", what);
|
stringField(body, "startedAt", what);
|
||||||
stringField(body, "logPath", what);
|
stringField(body, "logPath", what);
|
||||||
|
stringField(body, "logTail", what);
|
||||||
|
if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
|
||||||
const phase = stringField(body, "phase", what);
|
const phase = stringField(body, "phase", what);
|
||||||
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
|
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
|
||||||
fail(what);
|
fail(what);
|
||||||
|
|
@ -190,6 +223,8 @@ export function parseServiceStart(value: unknown): ServiceStartResult {
|
||||||
stringField(body, "command", what);
|
stringField(body, "command", what);
|
||||||
stringField(body, "logPath", what);
|
stringField(body, "logPath", what);
|
||||||
stringField(body, "startedAt", what);
|
stringField(body, "startedAt", what);
|
||||||
|
optionalStringField(body, "error", what);
|
||||||
|
optionalStringField(body, "code", what);
|
||||||
return value as unknown as ServiceStartResult;
|
return value as unknown as ServiceStartResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,12 +232,15 @@ export interface ServiceStopView {
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseServiceStop(value: unknown): ServiceStopView {
|
export function parseServiceStop(value: unknown): ServiceStopView {
|
||||||
const what = "service stop";
|
const what = "service stop";
|
||||||
const body = record(value, what);
|
const body = record(value, what);
|
||||||
booleanField(body, "stopped", what);
|
booleanField(body, "stopped", what);
|
||||||
|
optionalStringField(body, "reason", what);
|
||||||
|
optionalStringField(body, "code", what);
|
||||||
return value as ServiceStopView;
|
return value as ServiceStopView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { StatusRpc } from "../../client/status-page.js";
|
import type { StatusRpc } from "../../client/status-page.js";
|
||||||
import { safeError } from "../../endpoint.js";
|
import { safeError } from "../../endpoint.js";
|
||||||
import type { ServiceState } from "../types.js";
|
import type { ServiceStartResult, ServiceState } from "../types.js";
|
||||||
import type { EnvTranslator } from "./locales.js";
|
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||||
import { fill } from "./locales.js";
|
|
||||||
import { StatusBadge } from "./presentation.js";
|
import { StatusBadge } from "./presentation.js";
|
||||||
import {
|
import {
|
||||||
envCall,
|
envCall,
|
||||||
|
|
@ -21,6 +20,38 @@ import {
|
||||||
*/
|
*/
|
||||||
const START_DEADLINE_MS = 120_000;
|
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 {
|
export interface ServiceViewState {
|
||||||
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
|
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
|
||||||
data?: ServiceState;
|
data?: ServiceState;
|
||||||
|
|
@ -75,9 +106,15 @@ export function useService(
|
||||||
const startController = useRef<AbortController | undefined>(undefined);
|
const startController = useRef<AbortController | undefined>(undefined);
|
||||||
|
|
||||||
// A new endpoint has no known phase yet; keeping the previous endpoint's
|
// 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(() => {
|
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" });
|
setState({ phase: "checking" });
|
||||||
|
setStopError("");
|
||||||
}, [endpoint]);
|
}, [endpoint]);
|
||||||
|
|
||||||
useEffect(() => () => startController.current?.abort(), []);
|
useEffect(() => () => startController.current?.abort(), []);
|
||||||
|
|
@ -96,18 +133,19 @@ export function useService(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setState((current) =>
|
// Recheck must not be swallowed while a start is in flight: the
|
||||||
current.phase === "starting"
|
// response belongs to the endpoint on screen. `stopped` with a live pid
|
||||||
? current
|
// is the Host's "launch still booting" signal.
|
||||||
: { phase: next.phase, data: next },
|
setState({
|
||||||
);
|
phase:
|
||||||
|
next.phase === "stopped" && next.pid !== undefined
|
||||||
|
? "starting"
|
||||||
|
: next.phase,
|
||||||
|
data: next,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setState((current) =>
|
setState({ phase: "error", error: safeError(error) });
|
||||||
current.phase === "starting"
|
|
||||||
? current
|
|
||||||
: { phase: "error", error: safeError(error) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
|
|
@ -134,7 +172,7 @@ export function useService(
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
if (started.error) {
|
if (started.error) {
|
||||||
setState({ phase: "error", error: started.error });
|
setState({ phase: "error", error: startErrorText(t, started) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deadline = Date.now() + START_DEADLINE_MS;
|
const deadline = Date.now() + START_DEADLINE_MS;
|
||||||
|
|
@ -154,10 +192,23 @@ export function useService(
|
||||||
controller.signal,
|
controller.signal,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (status.phase === "running") {
|
if (status.phase === "running") {
|
||||||
setState({ phase: "running", data: status });
|
setState({ phase: "running", data: status });
|
||||||
return;
|
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 {
|
} catch {
|
||||||
// Keep polling: a starting service answers once it is listening.
|
// Keep polling: a starting service answers once it is listening.
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +226,7 @@ export function useService(
|
||||||
const result = parseServiceStop(
|
const result = parseServiceStop(
|
||||||
await envCall(rpc, "serviceStop", { endpoint }),
|
await envCall(rpc, "serviceStop", { endpoint }),
|
||||||
);
|
);
|
||||||
setStopError(result.stopped ? "" : result.reason || t("serviceError"));
|
setStopError(result.stopped ? "" : stopReasonText(t, result));
|
||||||
refresh();
|
refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setState({ phase: "error", error: safeError(error) });
|
setState({ phase: "error", error: safeError(error) });
|
||||||
|
|
@ -276,6 +327,7 @@ export function ServiceSection({
|
||||||
remeInstalled,
|
remeInstalled,
|
||||||
autoStart,
|
autoStart,
|
||||||
writable,
|
writable,
|
||||||
|
current,
|
||||||
onAutoStart,
|
onAutoStart,
|
||||||
}: {
|
}: {
|
||||||
t: EnvTranslator;
|
t: EnvTranslator;
|
||||||
|
|
@ -285,6 +337,8 @@ export function ServiceSection({
|
||||||
remeInstalled: boolean;
|
remeInstalled: boolean;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
writable: boolean;
|
writable: boolean;
|
||||||
|
/** False while the state on screen belongs to another endpoint. */
|
||||||
|
current: boolean;
|
||||||
onAutoStart(checked: boolean): void;
|
onAutoStart(checked: boolean): void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const { state, stopping, stopError, log } = control;
|
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
|
// offer the same Stop affordance instead of leaving the tab without a way
|
||||||
// out other than editing the record by hand.
|
// out other than editing the record by hand.
|
||||||
const canStop =
|
const canStop =
|
||||||
state.phase === "running" ||
|
current &&
|
||||||
(state.phase === "conflict" && state.data?.managed === true);
|
(state.phase === "running" ||
|
||||||
|
(state.phase === "conflict" && state.data?.managed === true));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="reme-env-card">
|
<section className="reme-env-card">
|
||||||
|
|
@ -307,10 +362,14 @@ export function ServiceSection({
|
||||||
<p>{t("serviceHint")}</p>
|
<p>{t("serviceHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
<aside>
|
<aside>
|
||||||
{state.phase === "checking" || state.phase === "starting" ? (
|
{!current ||
|
||||||
|
state.phase === "checking" ||
|
||||||
|
state.phase === "starting" ? (
|
||||||
<StatusBadge tone="progress">
|
<StatusBadge tone="progress">
|
||||||
{t(
|
{t(
|
||||||
state.phase === "starting"
|
!current
|
||||||
|
? "checking"
|
||||||
|
: state.phase === "starting"
|
||||||
? "serviceBadgeStarting"
|
? "serviceBadgeStarting"
|
||||||
: "checking",
|
: "checking",
|
||||||
)}
|
)}
|
||||||
|
|
@ -323,7 +382,11 @@ export function ServiceSection({
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
) : state.phase === "conflict" ? (
|
) : state.phase === "conflict" ? (
|
||||||
<StatusBadge tone="progress">
|
<StatusBadge tone="progress">
|
||||||
{t("serviceBadgeConflict")}
|
{t(
|
||||||
|
state.data?.managed
|
||||||
|
? "serviceBadgeUnhealthy"
|
||||||
|
: "serviceBadgeConflict",
|
||||||
|
)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
||||||
|
|
@ -344,7 +407,8 @@ export function ServiceSection({
|
||||||
{t("recheck")}
|
{t("recheck")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{state.phase === "running" || state.phase === "conflict" ? (
|
{current &&
|
||||||
|
(state.phase === "running" || state.phase === "conflict") ? (
|
||||||
<p className="reme-settings-muted">
|
<p className="reme-settings-muted">
|
||||||
{state.data?.managed
|
{state.data?.managed
|
||||||
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
|
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
|
||||||
|
|
@ -353,8 +417,10 @@ export function ServiceSection({
|
||||||
: null}
|
: null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{state.phase === "conflict" ? (
|
{current && state.phase === "conflict" ? (
|
||||||
<p className="reme-settings-muted">{t("conflictNote")}</p>
|
<p className="reme-settings-muted">
|
||||||
|
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{state.phase === "error" && state.error ? (
|
{state.phase === "error" && state.error ? (
|
||||||
<p className="reme-settings-error">
|
<p className="reme-settings-error">
|
||||||
|
|
@ -405,7 +471,11 @@ export function ServiceSection({
|
||||||
type="button"
|
type="button"
|
||||||
className="reme-settings-save"
|
className="reme-settings-save"
|
||||||
disabled={
|
disabled={
|
||||||
state.phase === "starting" || !remeInstalled || !pythonPath
|
!current ||
|
||||||
|
state.phase === "starting" ||
|
||||||
|
state.phase === "conflict" ||
|
||||||
|
!remeInstalled ||
|
||||||
|
!pythonPath
|
||||||
}
|
}
|
||||||
onClick={() => void control.start()}
|
onClick={() => void control.start()}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,13 @@ function endpointKey(endpoint: string): string {
|
||||||
try {
|
try {
|
||||||
const url = new URL(endpoint);
|
const url = new URL(endpoint);
|
||||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||||
return `${
|
// Match the Host's loopback alias rule so `localhost` and `127.0.0.1`
|
||||||
url.protocol
|
// answers compare equal.
|
||||||
}//${url.hostname.toLowerCase()}:${port}${url.pathname.replace(
|
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 authToken = value?.authToken ?? "";
|
||||||
const [heroTick, setHeroTick] = useState(0);
|
const [heroTick, setHeroTick] = useState(0);
|
||||||
// Settings writes are invisible to the sections otherwise: a rejected
|
// Settings writes are invisible to the sections otherwise: a rejected
|
||||||
// revision or a transport error would silently roll the control back.
|
// revision or a transport error would silently roll the control back. The
|
||||||
const [settingsError, setSettingsError] = useState("");
|
// 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(
|
const writeSetting = useCallback(
|
||||||
async (field: string, next: unknown): Promise<boolean> => {
|
async (field: string, next: unknown): Promise<boolean> => {
|
||||||
|
|
@ -145,18 +153,20 @@ export function ReMeEnvironmentTab({
|
||||||
snapshot.revision,
|
snapshot.revision,
|
||||||
);
|
);
|
||||||
if (applied === false) {
|
if (applied === false) {
|
||||||
setSettingsError(t("settingsWriteFailed"));
|
setSettingsError({ endpoint, message: t("settingsWriteFailed") });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setSettingsError("");
|
setSettingsError({ endpoint, message: "" });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSettingsError(safeError(error));
|
setSettingsError({ endpoint, message: safeError(error) });
|
||||||
return false;
|
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, {
|
const python = usePython(rpc, {
|
||||||
pythonPath: value?.pythonPath ?? "",
|
pythonPath: value?.pythonPath ?? "",
|
||||||
|
|
@ -198,6 +208,7 @@ export function ReMeEnvironmentTab({
|
||||||
// The Host names the endpoint its answer belongs to; comparing that with the
|
// 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.
|
// settings endpoint is what makes a stale answer fail the `current` test.
|
||||||
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
|
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
|
||||||
|
const serviceCurrent = endpointKey(serviceEndpoint) === endpointKey(endpoint);
|
||||||
const connection: ConnectionState = local
|
const connection: ConnectionState = local
|
||||||
? service.state.phase === "running"
|
? service.state.phase === "running"
|
||||||
? {
|
? {
|
||||||
|
|
@ -222,8 +233,10 @@ export function ReMeEnvironmentTab({
|
||||||
const ready =
|
const ready =
|
||||||
current &&
|
current &&
|
||||||
connection.phase === "ok" &&
|
connection.phase === "ok" &&
|
||||||
dependency.checkData?.installed === true &&
|
(!local ||
|
||||||
dependency.checkData.versionOk;
|
(dependency.checkData?.installed === true &&
|
||||||
|
dependency.checkData.versionOk &&
|
||||||
|
dependency.checkData.hasCore));
|
||||||
const scannedAt = python.detection.data?.scannedAt
|
const scannedAt = python.detection.data?.scannedAt
|
||||||
? Date.parse(python.detection.data.scannedAt)
|
? Date.parse(python.detection.data.scannedAt)
|
||||||
: Number.NaN;
|
: Number.NaN;
|
||||||
|
|
@ -259,8 +272,12 @@ export function ReMeEnvironmentTab({
|
||||||
</div>
|
</div>
|
||||||
<div className="reme-env-hero-chips">
|
<div className="reme-env-hero-chips">
|
||||||
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
|
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
|
||||||
|
{local ? (
|
||||||
|
<>
|
||||||
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
|
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
|
||||||
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
|
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="reme-env-hero-actions">
|
<div className="reme-env-hero-actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -294,9 +311,9 @@ export function ReMeEnvironmentTab({
|
||||||
{connection.error}
|
{connection.error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{settingsError ? (
|
{settingsErrorText ? (
|
||||||
<p className="reme-settings-error reme-env-hero-error" role="alert">
|
<p className="reme-settings-error reme-env-hero-error" role="alert">
|
||||||
{settingsError}
|
{settingsErrorText}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!local ? (
|
{!local ? (
|
||||||
|
|
@ -329,6 +346,7 @@ export function ReMeEnvironmentTab({
|
||||||
remeInstalled={dependency.checkData?.installed === true}
|
remeInstalled={dependency.checkData?.installed === true}
|
||||||
autoStart={value?.autoStart === true}
|
autoStart={value?.autoStart === true}
|
||||||
writable={snapshot.writable}
|
writable={snapshot.writable}
|
||||||
|
current={serviceCurrent}
|
||||||
onAutoStart={(checked) => {
|
onAutoStart={(checked) => {
|
||||||
void writeSetting("autoStart", checked);
|
void writeSetting("autoStart", checked);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,10 @@ export function useWorkspace(
|
||||||
error: safeError(error),
|
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],
|
[llmTest.phase, rpc, workspaceDir],
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,19 @@ export class ReMeEnvGateway extends TypertRemoteService {
|
||||||
@Remote("install")
|
@Remote("install")
|
||||||
async install(
|
async install(
|
||||||
input:
|
input:
|
||||||
| { pythonPath?: string; upgrade?: boolean; indexUrl?: string }
|
| {
|
||||||
|
pythonPath?: string;
|
||||||
|
upgrade?: boolean;
|
||||||
|
indexUrl?: string;
|
||||||
|
forceReinstall?: boolean;
|
||||||
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
): Promise<InstallStatus> {
|
): Promise<InstallStatus> {
|
||||||
return this.ctx.remeEnv.install({
|
return this.ctx.remeEnv.install({
|
||||||
pythonPath: input?.pythonPath ?? "",
|
pythonPath: input?.pythonPath ?? "",
|
||||||
upgrade: input?.upgrade === true,
|
upgrade: input?.upgrade === true,
|
||||||
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
|
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 { join } from "node:path";
|
||||||
|
|
||||||
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
|
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
|
||||||
import { openAppendLog } from "./log.js";
|
import { openAppendLog, pruneInstallLogs } from "./log.js";
|
||||||
import { isAbsolutePath } from "./paths.js";
|
import { isAbsolutePath } from "./paths.js";
|
||||||
|
|
||||||
export interface InstallChild {
|
export interface InstallChild {
|
||||||
|
|
@ -31,6 +31,9 @@ export function pipArgs(request: InstallRequest): string[] {
|
||||||
"--no-input",
|
"--no-input",
|
||||||
];
|
];
|
||||||
if (request.upgrade) args.push("-U");
|
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();
|
const indexUrl = (request.indexUrl ?? "").trim();
|
||||||
if (indexUrl) {
|
if (indexUrl) {
|
||||||
if (!/^https?:\/\/\S+$/i.test(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
|
// The cut may be mid-line and even mid-character; drop that partial line
|
||||||
// so the tail never starts with a replacement character.
|
// so the tail never starts with a replacement character.
|
||||||
const firstBreak = text.indexOf("\n");
|
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");
|
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||||
return text
|
return text
|
||||||
|
|
@ -206,6 +212,7 @@ export class InstallJobs {
|
||||||
36,
|
36,
|
||||||
)}-${(this.sequence += 1).toString(36)}`;
|
)}-${(this.sequence += 1).toString(36)}`;
|
||||||
mkdirSync(this.options.logDir, { recursive: true });
|
mkdirSync(this.options.logDir, { recursive: true });
|
||||||
|
pruneInstallLogs(this.options.logDir);
|
||||||
const logPath = join(this.options.logDir, `${id}.log`);
|
const logPath = join(this.options.logDir, `${id}.log`);
|
||||||
const child = this.options.spawner.start({
|
const child = this.options.spawner.start({
|
||||||
exe: pythonPath,
|
exe: pythonPath,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { mkdirSync, openSync, renameSync, statSync } from "node:fs";
|
import {
|
||||||
import { dirname } from "node:path";
|
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. */
|
/** Rotate a log once it grows past this size; only one backup is kept. */
|
||||||
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
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.
|
* Open a log for appending, rotating an oversized one to `<path>.1` first.
|
||||||
|
|
@ -22,3 +31,32 @@ export function openAppendLog(
|
||||||
}
|
}
|
||||||
return openSync(path, "a");
|
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,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: `A service start for ${starting.endpoint} is still in progress`,
|
error: `A service start for ${starting.endpoint} is still in progress`,
|
||||||
|
code: "other-endpoint-starting",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.starting = undefined;
|
this.starting = undefined;
|
||||||
|
|
@ -374,6 +375,7 @@ export class ReMeEnvManager {
|
||||||
logPath,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: "Another process is already listening on this endpoint",
|
error: "Another process is already listening on this endpoint",
|
||||||
|
code: "conflict",
|
||||||
};
|
};
|
||||||
const previous = await stopService(this.serviceIO, {
|
const previous = await stopService(this.serviceIO, {
|
||||||
statePath: this.serviceStatePath,
|
statePath: this.serviceStatePath,
|
||||||
|
|
@ -390,6 +392,7 @@ export class ReMeEnvManager {
|
||||||
error: `A previous launch could not be stopped: ${
|
error: `A previous launch could not be stopped: ${
|
||||||
previous.reason ?? "unknown reason"
|
previous.reason ?? "unknown reason"
|
||||||
}`,
|
}`,
|
||||||
|
code: "previous-not-stopped",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A record for a *different* endpoint that is still alive would be
|
// A record for a *different* endpoint that is still alive would be
|
||||||
|
|
@ -417,6 +420,7 @@ export class ReMeEnvManager {
|
||||||
logPath,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
|
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
|
||||||
|
code: "other-endpoint-running",
|
||||||
};
|
};
|
||||||
removeServiceState(this.serviceStatePath);
|
removeServiceState(this.serviceStatePath);
|
||||||
}
|
}
|
||||||
|
|
@ -445,6 +449,7 @@ export class ReMeEnvManager {
|
||||||
error: `A previous launch could not be stopped: ${
|
error: `A previous launch could not be stopped: ${
|
||||||
previous.reason ?? "unknown reason"
|
previous.reason ?? "unknown reason"
|
||||||
}`,
|
}`,
|
||||||
|
code: "previous-not-stopped",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
removeServiceState(this.serviceStatePath);
|
removeServiceState(this.serviceStatePath);
|
||||||
|
|
@ -459,6 +464,7 @@ export class ReMeEnvManager {
|
||||||
logPath,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: "Select a Python interpreter first",
|
error: "Select a Python interpreter first",
|
||||||
|
code: "no-python",
|
||||||
};
|
};
|
||||||
const check = await this.checkReme(path);
|
const check = await this.checkReme(path);
|
||||||
if (!check.installed)
|
if (!check.installed)
|
||||||
|
|
@ -469,6 +475,7 @@ export class ReMeEnvManager {
|
||||||
logPath,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: "reme-ai is not installed in the selected interpreter",
|
error: "reme-ai is not installed in the selected interpreter",
|
||||||
|
code: "not-installed",
|
||||||
};
|
};
|
||||||
if (!check.versionOk)
|
if (!check.versionOk)
|
||||||
return {
|
return {
|
||||||
|
|
@ -480,6 +487,7 @@ export class ReMeEnvManager {
|
||||||
error: `reme-ai ${
|
error: `reme-ai ${
|
||||||
check.version || "(unknown version)"
|
check.version || "(unknown version)"
|
||||||
} is older than ${MINIMUM_REME}; upgrade it first`,
|
} 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
|
// 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
|
// booting observes the first launch instead of spawning another one. It is
|
||||||
|
|
@ -508,6 +516,7 @@ export class ReMeEnvManager {
|
||||||
logPath,
|
logPath,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
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 { basename, dirname } from "node:path";
|
||||||
|
|
||||||
import { safeError } from "../../endpoint.js";
|
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 { openAppendLog } from "./log.js";
|
||||||
|
import { isWindowsPath } from "./paths.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The local ReMe service is a *detached* process: it outlives DSH, which is the
|
* The local ReMe service is a *detached* process: it outlives DSH, which is the
|
||||||
|
|
@ -45,6 +50,8 @@ export interface ServiceStopResult {
|
||||||
pid?: number;
|
pid?: number;
|
||||||
/** Why an unmanaged or missing service was left alone. */
|
/** Why an unmanaged or missing service was left alone. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
/** Stable reason the client localizes; `reason` stays for diagnostics. */
|
||||||
|
code?: ServiceStopReasonCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceIO {
|
export interface ServiceIO {
|
||||||
|
|
@ -256,9 +263,17 @@ export function isServiceImage(image: string): boolean {
|
||||||
*/
|
*/
|
||||||
export type ServiceOwnership = "owned" | "foreign" | "unknown";
|
export type ServiceOwnership = "owned" | "foreign" | "unknown";
|
||||||
|
|
||||||
/** Compare paths and ports with separators and case normalized. */
|
/**
|
||||||
function normalizeCommandText(value: string): string {
|
* Compare paths and ports with separators normalized. Windows command lines
|
||||||
return value.replaceAll("\\\\", "\\").replaceAll("/", "\\").toLowerCase();
|
* 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();
|
const workspace = persisted.workspaceDir.trim();
|
||||||
if (!workspace) return false;
|
if (!workspace) return false;
|
||||||
const text = normalizeCommandText(command);
|
const foldCase = isWindowsPath(workspace);
|
||||||
const marker = `workspace_dir=${normalizeCommandText(workspace)}`;
|
const text = normalizeCommandText(command, foldCase);
|
||||||
|
const marker = `workspace_dir=${normalizeCommandText(workspace, foldCase)}`;
|
||||||
const index = text.indexOf(marker);
|
const index = text.indexOf(marker);
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
// Require a boundary so `C:\a` does not match another service using `C:\ab`.
|
// 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] ?? "";
|
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);
|
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,6 +461,7 @@ export async function startService(
|
||||||
logPath: options.logPath,
|
logPath: options.logPath,
|
||||||
startedAt,
|
startedAt,
|
||||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||||
|
code: "start-failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let child: { pid: number | undefined };
|
let child: { pid: number | undefined };
|
||||||
|
|
@ -468,6 +486,7 @@ export async function startService(
|
||||||
logPath: options.logPath,
|
logPath: options.logPath,
|
||||||
startedAt,
|
startedAt,
|
||||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||||
|
code: "start-failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -494,6 +513,7 @@ export async function startService(
|
||||||
logPath: options.logPath,
|
logPath: options.logPath,
|
||||||
startedAt,
|
startedAt,
|
||||||
error: `Could not record the ReMe service: ${safeError(error)}`,
|
error: `Could not record the ReMe service: ${safeError(error)}`,
|
||||||
|
code: "start-failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -556,7 +576,11 @@ export async function stopService(
|
||||||
const persisted = readServiceState(options.statePath);
|
const persisted = readServiceState(options.statePath);
|
||||||
if (!persisted || !isValidPid(persisted.pid)) {
|
if (!persisted || !isValidPid(persisted.pid)) {
|
||||||
if (persisted) clearServiceState(options.statePath);
|
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 =
|
const ownership =
|
||||||
options.ownership ??
|
options.ownership ??
|
||||||
|
|
@ -566,11 +590,13 @@ export async function stopService(
|
||||||
stopped: false,
|
stopped: false,
|
||||||
reason:
|
reason:
|
||||||
"The recorded process identity could not be verified; refusing to stop it",
|
"The recorded process identity could not be verified; refusing to stop it",
|
||||||
|
code: "unverified",
|
||||||
};
|
};
|
||||||
if (ownership !== "owned")
|
if (ownership !== "owned")
|
||||||
return {
|
return {
|
||||||
stopped: false,
|
stopped: false,
|
||||||
reason: "The recorded service does not belong to this endpoint",
|
reason: "The recorded service does not belong to this endpoint",
|
||||||
|
code: "foreign",
|
||||||
};
|
};
|
||||||
const pid = persisted.pid;
|
const pid = persisted.pid;
|
||||||
const pollMs = options.pollMs ?? STOP_POLL_MS;
|
const pollMs = options.pollMs ?? STOP_POLL_MS;
|
||||||
|
|
@ -607,6 +633,7 @@ export async function stopService(
|
||||||
stopped: false,
|
stopped: false,
|
||||||
pid,
|
pid,
|
||||||
reason: "The service did not exit after the force-kill",
|
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],
|
stdio: ["ignore", fd, fd],
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
child.once("spawn", () => resolve());
|
// A spawn acknowledgement that never arrives must not block the
|
||||||
child.once("error", (error: Error) => reject(error));
|
// 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 {
|
} finally {
|
||||||
// The child owns its inherited handle; the parent copy is no longer
|
// The child owns its inherited handle; the parent copy is no longer
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ export interface InstallRequest {
|
||||||
pythonPath: string;
|
pythonPath: string;
|
||||||
/** Upgrade an existing installation to the newest satisfying version. */
|
/** Upgrade an existing installation to the newest satisfying version. */
|
||||||
upgrade?: boolean;
|
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. */
|
/** Optional package index URL (mirror); empty keeps the user's pip config. */
|
||||||
indexUrl?: string;
|
indexUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +135,24 @@ export interface ServiceState {
|
||||||
logPath: string;
|
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 {
|
export interface ServiceStartResult {
|
||||||
started: boolean;
|
started: boolean;
|
||||||
alreadyRunning: boolean;
|
alreadyRunning: boolean;
|
||||||
|
|
@ -141,7 +161,9 @@ export interface ServiceStartResult {
|
||||||
command: string;
|
command: string;
|
||||||
logPath: string;
|
logPath: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
/** English diagnostic; shown only when no known `code` applies. */
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: ServiceStartErrorCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceLog {
|
export interface ServiceLog {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,12 @@ test("composes pip arguments for install, upgrade and mirrors", () => {
|
||||||
"reme-ai[core]",
|
"reme-ai[core]",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
pipArgs({
|
||||||
|
pythonPath: "C:\\env\\python.exe",
|
||||||
|
forceReinstall: true,
|
||||||
|
}).includes("--force-reinstall"),
|
||||||
|
);
|
||||||
assert.throws(() =>
|
assert.throws(() =>
|
||||||
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
|
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
closeSync,
|
closeSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
|
@ -12,7 +13,10 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { createInstallSpawner } from "../dist/environment/host/install.js";
|
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";
|
import { createNodeRunner } from "../dist/environment/host/runner.js";
|
||||||
|
|
||||||
test("a timeout settles with an explicit error instead of hanging", async () => {
|
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(readFileSync(`${path}.1`, "utf8").length, 200);
|
||||||
assert.equal(statSync(path).size, 0);
|
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) => {
|
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
|
||||||
const { directory, statePath } = tempState(t, "reme-dead-");
|
const { directory, statePath } = tempState(t, "reme-dead-");
|
||||||
record(statePath);
|
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 }), {
|
assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), {
|
||||||
stopped: false,
|
stopped: false,
|
||||||
reason: "No service started by this plugin",
|
reason: "No service started by this plugin",
|
||||||
|
code: "no-record",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const RESPONSES = {
|
||||||
versionOk: true,
|
versionOk: true,
|
||||||
hasCore: true,
|
hasCore: true,
|
||||||
cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`,
|
cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`,
|
||||||
|
error: "",
|
||||||
},
|
},
|
||||||
"remeEnv/installStatus": null,
|
"remeEnv/installStatus": null,
|
||||||
"remeEnv/readEnv": {
|
"remeEnv/readEnv": {
|
||||||
|
|
@ -147,9 +148,24 @@ async function mount(overrides = {}, options = {}) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const rpc = {
|
const rpc = {
|
||||||
call: async (_path, method, payload) => {
|
call: async (_path, method, payload, signal) => {
|
||||||
calls.rpc.push({ method, input: payload?.args?.input });
|
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,
|
pythonPath: SUGGESTED,
|
||||||
upgrade: false,
|
upgrade: false,
|
||||||
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
|
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": {
|
"remeEnv/serviceStop": {
|
||||||
stopped: false,
|
stopped: false,
|
||||||
reason: "The recorded process identity could not be verified",
|
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 act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
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) => {
|
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(" "), /设置未保存/);
|
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