fix(dsh): mount the environment gateway under its own service key

TypertRemoteService registers itself under the constructor key, so
`super(ctx, remeEnv)` collided with the already-provided `remeEnv`
manager: the gateway plugin failed activation (its async rejection was
logged only), leaving every /api/remeEnv/* call with HTTP 404.

Register the service as `remeEnvGateway` and keep the wire namespace
`remeEnv`, which is what the client calls. Add a real-cordis regression
test that mounts the gateway inside the same isolated realm and asserts
the binding, the namespace, and the SRC-parsed `input` parameter.

Also show the interpreter hint on detection failure so an empty list
never leaves the tab without guidance.
This commit is contained in:
LiangshouX 2026-10-27 19:56:45 +08:00
parent 5d3b4fe31f
commit 80ce19645e
3 changed files with 99 additions and 2 deletions

View file

@ -992,7 +992,8 @@ export function ReMeEnvironmentTab({
{detection.error ? `: ${detection.error}` : ""}
</p>
) : null}
{detected && detected.envs.length === 0 ? (
{detection.phase === "error" ||
(detected && detected.envs.length === 0) ? (
<div className="reme-env-hint">
<strong>{t("emptyTitle")}</strong>
<span>{t("emptyHint")}</span>

View file

@ -31,7 +31,11 @@ export class ReMeEnvGateway extends TypertRemoteService {
static inject = ["remeEnv"];
constructor(ctx: Context) {
super(ctx, "remeEnv");
// The service key must differ from the manager registered as `remeEnv`:
// TypertRemoteService registers itself under this key, and two providers
// for one key fail plugin activation. The wire namespace stays `remeEnv`,
// which is the endpoint the client calls.
super(ctx, "remeEnvGateway", { namespace: "remeEnv" });
}
/** Detect local Python interpreters; cached briefly unless refreshed. */

View file

@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Context } from "@deepseek-ai/cordis";
import { ReMeEnvGateway } from "../dist/env-gateway.js";
/** Manager stub: no subprocesses, just enough surface for the gateway. */
function stubManager() {
return {
detectPython: async () => ({
platform: "win32",
scannedAt: "",
envs: [],
suggested: "",
condaRoot: "",
errors: [],
}),
checkReme: async (pythonPath) => ({
installed: false,
version: "",
versionOk: false,
hasCore: false,
cliPath: "",
error: pythonPath ? "" : "Python interpreter path must be absolute",
}),
};
}
/** Mount the gateway inside the same isolated realm the patch creates. */
async function mounted() {
const root = new Context();
let realm;
await root.plugin((ctx) => {
realm = ctx.isolate({ remeMemory: true });
});
await realm.plugin((ctx) => {
ctx.provide("remeMemory", {});
ctx.provide("remeEnv", stubManager());
});
await realm.plugin(ReMeEnvGateway);
return { root, realm };
}
test("gateway mounts without a service-key conflict and keeps the wire namespace", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
assert.ok(receiver, "gateway service is registered under its own key");
assert.ok(receiver.typertRemote, "gateway exposes a typertRemote binding");
assert.equal(receiver.typertRemote.serviceKey, "remeEnvGateway");
assert.equal(receiver.typertRemote.namespace, "remeEnv");
// The manager must stay reachable under the key the methods inject.
assert.ok(root.reflect.get("remeEnv", false));
});
test("SRC discovery exposes remeEnv/python with one plain input parameter", async () => {
const { root } = await mounted();
let binding;
for (const [serviceKey, definition] of Object.entries(root.reflect.props)) {
if (definition.type !== "service") continue;
const receiver = root.reflect.get(serviceKey, false);
if (!receiver?.typertRemote) continue;
if (receiver.typertRemote.namespace !== "remeEnv") continue;
binding = { serviceKey, receiver };
}
assert.ok(binding, "an active service exports the remeEnv namespace");
assert.equal(binding.serviceKey, "remeEnvGateway");
const source = Function.prototype.toString.call(
Object.getPrototypeOf(binding.receiver).python,
);
const open = source.indexOf("(");
const close = source.indexOf(")", open + 1);
const parameters = source
.slice(open + 1, close)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
// The Gateway parses this source, so defaults, destructuring, and rest
// parameters would make the endpoint call fail at runtime.
assert.deepEqual(parameters, ["input"]);
const result = await binding.receiver.python({ refresh: true });
assert.equal(result.platform, "win32");
});
test("gateway methods remain callable through the manager after remount", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
const check = await receiver.checkReme({ pythonPath: "" });
assert.equal(check.installed, false);
assert.match(check.error, /absolute/);
});