mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-11 08:38:48 +00:00
fix(dsh): address the environment review on 603ea41
This commit is contained in:
parent
603ea41fba
commit
9f35f0c434
21 changed files with 855 additions and 164 deletions
|
|
@ -120,18 +120,23 @@ processes it manages when the Host exits. The plugin owns a small service adapte
|
|||
**after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind.
|
||||
- A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on
|
||||
screen, the pid is still alive, its image is still a Python or `reme` executable, **and** its command line still
|
||||
carries the recorded `workspace_dir` and `service.port`. The command line is what makes a recycled pid safe: an
|
||||
image name alone cannot prove which launch a pid belongs to. A record for port 2333 never claims a service on
|
||||
port 2444. When the evidence cannot be read, ownership stays unproven: the process is shown as foreign, Stop
|
||||
refuses to touch it, and a later Start drops the stale record instead of being blocked forever.
|
||||
carries the recorded `workspace_dir` and `service.port`. The command line rules out most recycled pids (a longer
|
||||
workspace path or another port never matches), though it cannot tell a pid apart from a user who starts the exact
|
||||
same command by hand. A record for port 2333 never claims a service on port 2444.
|
||||
- When the evidence cannot be read (localized `tasklist`/`ps`, missing permissions), ownership stays unproven. Start
|
||||
and Stop both leave the process and the record alone: Stop refuses, and Start reports an error instead of dropping
|
||||
the record. Discarding it would orphan a live service that the tab could never stop again. Verify the process
|
||||
yourself, stop it, then retry — or remove `service.json` if it is really gone.
|
||||
- A service that occupies the port but fails the health check is reported as a conflict; if it is provably this
|
||||
plugin's service, the tab still offers **Stop**.
|
||||
- **Stop** tree-kills the recorded process (process group on POSIX, `taskkill /T /F` on Windows), waits for it to
|
||||
exit, escalates (POSIX: `SIGTERM` then `SIGKILL`; Windows: force `taskkill` again), and clears the record only
|
||||
after the pid is gone. A service started elsewhere stays untouched and is only observed.
|
||||
- A recorded launch that is still alive but never became healthy is shown as **Last launch not ready**, with Stop as
|
||||
the way out; it is not shown as "Starting" unless a start from this tab is actually in flight.
|
||||
- **Start** is serialized: two requests cannot spawn two services, and a second request observes the launch
|
||||
already in flight. Before replacing a recorded launch that never became healthy, Start only kills it when
|
||||
ownership is proven; otherwise it drops the record without touching the process.
|
||||
ownership is proven; an unproven record is kept and reported, never silently dropped.
|
||||
- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running.
|
||||
|
||||
Python probes and pip installs are bounded Host operations; the service is the only process that must outlive DSH.
|
||||
|
|
@ -164,9 +169,14 @@ the file is left unchanged.
|
|||
**The service reports “Port occupied”.** Something else listens on the endpoint. Change the port in the service
|
||||
URL (Memory settings) or stop the other program.
|
||||
|
||||
**The service does not become healthy.** The Start action polls for 120 seconds and then reports a timeout. Use
|
||||
**View log** (`$DSH_HOME`-independent: the workspace `service.log`) for the startup trace — a missing `reme-ai`,
|
||||
an invalid `.env`, or a port conflict all appear there.
|
||||
**The service does not become healthy.** The Start action polls for 120 seconds. If the port is answered but
|
||||
`/health_check` never passes, the card switches to the conflict state so **Stop** stays available. Use **View log**
|
||||
(the workspace `service.log`) for the startup trace — a missing `reme-ai`, an invalid `.env`, or a port conflict all
|
||||
appear there.
|
||||
|
||||
**The service shows “Last launch not ready”.** A launch recorded by the plugin is still alive but never answered
|
||||
`/health_check` (for example DSH closed while it was booting). Use **Stop** to clear it, then start again. If Stop
|
||||
reports that the identity cannot be verified, confirm the process yourself before ending it.
|
||||
|
||||
**The service shows as foreign.** It was started outside the plugin, so it is observed but not managed. Stop it
|
||||
in its own terminal if you want the tab to manage the next launch.
|
||||
|
|
|
|||
|
|
@ -100,15 +100,20 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把
|
|||
- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间),
|
||||
所以解释器缺失时不会留下残留状态。
|
||||
- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与
|
||||
`service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行是防止 pid 复用的关键证据:只看映像名
|
||||
无法证明 pid 属于哪次启动。2333 端口上的记录绝不会声称管理 2444 端口上的服务。证据读不到时按失败关闭处理:显示为
|
||||
外部进程,停止操作不会碰它;之后点启动会丢弃这条过期记录,而不是永久卡住。
|
||||
`service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行能排除大多数 pid 复用(更长的
|
||||
workspace 路径、另一个端口都不会命中),但无法区分“用户照抄同一命令行手工启动”的同一服务。2333 端口上的记录绝不会
|
||||
声称管理 2444 端口上的服务。
|
||||
- 证据读不到时(`tasklist`/`ps` 输出本地化、权限不足等)归属保持“未证明”:启动与停止都不会碰进程、也不会丢弃记录——
|
||||
停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;请自行确认该进程后结束它
|
||||
再重试,若确实已消失则删除 `service.json`。
|
||||
- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。
|
||||
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX:
|
||||
`SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,
|
||||
只会被观察。
|
||||
- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并给出**停止**按钮;只有本标签页发起的启动仍在进行时才会
|
||||
显示“启动中”。
|
||||
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
|
||||
归属被证明才会杀掉它;证明不了就只丢弃记录,不碰进程。
|
||||
归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。
|
||||
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。
|
||||
|
||||
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。
|
||||
|
|
@ -137,8 +142,11 @@ Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要
|
|||
|
||||
**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。
|
||||
|
||||
**服务迟迟未就绪。** 启动会轮询 120 秒后报超时。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、
|
||||
`.env` 不合法、端口冲突都会体现在那里。
|
||||
**服务迟迟未就绪。** 启动会轮询 120 秒;如果端口有响应但 `/health_check` 一直不通过,卡片会切到“端口被占用”状态,**停止**按钮
|
||||
始终可用。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、`.env` 不合法、端口冲突都会体现在那里。
|
||||
|
||||
**服务显示“上次启动未就绪”。** 本插件记录的进程仍存活,但一直没有通过 `/health_check`(例如启动途中 DSH 被关闭)。点**停止**
|
||||
清掉它,再重新启动。若停止时提示无法验证进程身份,请先自行确认该进程再结束它。
|
||||
|
||||
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从
|
||||
这里启动。
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useEffect, useReducer, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type { SettingsFieldState } from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
||||
|
|
@ -289,21 +296,40 @@ function ReMeSettingsCard({
|
|||
envT,
|
||||
view,
|
||||
}: ReMeCardProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => scope.subscribe(listener),
|
||||
() => scope.getSnapshot(),
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => scope.subscribe(listener),
|
||||
[scope],
|
||||
);
|
||||
const getSnapshot = useCallback(() => scope.getSnapshot(), [scope]);
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
||||
// DSH's form model stages the drafts, marks the ones that are invalid and
|
||||
// writes every staged edit as one revision-fenced mutation, so this page owns
|
||||
// only its controls and the field specs behind them.
|
||||
const [model] = useState(
|
||||
() => new SettingsFormModel<ReMeSettings>(scope, SETTINGS_SPECS),
|
||||
);
|
||||
const disposeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [, publish] = useReducer((tick: number) => tick + 1, 0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [tab, setTab] = useState<"memory" | "environment">("memory");
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
useEffect(() => {
|
||||
// React StrictMode mounts, cleans up and mounts again in development.
|
||||
// Disposing the model immediately would hand the remounted card a dead
|
||||
// one, so the cleanup is deferred and cancelled by the next mount.
|
||||
if (disposeTimer.current !== undefined) {
|
||||
clearTimeout(disposeTimer.current);
|
||||
disposeTimer.current = undefined;
|
||||
}
|
||||
return () => {
|
||||
disposeTimer.current = setTimeout(() => {
|
||||
disposeTimer.current = undefined;
|
||||
model.dispose();
|
||||
}, 0);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
if (view === "summary") return <>{t("description")}</>;
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
|
|
@ -358,7 +384,9 @@ function ReMeSettingsCard({
|
|||
type="button"
|
||||
role="tab"
|
||||
id="reme-tab-memory"
|
||||
aria-controls="reme-tabpanel-memory"
|
||||
aria-controls={
|
||||
tab === "memory" ? "reme-tabpanel-memory" : undefined
|
||||
}
|
||||
aria-selected={tab === "memory"}
|
||||
className={tab === "memory" ? "active" : ""}
|
||||
onClick={() => setTab("memory")}
|
||||
|
|
@ -369,7 +397,9 @@ function ReMeSettingsCard({
|
|||
type="button"
|
||||
role="tab"
|
||||
id="reme-tab-environment"
|
||||
aria-controls="reme-tabpanel-environment"
|
||||
aria-controls={
|
||||
tab === "environment" ? "reme-tabpanel-environment" : undefined
|
||||
}
|
||||
aria-selected={tab === "environment"}
|
||||
className={tab === "environment" ? "active" : ""}
|
||||
onClick={() => setTab("environment")}
|
||||
|
|
|
|||
|
|
@ -294,10 +294,12 @@ export function ReMeStatusPage({
|
|||
rpc,
|
||||
t,
|
||||
}: ReMeStatusPageProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => scope.subscribe(listener),
|
||||
() => scope.getSnapshot(),
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => scope.subscribe(listener),
|
||||
[scope],
|
||||
);
|
||||
const getSnapshot = useCallback(() => scope.getSnapshot(), [scope]);
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const settings = snapshot.value;
|
||||
const [tab, setTab] = useState<StatusTab>("overview");
|
||||
const [data, setData] = useState<StatusData>({ loading: true, error: "" });
|
||||
|
|
|
|||
|
|
@ -253,6 +253,9 @@ export function DependencySection({
|
|||
const { checkData, checkError, checking, install, now } = dependency;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mirror, setMirror] = useState("");
|
||||
// The confirm copy follows the CTA: a Reinstall downloads again with
|
||||
// `--force-reinstall`, so the first-install wording would be misleading.
|
||||
const reinstall = checkData?.installed === true && checkData.versionOk;
|
||||
|
||||
const badge = !checkData
|
||||
? undefined
|
||||
|
|
@ -307,9 +310,13 @@ export function DependencySection({
|
|||
</p>
|
||||
) : null}
|
||||
{checkData?.error ? (
|
||||
<p className="reme-settings-error">
|
||||
{`${t("checkRemeFailed")}: ${checkData.error}`}
|
||||
</p>
|
||||
<>
|
||||
<p className="reme-settings-error">{t("checkRemeFailed")}</p>
|
||||
<details className="reme-settings-details">
|
||||
<summary>{t("errorsTitle")}</summary>
|
||||
<pre>{checkData.error}</pre>
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
{checkData ? (
|
||||
<div className="reme-env-metrics">
|
||||
|
|
@ -387,7 +394,11 @@ export function DependencySection({
|
|||
</label>
|
||||
{confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>{fill(t("installConfirm"), { path: pythonPath })}</p>
|
||||
<p>
|
||||
{fill(t(reinstall ? "reinstallConfirm" : "installConfirm"), {
|
||||
path: pythonPath,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
|
|
@ -395,8 +406,6 @@ export function DependencySection({
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export const envEn = {
|
|||
reinstall: "Reinstall reme-ai[core]",
|
||||
installConfirm:
|
||||
"Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
|
||||
reinstallConfirm:
|
||||
"Reinstalls reme-ai[core] into {path} with --force-reinstall. Packages are downloaded again; progress is shown below.",
|
||||
confirmInstall: "Confirm install",
|
||||
cancel: "Cancel",
|
||||
installing: "Installing",
|
||||
|
|
@ -100,6 +102,7 @@ export const envEn = {
|
|||
serviceBadgeConflict: "Port occupied",
|
||||
serviceBadgeUnhealthy: "Not healthy",
|
||||
serviceBadgeStopped: "Stopped",
|
||||
serviceBadgeStale: "Last launch not ready",
|
||||
serviceRunning: "Running",
|
||||
startService: "Start service",
|
||||
stopService: "Stop service",
|
||||
|
|
@ -113,6 +116,8 @@ export const envEn = {
|
|||
"A service for another endpoint is still running; stop it there first.",
|
||||
startErrorPreviousNotStopped:
|
||||
"The previous service could not be stopped; check the log.",
|
||||
startErrorUnverifiedRecord:
|
||||
"The recorded service identity could not be verified, so it was left untouched; stop it manually, then retry.",
|
||||
startErrorNoPython: "Select a Python interpreter first.",
|
||||
startErrorNotInstalled:
|
||||
"reme-ai is not installed in the selected interpreter.",
|
||||
|
|
@ -133,6 +138,15 @@ export const envEn = {
|
|||
"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.",
|
||||
staleNote:
|
||||
"A recorded launch is still running but never became healthy. Stop it, then start again.",
|
||||
healthErrorHttp:
|
||||
"The endpoint returned an HTTP error, not a ReMe health report.",
|
||||
healthErrorNotReme:
|
||||
"The endpoint answered, but not with a ReMe health report.",
|
||||
healthErrorReme: "The service reported an error during the health check.",
|
||||
healthErrorUnreachable:
|
||||
"The endpoint could not be reached from this machine.",
|
||||
viewLogs: "View log",
|
||||
logEmpty: "The service log is empty.",
|
||||
autoStartLabel: "Start the service automatically with DSH",
|
||||
|
|
@ -224,6 +238,8 @@ export const envZh: typeof envEn = {
|
|||
reinstall: "重装 reme-ai[core]",
|
||||
installConfirm:
|
||||
"将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
|
||||
reinstallConfirm:
|
||||
"将使用 --force-reinstall 重新安装到 {path},依赖会重新下载,进度见下方日志。",
|
||||
confirmInstall: "确认安装",
|
||||
cancel: "取消",
|
||||
installing: "正在安装",
|
||||
|
|
@ -272,6 +288,7 @@ export const envZh: typeof envEn = {
|
|||
serviceBadgeConflict: "端口被占用",
|
||||
serviceBadgeUnhealthy: "未就绪",
|
||||
serviceBadgeStopped: "已停止",
|
||||
serviceBadgeStale: "上次启动未就绪",
|
||||
serviceRunning: "运行中",
|
||||
startService: "启动服务",
|
||||
stopService: "停止服务",
|
||||
|
|
@ -282,6 +299,8 @@ export const envZh: typeof envEn = {
|
|||
startErrorOtherEndpointRunning:
|
||||
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
|
||||
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
|
||||
startErrorUnverifiedRecord:
|
||||
"无法验证记录中的进程身份,已保持其运行;请手动停止后重试。",
|
||||
startErrorNoPython: "请先选择一个 Python 解释器。",
|
||||
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
|
||||
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
|
||||
|
|
@ -295,6 +314,11 @@ export const envZh: typeof envEn = {
|
|||
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
||||
conflictManagedNote:
|
||||
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
|
||||
staleNote: "上次启动的进程仍在运行,但从未就绪;请停止它后重新启动。",
|
||||
healthErrorHttp: "服务地址返回了 HTTP 错误,而不是 ReMe 健康报告。",
|
||||
healthErrorNotReme: "服务地址有响应,但返回的不是 ReMe 健康报告。",
|
||||
healthErrorReme: "服务在健康检查中报告了错误。",
|
||||
healthErrorUnreachable: "无法从本机连接到该服务地址。",
|
||||
viewLogs: "查看日志",
|
||||
logEmpty: "服务日志为空。",
|
||||
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ export function PythonSection({
|
|||
writable: boolean;
|
||||
}): JSX.Element {
|
||||
const { detection, suggested, selectedPath, saving } = selection;
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
// A refresh keeps the last detection data, so the list must keep rendering it
|
||||
// instead of flashing "no usable Python" while the new probe is in flight.
|
||||
const detected = detection.data;
|
||||
const envs = detected?.envs ?? [];
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -176,7 +178,8 @@ export function PythonSection({
|
|||
{detection.error ? `: ${detection.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{detection.phase === "error" || envs.length === 0 ? (
|
||||
{detection.phase === "error" ||
|
||||
(detection.phase === "ready" && envs.length === 0) ? (
|
||||
<div className="reme-env-hint">
|
||||
<strong>{t("emptyTitle")}</strong>
|
||||
<span>{t("emptyHint")}</span>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
|
||||
/** Methods the Host exposes on the `remeEnv` wire namespace. */
|
||||
|
|
@ -85,6 +86,17 @@ function optionalStringField(
|
|||
if (body[key] !== undefined && typeof body[key] !== "string") fail(what);
|
||||
}
|
||||
|
||||
function optionalPidField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): void {
|
||||
const value = body[key];
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value <= 1)
|
||||
fail(what);
|
||||
}
|
||||
|
||||
function arrayField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
|
|
@ -181,6 +193,7 @@ export function parseEnvSummary(value: unknown): EnvSummary {
|
|||
booleanField(entry, "secret", what);
|
||||
booleanField(entry, "set", what);
|
||||
stringField(entry, "masked", what);
|
||||
stringField(entry, "value", what);
|
||||
}
|
||||
return value as unknown as EnvSummary;
|
||||
}
|
||||
|
|
@ -191,6 +204,7 @@ export function parseLlmResult(value: unknown): LlmTestResult {
|
|||
booleanField(body, "ok", what);
|
||||
if (typeof body.status !== "number" || typeof body.latencyMs !== "number")
|
||||
fail(what);
|
||||
stringField(body, "error", what);
|
||||
return value as unknown as LlmTestResult;
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +215,7 @@ export function parseHealth(value: unknown): ConnectionHealth {
|
|||
stringField(body, "version", what);
|
||||
stringField(body, "error", what);
|
||||
stringField(body, "endpoint", what);
|
||||
optionalStringField(body, "code", what);
|
||||
return value as unknown as ConnectionHealth;
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +227,9 @@ export function parseServiceState(value: unknown): ServiceState {
|
|||
booleanField(body, "managed", what);
|
||||
stringField(body, "endpoint", what);
|
||||
stringField(body, "logPath", what);
|
||||
optionalPidField(body, "pid", what);
|
||||
optionalStringField(body, "version", what);
|
||||
optionalStringField(body, "startedAt", what);
|
||||
return value as unknown as ServiceState;
|
||||
}
|
||||
|
||||
|
|
@ -225,23 +243,19 @@ export function parseServiceStart(value: unknown): ServiceStartResult {
|
|||
stringField(body, "startedAt", what);
|
||||
optionalStringField(body, "error", what);
|
||||
optionalStringField(body, "code", what);
|
||||
optionalStringField(body, "version", what);
|
||||
optionalPidField(body, "pid", what);
|
||||
return value as unknown as ServiceStartResult;
|
||||
}
|
||||
|
||||
export interface ServiceStopView {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
reason?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export function parseServiceStop(value: unknown): ServiceStopView {
|
||||
export function parseServiceStop(value: unknown): ServiceStopResult {
|
||||
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;
|
||||
optionalPidField(body, "pid", what);
|
||||
return value as unknown as ServiceStopResult;
|
||||
}
|
||||
|
||||
export function parseServiceLog(value: unknown): ServiceLog {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { ServiceStartResult, ServiceState } from "../types.js";
|
||||
import type {
|
||||
HealthErrorCode,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
} from "../types.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import {
|
||||
|
|
@ -26,6 +30,7 @@ const START_ERROR_KEYS: Record<string, EnvKey> = {
|
|||
conflict: "startErrorConflict",
|
||||
"other-endpoint-running": "startErrorOtherEndpointRunning",
|
||||
"previous-not-stopped": "startErrorPreviousNotStopped",
|
||||
"unverified-record": "startErrorUnverifiedRecord",
|
||||
"no-python": "startErrorNoPython",
|
||||
"not-installed": "startErrorNotInstalled",
|
||||
"version-too-old": "startErrorVersionTooOld",
|
||||
|
|
@ -104,17 +109,35 @@ export function useService(
|
|||
logPath: "",
|
||||
});
|
||||
const startController = useRef<AbortController | undefined>(undefined);
|
||||
/** True only while a start this tab requested is being polled. */
|
||||
const startInFlight = useRef(false);
|
||||
/** The endpoint the callbacks below belong to; stale answers are dropped. */
|
||||
const endpointRef = useRef(endpoint);
|
||||
/** Sequence of status answers; a slow one must not overwrite a newer phase. */
|
||||
const statusSeq = useRef(0);
|
||||
|
||||
const applyState = useCallback((next: ServiceViewState) => {
|
||||
// An answer that settles a start is newer than any status read that was
|
||||
// already in flight, so that read is discarded when it lands.
|
||||
statusSeq.current += 1;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
// A new endpoint has no known phase yet; keeping the previous endpoint's
|
||||
// "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(() => {
|
||||
endpointRef.current = endpoint;
|
||||
startInFlight.current = false;
|
||||
startController.current?.abort();
|
||||
// Answers requested for the old endpoint are no longer relevant.
|
||||
statusSeq.current += 1;
|
||||
// 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("");
|
||||
setStopping(false);
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => () => startController.current?.abort(), []);
|
||||
|
|
@ -122,6 +145,8 @@ export function useService(
|
|||
useEffect(() => {
|
||||
if (!local || !endpoint) return;
|
||||
const controller = new AbortController();
|
||||
const request = statusSeq.current + 1;
|
||||
statusSeq.current = request;
|
||||
void (async () => {
|
||||
try {
|
||||
const next = parseServiceState(
|
||||
|
|
@ -132,19 +157,21 @@ export function useService(
|
|||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
// 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.
|
||||
if (controller.signal.aborted || request !== statusSeq.current) return;
|
||||
// A start this tab requested maps "stopped with a live pid" (the Host's
|
||||
// "launch still booting" signal) to `starting`; a plain status read
|
||||
// keeps the Host's phase, so a leftover process never locks the card.
|
||||
setState({
|
||||
phase:
|
||||
next.phase === "stopped" && next.pid !== undefined
|
||||
next.phase === "stopped" &&
|
||||
next.pid !== undefined &&
|
||||
startInFlight.current
|
||||
? "starting"
|
||||
: next.phase,
|
||||
data: next,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (controller.signal.aborted || request !== statusSeq.current) return;
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
|
|
@ -158,6 +185,9 @@ export function useService(
|
|||
startController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
startController.current = controller;
|
||||
startInFlight.current = true;
|
||||
// A status read requested before this click is older than the start.
|
||||
statusSeq.current += 1;
|
||||
setState({ phase: "starting" });
|
||||
setStopError("");
|
||||
setLog({ open: false, lines: "", logPath: "" });
|
||||
|
|
@ -172,15 +202,45 @@ export function useService(
|
|||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (started.error) {
|
||||
setState({ phase: "error", error: startErrorText(t, started) });
|
||||
if (started.code === "conflict") {
|
||||
// The Host refused because the port answered. Showing that as a
|
||||
// conflict card (with Stop when it is ours) matches what the poll
|
||||
// below would report, instead of an error whose Start just repeats.
|
||||
try {
|
||||
const status = parseServiceState(
|
||||
await envCall(
|
||||
rpc,
|
||||
"serviceStatus",
|
||||
{ endpoint, authToken },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (status.phase === "conflict") {
|
||||
applyState({ phase: "conflict", data: status });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the reported start error.
|
||||
}
|
||||
}
|
||||
applyState({ phase: "error", error: startErrorText(t, started) });
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + START_DEADLINE_MS;
|
||||
let ownedConflict: ServiceState | undefined;
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (controller.signal.aborted) return;
|
||||
if (Date.now() > deadline) {
|
||||
setState({ phase: "error", error: t("startTimeout") });
|
||||
// A service that holds the port but never turns healthy cannot be
|
||||
// recovered by waiting; an *owned* one is still stoppable, so keep
|
||||
// that way out instead of a dead-end timeout error.
|
||||
applyState(
|
||||
ownedConflict
|
||||
? { phase: "conflict", data: ownedConflict }
|
||||
: { phase: "error", error: t("startTimeout") },
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -194,46 +254,82 @@ export function useService(
|
|||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (status.phase === "running") {
|
||||
setState({ phase: "running", data: status });
|
||||
applyState({ phase: "running", data: status });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "conflict") {
|
||||
// Someone (possibly our own booting service) owns the port.
|
||||
setState({ phase: "conflict", data: status });
|
||||
if (status.managed) {
|
||||
// Our own launch can hold the port before `/health_check`
|
||||
// passes. Keep polling instead of offering Stop too early; if it
|
||||
// never becomes healthy the deadline shows the conflict card.
|
||||
ownedConflict = status;
|
||||
continue;
|
||||
}
|
||||
applyState({ 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") });
|
||||
applyState({ phase: "error", error: t("startFailedStopped") });
|
||||
return;
|
||||
}
|
||||
// stopped with a live pid: the process is still booting.
|
||||
// stopped with a live pid: the process is still booting. Carry the
|
||||
// pid so a launch that never becomes healthy can still be stopped.
|
||||
if (status.pid !== undefined)
|
||||
setState((current) =>
|
||||
current.phase === "starting"
|
||||
? { phase: "starting", data: status }
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
// Keep polling: a starting service answers once it is listening.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
applyState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
if (startController.current === controller) {
|
||||
startController.current = undefined;
|
||||
startInFlight.current = false;
|
||||
}
|
||||
}
|
||||
}, [authToken, endpoint, pythonPath, rpc, state.phase, t, workspaceDir]);
|
||||
}, [
|
||||
applyState,
|
||||
authToken,
|
||||
endpoint,
|
||||
pythonPath,
|
||||
rpc,
|
||||
state.phase,
|
||||
t,
|
||||
workspaceDir,
|
||||
]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (stopping) return;
|
||||
const target = endpoint;
|
||||
// A Stop during a start must cancel that poll, or its next tick would
|
||||
// repaint the launch the user just cancelled.
|
||||
startController.current?.abort();
|
||||
startInFlight.current = false;
|
||||
setStopping(true);
|
||||
try {
|
||||
const result = parseServiceStop(
|
||||
await envCall(rpc, "serviceStop", { endpoint }),
|
||||
await envCall(rpc, "serviceStop", { endpoint: target }),
|
||||
);
|
||||
if (endpointRef.current !== target) return;
|
||||
setStopError(result.stopped ? "" : stopReasonText(t, result));
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
if (endpointRef.current !== target) return;
|
||||
applyState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
setStopping(false);
|
||||
// A result for the previous endpoint must not disable the new card; that
|
||||
// endpoint's effect already reset the flag.
|
||||
if (endpointRef.current === target) setStopping(false);
|
||||
}
|
||||
}, [endpoint, refresh, rpc, stopping, t]);
|
||||
}, [applyState, endpoint, refresh, rpc, stopping, t]);
|
||||
|
||||
const viewLog = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -269,6 +365,8 @@ export interface ConnectionState {
|
|||
endpoint: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
/** Stable Host reason, localized by the hero instead of showing `error`. */
|
||||
code?: HealthErrorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -305,7 +403,12 @@ export function useHealth(options: {
|
|||
setHealth(
|
||||
result.ok
|
||||
? { phase: "ok", endpoint, version: result.version }
|
||||
: { phase: "fail", endpoint, error: result.error },
|
||||
: {
|
||||
phase: "fail",
|
||||
endpoint,
|
||||
error: result.error,
|
||||
...(result.code === undefined ? {} : { code: result.code }),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
|
|
@ -345,11 +448,21 @@ export function ServiceSection({
|
|||
const [confirming, setConfirming] = useState(false);
|
||||
// A conflict can still be our own unhealthy service (booting, wrong token);
|
||||
// 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. `stopped`/`starting` with a
|
||||
// live pid is a recorded launch that never became healthy: Stop is the only
|
||||
// way to clear it, so it must be offered there too.
|
||||
const canStop =
|
||||
current &&
|
||||
(state.phase === "running" ||
|
||||
(state.phase === "conflict" && state.data?.managed === true));
|
||||
(state.phase === "conflict" && state.data?.managed === true) ||
|
||||
((state.phase === "stopped" || state.phase === "starting") &&
|
||||
state.data?.pid !== undefined));
|
||||
// Only a pid this plugin recorded can be stopped; a foreign running service
|
||||
// has no managed flag and keeps the button disabled.
|
||||
const canSignal =
|
||||
state.data?.managed === true ||
|
||||
((state.phase === "stopped" || state.phase === "starting") &&
|
||||
state.data?.pid !== undefined);
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
|
|
@ -388,6 +501,8 @@ export function ServiceSection({
|
|||
: "serviceBadgeConflict",
|
||||
)}
|
||||
</StatusBadge>
|
||||
) : state.phase === "stopped" && state.data?.pid !== undefined ? (
|
||||
<StatusBadge tone="progress">{t("serviceBadgeStale")}</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
||||
)}
|
||||
|
|
@ -422,10 +537,13 @@ export function ServiceSection({
|
|||
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
|
||||
</p>
|
||||
) : null}
|
||||
{state.phase === "error" && state.error ? (
|
||||
<p className="reme-settings-error">
|
||||
{`${t("serviceError")}: ${state.error}`}
|
||||
</p>
|
||||
{current &&
|
||||
state.phase === "stopped" &&
|
||||
state.data?.pid !== undefined ? (
|
||||
<p className="reme-settings-muted">{t("staleNote")}</p>
|
||||
) : null}
|
||||
{current && state.phase === "error" && state.error ? (
|
||||
<p className="reme-settings-error">{state.error}</p>
|
||||
) : null}
|
||||
{stopError ? (
|
||||
<p className="reme-settings-error" role="alert">
|
||||
|
|
@ -460,7 +578,7 @@ export function ServiceSection({
|
|||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={stopping || !state.data?.managed}
|
||||
disabled={stopping || !canSignal}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("stopService")}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
|||
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { RemeCheck } from "../types.js";
|
||||
import { serviceEndpointKey } from "../endpoint-key.js";
|
||||
import type { HealthErrorCode, RemeCheck } from "../types.js";
|
||||
import type { ReMeSettings } from "../../types.js";
|
||||
import { DependencySection, useDependency } from "./dependency.js";
|
||||
import { fill, type EnvTranslator } from "./locales.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge, type Badge } from "./presentation.js";
|
||||
import { PythonSection, usePython, type PythonSelection } from "./python.js";
|
||||
import {
|
||||
|
|
@ -39,29 +40,6 @@ function isLocalEndpoint(endpoint: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare endpoints the way the Host does, so a default port or a trailing
|
||||
* slash cannot make the Host's answer look like it belongs to an older URL.
|
||||
*/
|
||||
function endpointKey(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
// 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(
|
||||
/\/+$/,
|
||||
"",
|
||||
)}`;
|
||||
} catch {
|
||||
return endpoint.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function connectionBadge(
|
||||
t: EnvTranslator,
|
||||
connection: ConnectionState,
|
||||
|
|
@ -80,6 +58,23 @@ function connectionBadge(
|
|||
return { tone: "bad", text: label(t("disconnected")) };
|
||||
}
|
||||
|
||||
/** Host health failures carry a stable code; older Hosts fall back to `error`. */
|
||||
const HEALTH_ERROR_KEYS: Partial<Record<HealthErrorCode, EnvKey>> = {
|
||||
"http-status": "healthErrorHttp",
|
||||
"not-reme": "healthErrorNotReme",
|
||||
"reme-error": "healthErrorReme",
|
||||
unreachable: "healthErrorUnreachable",
|
||||
};
|
||||
|
||||
function healthErrorText(
|
||||
t: EnvTranslator,
|
||||
connection: ConnectionState,
|
||||
): string {
|
||||
if (connection.phase !== "fail") return "";
|
||||
const key = connection.code ? HEALTH_ERROR_KEYS[connection.code] : undefined;
|
||||
return key ? t(key) : connection.error ?? "";
|
||||
}
|
||||
|
||||
function interpreterBadge(t: EnvTranslator, selection: PythonSelection): Badge {
|
||||
const env = selection.selectedEnv;
|
||||
if (!env)
|
||||
|
|
@ -126,10 +121,12 @@ export function ReMeEnvironmentTab({
|
|||
rpc,
|
||||
t,
|
||||
}: ReMeEnvironmentTabProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => scope.subscribe(listener),
|
||||
() => scope.getSnapshot(),
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => scope.subscribe(listener),
|
||||
[scope],
|
||||
);
|
||||
const getSnapshot = useCallback(() => scope.getSnapshot(), [scope]);
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const value = snapshot.value;
|
||||
const endpoint = value?.endpoint ?? "";
|
||||
const local = isLocalEndpoint(endpoint);
|
||||
|
|
@ -208,7 +205,8 @@ 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 serviceCurrent =
|
||||
serviceEndpointKey(serviceEndpoint) === serviceEndpointKey(endpoint);
|
||||
const connection: ConnectionState = local
|
||||
? service.state.phase === "running"
|
||||
? {
|
||||
|
|
@ -229,7 +227,8 @@ export function ReMeEnvironmentTab({
|
|||
|
||||
const current =
|
||||
connection.phase !== "loading" &&
|
||||
endpointKey(connection.endpoint) === endpointKey(endpoint);
|
||||
serviceEndpointKey(connection.endpoint) === serviceEndpointKey(endpoint);
|
||||
const connectionError = healthErrorText(t, connection);
|
||||
const ready =
|
||||
current &&
|
||||
connection.phase === "ok" &&
|
||||
|
|
@ -306,9 +305,9 @@ export function ReMeEnvironmentTab({
|
|||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{current && connection.phase === "fail" && connection.error ? (
|
||||
{current && connectionError ? (
|
||||
<p className="reme-settings-error reme-env-hero-error">
|
||||
{connection.error}
|
||||
{connectionError}
|
||||
</p>
|
||||
) : null}
|
||||
{settingsErrorText ? (
|
||||
|
|
|
|||
21
integrations/dsh/src/environment/endpoint-key.ts
Normal file
21
integrations/dsh/src/environment/endpoint-key.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Canonical identity of an endpoint, shared by the Host (service records) and
|
||||
* the tab (matching an answer to the URL on screen).
|
||||
*
|
||||
* Loopback aliases and default ports compare equal, so saving
|
||||
* `localhost:2333` does not make the service this plugin started on
|
||||
* `127.0.0.1:2333` look foreign. This module stays browser-safe: no Node
|
||||
* built-ins, because the client imports it too.
|
||||
*/
|
||||
export function serviceEndpointKey(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint.trim());
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const host = hostname === "localhost" ? "127.0.0.1" : hostname;
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
return `${url.protocol}//${host}:${port}${path}`;
|
||||
} catch {
|
||||
return endpoint.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ import type {
|
|||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
import type { ReMeEnvManager } from "./manager.js";
|
||||
import type { ServiceStopResult } from "./service.js";
|
||||
|
||||
declare module "@deepseek-ai/cordis" {
|
||||
interface Context {
|
||||
|
|
|
|||
|
|
@ -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, pruneInstallLogs } from "./log.js";
|
||||
import { INSTALL_LOG_KEEP, openAppendLog, pruneInstallLogs } from "./log.js";
|
||||
import { isAbsolutePath } from "./paths.js";
|
||||
|
||||
export interface InstallChild {
|
||||
|
|
@ -212,7 +212,6 @@ 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,
|
||||
|
|
@ -231,6 +230,15 @@ export class InstallJobs {
|
|||
};
|
||||
this.jobs.set(id, job);
|
||||
this.latest = id;
|
||||
// Prune once the new log exists, so the steady state really is `keep`
|
||||
// files rather than one more than the documented number.
|
||||
pruneInstallLogs(
|
||||
this.options.logDir,
|
||||
INSTALL_LOG_KEEP,
|
||||
[...this.jobs.values()]
|
||||
.filter((running) => running.phase === "running")
|
||||
.map((running) => running.logPath),
|
||||
);
|
||||
child.onExit((code) => this.finish(job, code));
|
||||
return this.snapshot(job);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, 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;
|
||||
|
|
@ -35,12 +35,18 @@ export function openAppendLog(
|
|||
/**
|
||||
* Keep only the newest `keep` per-job logs. Each install writes a new file, so
|
||||
* size-based rotation alone never bounds the directory.
|
||||
*
|
||||
* `active` are logs a running job is still writing; they are never removed.
|
||||
* Each removal is attempted on its own, because one locked file (Windows) must
|
||||
* not abort the rest of the housekeeping.
|
||||
*/
|
||||
export function pruneInstallLogs(
|
||||
directory: string,
|
||||
keep = INSTALL_LOG_KEEP,
|
||||
active: readonly string[] = [],
|
||||
): void {
|
||||
try {
|
||||
const keepSet = new Set(active.map((path) => basename(path)));
|
||||
const entries = readdirSync(directory).filter(
|
||||
(name) => name.startsWith("install-") && name.endsWith(".log"),
|
||||
);
|
||||
|
|
@ -54,8 +60,14 @@ export function pruneInstallLogs(
|
|||
}
|
||||
})
|
||||
.sort((left, right) => right.mtime - left.mtime);
|
||||
for (const old of byAge.slice(keep))
|
||||
rmSync(join(directory, old.name), { force: true });
|
||||
for (const old of byAge.slice(keep)) {
|
||||
if (keepSet.has(old.name)) continue;
|
||||
try {
|
||||
rmSync(join(directory, old.name), { force: true });
|
||||
} catch {
|
||||
// A log another job still holds open is retried next time.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Pruning is housekeeping; a missing directory is not an error.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
startService,
|
||||
stopService,
|
||||
} from "./service.js";
|
||||
import type { ServiceIO, ServiceStopResult } from "./service.js";
|
||||
import type { ServiceIO } from "./service.js";
|
||||
import {
|
||||
envSummary,
|
||||
mergeEnvFile,
|
||||
|
|
@ -39,6 +39,7 @@ import type {
|
|||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
|
||||
const DEFAULT_ENDPOINT = "http://127.0.0.1:2333";
|
||||
|
|
@ -177,6 +178,7 @@ export class ReMeEnvManager {
|
|||
ok: health.ok,
|
||||
version: health.version,
|
||||
error: health.error,
|
||||
...(health.code === undefined ? {} : { code: health.code }),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -257,7 +259,10 @@ export class ReMeEnvManager {
|
|||
serviceLog(workspaceDir: string): ServiceLog {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const logPath = join(dir, "service.log");
|
||||
return { logPath, lines: tailLog(logPath, 40) };
|
||||
// Rotation moves the previous launch's crash output to `service.log.1`; if
|
||||
// the current file is still empty, that backup is the only diagnostic.
|
||||
const lines = tailLog(logPath, 40) || tailLog(`${logPath}.1`, 40);
|
||||
return { logPath, lines };
|
||||
}
|
||||
|
||||
/** DSH-startup best effort: start a stopped local service when configured. */
|
||||
|
|
@ -397,8 +402,9 @@ export class ReMeEnvManager {
|
|||
}
|
||||
// A record for a *different* endpoint that is still alive would be
|
||||
// overwritten by the new launch, leaving that endpoint unable to stop it.
|
||||
// Only a provably owned process blocks the new start; an unprovable record
|
||||
// is stale and is discarded, just like the same-endpoint case below.
|
||||
// A provably owned or an unverifiable process blocks the new start; only a
|
||||
// provably foreign record is discarded, just like the same-endpoint case
|
||||
// below.
|
||||
const recorded = readServiceState(this.serviceStatePath);
|
||||
if (
|
||||
recorded &&
|
||||
|
|
@ -422,11 +428,25 @@ export class ReMeEnvManager {
|
|||
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
|
||||
code: "other-endpoint-running",
|
||||
};
|
||||
// An unreadable image/command line is not proof the process is gone:
|
||||
// dropping the record would orphan a live service this plugin can no
|
||||
// longer stop. Keep it and report why the start is blocked.
|
||||
if (other === "unknown")
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
error:
|
||||
"The recorded service identity could not be verified; leaving it untouched",
|
||||
code: "unverified-record",
|
||||
};
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
// A previous launch that never became healthy may still be alive. Stop it
|
||||
// when ownership is proven; when it cannot be proven, drop the stale record
|
||||
// without killing anything so a recycled pid is never touched.
|
||||
// when ownership is proven; an unprovable record is kept and reported,
|
||||
// because dropping it would orphan a live process the tab cannot stop.
|
||||
if (current.pid !== undefined) {
|
||||
const ownership = await serviceOwnership(
|
||||
this.serviceIO,
|
||||
|
|
@ -451,6 +471,19 @@ export class ReMeEnvManager {
|
|||
}`,
|
||||
code: "previous-not-stopped",
|
||||
};
|
||||
} else if (ownership === "unknown") {
|
||||
// PowerShell/ps failed to describe a live process. Discarding the
|
||||
// record here would orphan it: the tab could never stop or replace it.
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
error:
|
||||
"The recorded service identity could not be verified; leaving it untouched",
|
||||
code: "unverified-record",
|
||||
};
|
||||
} else {
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ import { createConnection } from "node:net";
|
|||
import { basename, dirname } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { serviceEndpointKey } from "../endpoint-key.js";
|
||||
import type {
|
||||
HealthErrorCode,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopReasonCode,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
import { openAppendLog } from "./log.js";
|
||||
import { isWindowsPath } from "./paths.js";
|
||||
|
||||
export { serviceEndpointKey } from "../endpoint-key.js";
|
||||
|
||||
/**
|
||||
* The local ReMe service is a *detached* process: it outlives DSH, which is the
|
||||
* point of starting it from here, so it is deliberately not owned by DSH's
|
||||
|
|
@ -43,15 +47,8 @@ export interface ServiceHealth {
|
|||
version: string;
|
||||
/** Non-empty when the endpoint answered but not as a ReMe service. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ServiceStopResult {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
/** Why an unmanaged or missing service was left alone. */
|
||||
reason?: string;
|
||||
/** Stable reason the client localizes; `reason` stays for diagnostics. */
|
||||
code?: ServiceStopReasonCode;
|
||||
/** Stable reason the client localizes instead of showing `error`. */
|
||||
code?: HealthErrorCode;
|
||||
}
|
||||
|
||||
export interface ServiceIO {
|
||||
|
|
@ -84,24 +81,8 @@ export function isValidPid(pid: unknown): pid is number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Canonical identity of an endpoint for service-record matching. Loopback
|
||||
* aliases and default ports compare equal, so saving `localhost:2333` does not
|
||||
* make the service this plugin started on `127.0.0.1:2333` look foreign.
|
||||
* Only loopback HTTP endpoints may be managed as a local service.
|
||||
*/
|
||||
export function serviceEndpointKey(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint.trim());
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const host = hostname === "localhost" ? "127.0.0.1" : hostname;
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
return `${url.protocol}//${host}:${port}${path}`;
|
||||
} catch {
|
||||
return endpoint.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/** Only loopback HTTP endpoints may be managed as a local service. */
|
||||
export function parseLocalEndpoint(endpoint: string): {
|
||||
host: string;
|
||||
port: number;
|
||||
|
|
@ -296,10 +277,12 @@ function commandMatches(command: string, persisted: PersistedService): boolean {
|
|||
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.
|
||||
// Require an argument boundary. A path separator is *not* one: the record
|
||||
// `C:\a` must not match a launch for `C:\a\b` (POSIX `/repo` vs `/repo/sub`).
|
||||
// The `\"` form Windows shows for the wrapper is folded by
|
||||
// `normalizeCommandText`, so the quote itself is the boundary there.
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -659,16 +642,27 @@ export function createServiceIO(): ServiceIO {
|
|||
metadata?: { health?: { version?: unknown } };
|
||||
};
|
||||
if (!response.ok)
|
||||
return { ok: false, version: "", error: `HTTP ${response.status}` };
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: `HTTP ${response.status}`,
|
||||
code: "http-status" as const,
|
||||
};
|
||||
const health = body.metadata?.health;
|
||||
if (body.success === false)
|
||||
return { ok: false, version: "", error: "ReMe reported an error" };
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: "ReMe reported an error",
|
||||
code: "reme-error" as const,
|
||||
};
|
||||
// A generic HTTP server answering 200 with `{}` is not ReMe.
|
||||
if (typeof health !== "object" || health === null)
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: "The endpoint did not answer with a ReMe health report",
|
||||
code: "not-reme" as const,
|
||||
};
|
||||
const version = health.version;
|
||||
return {
|
||||
|
|
@ -677,7 +671,12 @@ export function createServiceIO(): ServiceIO {
|
|||
error: "",
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, version: "", error: safeError(error) };
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: safeError(error),
|
||||
code: "unreachable" as const,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
|
@ -697,7 +696,7 @@ export function createServiceIO(): ServiceIO {
|
|||
},
|
||||
async spawnService({ exe, args, cwd, logPath, env }) {
|
||||
const fd = openAppendLog(logPath);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
|
|
@ -707,36 +706,40 @@ export function createServiceIO(): ServiceIO {
|
|||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
const started = child;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// A spawn acknowledgement that never arrives must not block the
|
||||
// start queue (and therefore a queued Stop) forever.
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
started.kill();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
reject(new Error("spawn did not acknowledge within 10 s"));
|
||||
}, 10_000);
|
||||
timer.unref();
|
||||
child.once("spawn", () => {
|
||||
started.once("spawn", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.once("error", (error: Error) => {
|
||||
started.once("error", (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
// The child owns its inherited handle; the parent copy is no longer
|
||||
// needed, and an unref'd child survives this Host exiting.
|
||||
// needed. The listener and `unref()` must run on the timeout path too:
|
||||
// otherwise a late acknowledgement keeps the child referenced and its
|
||||
// `error` event unhandled, and the Host cannot exit.
|
||||
closeSync(fd);
|
||||
if (child !== undefined) {
|
||||
child.on("error", () => undefined);
|
||||
child.unref();
|
||||
}
|
||||
}
|
||||
// A failed executable is reported here rather than as an unhandled
|
||||
// `error` event that would exit the Host.
|
||||
child.on("error", () => undefined);
|
||||
child.unref();
|
||||
if (child === undefined) throw new Error("spawn did not start");
|
||||
return { pid: child.pid };
|
||||
},
|
||||
killTree(pid, force = false) {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,13 @@ export interface LlmTestResult {
|
|||
error: string;
|
||||
}
|
||||
|
||||
/** Stable reason a health probe failed, so the client can localize it. */
|
||||
export type HealthErrorCode =
|
||||
| "http-status"
|
||||
| "not-reme"
|
||||
| "reme-error"
|
||||
| "unreachable";
|
||||
|
||||
/** Whether the configured endpoint answers, probed from the DSH Host. */
|
||||
export interface ConnectionHealth {
|
||||
endpoint: string;
|
||||
|
|
@ -121,6 +128,8 @@ export interface ConnectionHealth {
|
|||
version: string;
|
||||
/** Non-empty when the endpoint answered but not as a ReMe service. */
|
||||
error: string;
|
||||
/** Stable reason; the client shows localized copy instead of `error`. */
|
||||
code?: HealthErrorCode;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +150,7 @@ export type ServiceStartErrorCode =
|
|||
| "conflict"
|
||||
| "other-endpoint-running"
|
||||
| "previous-not-stopped"
|
||||
| "unverified-record"
|
||||
| "no-python"
|
||||
| "not-installed"
|
||||
| "version-too-old"
|
||||
|
|
@ -170,3 +180,16 @@ export interface ServiceLog {
|
|||
logPath: string;
|
||||
lines: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of one Stop request. It crosses the `remeEnv` boundary, so it lives
|
||||
* here rather than beside the Host-only process adapter.
|
||||
*/
|
||||
export interface ServiceStopResult {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
/** Why an unmanaged or missing service was left alone. */
|
||||
reason?: string;
|
||||
/** Stable reason the client localizes; `reason` stays for diagnostics. */
|
||||
code?: ServiceStopReasonCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ test("stops a previous launch that never became healthy before replacing it", as
|
|||
assert.equal(calls.spawn.length, 1);
|
||||
});
|
||||
|
||||
test("an unprovable previous record is discarded without killing it", async (t) => {
|
||||
test("an unprovable previous record blocks the start instead of orphaning it", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "stopped",
|
||||
|
|
@ -179,17 +179,20 @@ test("an unprovable previous record is discarded without killing it", async (t)
|
|||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// The process exists, but its command line cannot be read, so ownership is
|
||||
// unproven: never kill it, just drop the stale record and start.
|
||||
// unproven. Dropping the record would orphan it: it must stay, and the start
|
||||
// must report why instead of spawning a second service.
|
||||
io.processCommandLine = async () => "";
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.equal(result.started, false);
|
||||
assert.equal(result.code, "unverified-record");
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
assert.equal(readServiceState(statePath)?.pid, 5001);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
// Once the evidence is readable again, the same record can be stopped.
|
||||
assert.equal(readServiceState(statePath)?.pid, 6000);
|
||||
});
|
||||
|
||||
test("refuses to overwrite a live record owned by another endpoint", async (t) => {
|
||||
|
|
@ -259,6 +262,15 @@ test("probes health from the Host and rejects a non-http endpoint", async (t) =>
|
|||
await assert.rejects(() => manager.health("file:///etc/passwd"), /http/);
|
||||
});
|
||||
|
||||
test("shows the rotated service log when the current one is empty", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
writeFileSync(join(directory, "service.log"), "");
|
||||
writeFileSync(join(directory, "service.log.1"), "Traceback: boom\n");
|
||||
const log = manager.serviceLog(directory);
|
||||
assert.equal(log.logPath, join(directory, "service.log"));
|
||||
assert.match(log.lines, /boom/);
|
||||
});
|
||||
|
||||
test("validates the merged .env, not just the submitted values", async (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import {
|
||||
checkReme,
|
||||
|
|
@ -180,7 +180,10 @@ test("tails a log with CRLF and progress carriage returns", (t) => {
|
|||
function fakeSpawner() {
|
||||
const children = [];
|
||||
const spawner = {
|
||||
start() {
|
||||
start(options) {
|
||||
// The real spawner opens the job log before spawning, so pruning must see
|
||||
// the new file too.
|
||||
if (options?.logPath) writeFileSync(options.logPath, "");
|
||||
const child = {
|
||||
pid: 4000 + children.length,
|
||||
killed: false,
|
||||
|
|
@ -236,6 +239,19 @@ test("finds the reme CLI in a POSIX bin directory", () => {
|
|||
assert.equal(cli, "/home/u/venv/bin/reme");
|
||||
});
|
||||
|
||||
test("keeps exactly the documented number of install logs", (t) => {
|
||||
const { jobs, directory } = makeJobs(t);
|
||||
for (let index = 0; index < 24; index += 1)
|
||||
writeFileSync(join(directory, `install-old-${index}.log`), "x");
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
const remaining = readdirSync(directory).filter((name) =>
|
||||
name.endsWith(".log"),
|
||||
);
|
||||
// The steady state is the documented count, not one more than it.
|
||||
assert.equal(remaining.length, 20);
|
||||
assert.ok(remaining.includes(basename(started.logPath)));
|
||||
});
|
||||
|
||||
test("tracks a successful pip job through exit", (t) => {
|
||||
const { jobs, children, directory } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
|
|
|
|||
|
|
@ -266,10 +266,13 @@ test("a recycled pid that is no longer the service is not managed", async (t) =>
|
|||
false,
|
||||
);
|
||||
// Matching is boundary-aware: a longer workspace path or port is another
|
||||
// launch, not this one.
|
||||
// launch, not this one. A path separator is not a boundary either, so a
|
||||
// record for `...\reme-x` never claims a service started in `...\reme-x\sub`.
|
||||
const state = readServiceState(statePath);
|
||||
for (const command of [
|
||||
`reme start workspace_dir=${state.workspaceDir}2 service.port=2334`,
|
||||
`reme start workspace_dir=${state.workspaceDir}\\sub service.port=2334`,
|
||||
`reme start workspace_dir=${state.workspaceDir}/sub service.port=2334`,
|
||||
`reme start workspace_dir=${state.workspaceDir} service.port=23340`,
|
||||
]) {
|
||||
const { io: longer } = fakeIO({
|
||||
|
|
@ -465,10 +468,26 @@ test("real IO accepts only a ReMe health report", async (t) => {
|
|||
const good = await io.fetchHealth(reme, "");
|
||||
assert.equal(good.ok, true);
|
||||
assert.equal(good.version, "0.4.2.1");
|
||||
assert.equal(good.code, undefined);
|
||||
|
||||
const empty = await io.fetchHealth(generic, "");
|
||||
assert.equal(empty.ok, false);
|
||||
assert.match(empty.error, /ReMe health report/);
|
||||
// The client localizes the code instead of showing this English sentence.
|
||||
assert.equal(empty.code, "not-reme");
|
||||
|
||||
const failing = await listening((_request, response) => {
|
||||
response.statusCode = 503;
|
||||
response.end("{}");
|
||||
}, t);
|
||||
const httpError = await io.fetchHealth(failing, "");
|
||||
assert.equal(httpError.code, "http-status");
|
||||
|
||||
const unhappy = await listening((_request, response) => {
|
||||
response.end(JSON.stringify({ success: false }));
|
||||
}, t);
|
||||
const reported = await io.fetchHealth(unhappy, "");
|
||||
assert.equal(reported.code, "reme-error");
|
||||
|
||||
// The Authorization header is actually sent when a token is configured.
|
||||
let authorization = "";
|
||||
|
|
|
|||
|
|
@ -519,6 +519,9 @@ test("a start that died before listening reports immediately", async (t) => {
|
|||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.startFailedStopped));
|
||||
// The message is already a complete sentence; prefixing it would read
|
||||
// "Service operation failed: The service exited…".
|
||||
assert.equal(copy.includes(`${envZh.serviceError}:`), false);
|
||||
// Ready to retry without waiting out the 120 s deadline.
|
||||
assert.ok(buttonByText(renderer, envZh.startService));
|
||||
});
|
||||
|
|
@ -630,6 +633,12 @@ test("Reinstall forces a reinstall while install and upgrade use the right flags
|
|||
await act(async () => {
|
||||
buttonByText(reinstall.renderer, envZh.reinstall).props.onClick();
|
||||
});
|
||||
// The confirmation copy follows the Reinstall CTA, not the first-install one.
|
||||
assert.ok(
|
||||
texts(reinstall.renderer.toJSON())
|
||||
.join(" ")
|
||||
.includes(envZh.reinstallConfirm.split("{path}")[0]),
|
||||
);
|
||||
await act(async () => {
|
||||
buttonByText(reinstall.renderer, envZh.confirmInstall).props.onClick();
|
||||
});
|
||||
|
|
@ -739,3 +748,321 @@ test("switching the endpoint aborts the previous start poll", async (t) => {
|
|||
assert.ok(copy.includes("PID 1111"), "the new endpoint's pid is shown");
|
||||
assert.equal(copy.includes("PID 5000"), false);
|
||||
});
|
||||
|
||||
test("a live record that never listened offers Stop instead of 'Starting'", async (t) => {
|
||||
// The Host reports a recorded launch that is still alive but not listening
|
||||
// as `stopped` + pid. Mapping that to `starting` on every read used to lock
|
||||
// the card: Start disabled, no Stop, and Recheck repeated the same answer.
|
||||
const stale = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{ responses: { "remeEnv/serviceStatus": stale } },
|
||||
);
|
||||
t.after(restore);
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.equal(copy.includes(envZh.serviceBadgeStarting), false);
|
||||
assert.ok(copy.includes(envZh.serviceBadgeStale));
|
||||
assert.ok(copy.includes(envZh.staleNote));
|
||||
const start = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "button" &&
|
||||
textOf(node.props.children) === envZh.startService,
|
||||
);
|
||||
assert.equal(start.length, 0, "Start is replaced by the Stop way out");
|
||||
const stop = buttonByText(renderer, envZh.stopService);
|
||||
assert.equal(stop.props.disabled, false, "the recorded pid can be stopped");
|
||||
});
|
||||
|
||||
test("an owned conflict during a start keeps polling until healthy", async (t) => {
|
||||
const stop = (pid) => ({
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
...(pid === undefined ? {} : { pid }),
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
});
|
||||
const conflict = {
|
||||
phase: "conflict",
|
||||
managed: true,
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const running = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 6000,
|
||||
version: "0.4.0.9",
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": () => {
|
||||
statusCalls += 1;
|
||||
if (statusCalls === 1) return stop();
|
||||
if (statusCalls === 2) return conflict;
|
||||
return running;
|
||||
},
|
||||
"remeEnv/serviceStart": {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: running.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));
|
||||
});
|
||||
// One owned conflict is our own launch booting; it must not become a Stop
|
||||
// offer that would kill a service which is about to answer.
|
||||
assert.ok(
|
||||
texts(renderer.toJSON()).join(" ").includes(envZh.serviceBadgeStarting),
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("ReMe 0.4.0.9"));
|
||||
assert.equal(copy.includes(envZh.conflictManagedNote), false);
|
||||
});
|
||||
|
||||
test("a Host conflict refusal shows the conflict card, not an error", async (t) => {
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const conflict = {
|
||||
phase: "conflict",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: stopped.logPath,
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": () => {
|
||||
statusCalls += 1;
|
||||
return statusCalls === 1 ? stopped : conflict;
|
||||
},
|
||||
"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, 50));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.conflictNote));
|
||||
assert.equal(copy.includes(envZh.startErrorConflict), false);
|
||||
});
|
||||
|
||||
test("a slow status answer does not overwrite a finished start", async (t) => {
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const running = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 7000,
|
||||
version: "0.4.0.9",
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: stopped.logPath,
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": () => {
|
||||
statusCalls += 1;
|
||||
if (statusCalls === 1) return stopped;
|
||||
// The manual Recheck answers late, after the start poll has settled.
|
||||
if (statusCalls === 2)
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(stopped), 1_500),
|
||||
);
|
||||
return running;
|
||||
},
|
||||
"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();
|
||||
});
|
||||
const rechecks = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "button" && textOf(node.props.children) === envZh.recheck,
|
||||
);
|
||||
await act(async () => {
|
||||
rechecks.at(-1).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_300));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("ReMe 0.4.0.9"));
|
||||
assert.equal(copy.includes(envZh.serviceBadgeStopped), false);
|
||||
});
|
||||
|
||||
test("a Stop result for the previous endpoint is dropped", async (t) => {
|
||||
const OLD = "http://127.0.0.1:2333";
|
||||
const NEW = "http://127.0.0.1:2444";
|
||||
const oldRunning = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 5000,
|
||||
version: "0.4.0.9",
|
||||
endpoint: OLD,
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const newRunning = {
|
||||
...oldRunning,
|
||||
pid: 1111,
|
||||
version: "9.9.9",
|
||||
endpoint: NEW,
|
||||
};
|
||||
let release;
|
||||
const stopResult = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { renderer, setValue, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": (input) =>
|
||||
input?.endpoint === NEW ? newRunning : oldRunning,
|
||||
"remeEnv/serviceStop": () => stopResult,
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.stopService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.stopService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
setValue({ endpoint: NEW });
|
||||
});
|
||||
await act(async () => {
|
||||
release({
|
||||
stopped: false,
|
||||
reason: "could not be verified",
|
||||
code: "unverified",
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.equal(copy.includes(envZh.stopUnknownIdentity), false);
|
||||
const stops = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "button" &&
|
||||
textOf(node.props.children) === envZh.stopService,
|
||||
);
|
||||
assert.ok(stops.length > 0);
|
||||
assert.equal(stops[0].props.disabled, false);
|
||||
});
|
||||
|
||||
test("localizes a Host health failure code", async (t) => {
|
||||
const { renderer, restore } = await mount(
|
||||
{ endpoint: "http://10.0.0.5:2333" },
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/health": {
|
||||
endpoint: "http://10.0.0.5:2333",
|
||||
ok: false,
|
||||
version: "",
|
||||
error: "The endpoint did not answer with a ReMe health report",
|
||||
code: "not-reme",
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.healthErrorNotReme));
|
||||
assert.equal(
|
||||
copy.includes("did not answer with a ReMe health report"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a Python refresh keeps the previous interpreter list on screen", async (t) => {
|
||||
let pythonCalls = 0;
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/python": () => {
|
||||
pythonCalls += 1;
|
||||
// The refresh never answers; the previous detection must stay.
|
||||
return pythonCalls === 1
|
||||
? RESPONSES["remeEnv/python"]
|
||||
: new Promise(() => {});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.changeInterpreter).props.onClick();
|
||||
});
|
||||
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 6);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.refresh).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.equal(copy.includes(envZh.emptyTitle), false);
|
||||
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 6);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue