mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-14 09:01:22 +00:00
Merge 3d52f61da1 into bc91a0c7d2
This commit is contained in:
commit
7a673579dd
60 changed files with 16504 additions and 418 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,6 +45,9 @@ tmp*/
|
|||
temp*/
|
||||
.trash/
|
||||
|
||||
# Local tool indexes (rebuildable, machine-local)
|
||||
.codegraph/
|
||||
|
||||
# ReMe runtime data
|
||||
.reme/
|
||||
reme_workspace/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
This guide explains how to install, configure, and use `@agentscope-ai/reme-dsh-plugin` with DeepSeek Harness (DSH), including memory guidance injection, the `reme_search` tool, automatic memory, daily consolidation, and the ReMe Status page.
|
||||
|
||||
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, and `0.2.1-rc.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
|
||||
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, and `0.2.2-alpha.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
|
||||
|
||||
## 1. How the plugin works
|
||||
|
||||
|
|
@ -26,8 +26,8 @@ The DSH adapter injects **usage guidance**, not every historical memory. Relevan
|
|||
## 2. Requirements
|
||||
|
||||
- ReMe is installed and its configuration exposes the `search`, `auto_memory`, and `auto_dream` jobs.
|
||||
- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, or `0.2.1-rc.2`.
|
||||
- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range.
|
||||
- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, or `0.2.2-alpha.2`.
|
||||
- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range (DSH `0.2.2` needs Node 24.9 or newer).
|
||||
- The browser running DSH can reach the configured ReMe HTTP endpoint. Cross-machine deployments must also allow the DSH browser origin.
|
||||
|
||||
The default ReMe endpoint is `http://127.0.0.1:2333`. ReMe HTTP does not use API-key authentication, so do not expose it directly to an untrusted network.
|
||||
|
|
@ -110,26 +110,43 @@ If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported
|
|||
|
||||

|
||||
|
||||
| UI meaning | Configuration key | Default | Description |
|
||||
| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------- |
|
||||
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
|
||||
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
|
||||
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
|
||||
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
|
||||
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
|
||||
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
|
||||
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
|
||||
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
|
||||
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
|
||||
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
|
||||
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
|
||||
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
|
||||
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
|
||||
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
|
||||
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
|
||||
| UI meaning | Configuration key | Default | Description |
|
||||
| ---------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
|
||||
| Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; set in the profile only (not shown in this form), stored in clear text, trusted networks only. |
|
||||
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
|
||||
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
|
||||
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
|
||||
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
|
||||
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
|
||||
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
|
||||
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
|
||||
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
|
||||
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
|
||||
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
|
||||
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
|
||||
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
|
||||
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
|
||||
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
|
||||
|
||||
Deployment configuration also supports `REME_URL`, or `REME_HOST` together with `REME_PORT`. The timer-only test option `dreamIntervalMs` is intentionally excluded from user settings.
|
||||
|
||||
## 4.1 Environment management (Environment tab)
|
||||
|
||||
The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers the setup that
|
||||
otherwise needs a separate terminal, on the machine that runs DSH:
|
||||
|
||||
| Step | What it does |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Local Python | Finds PEP 514, `py` launcher, `PATH` and conda interpreters, labels each origin and `reme-ai` state, stores `pythonPath` |
|
||||
| ReMe dependency | Checks `reme-ai`, the `[core]` extras and the `reme` CLI; installs or upgrades `reme-ai[core]` with a live log and cancel |
|
||||
| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked; provides an LLM connectivity test |
|
||||
| Service control | Starts and stops the local ReMe service, showing PID, version and log |
|
||||
|
||||
See the [Environment management guide](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment.md)
|
||||
for interpreter discovery and the recommendation rule, dependency installation, `.env` validation, connection
|
||||
testing, service lifetime, platform support and troubleshooting.
|
||||
|
||||
## 5. Memory context injection
|
||||
|
||||
On `agent/created`, the plugin injects long-term-memory guidance as native plugin context. Expand **Context injection · reme-memory** in the message flow to inspect both the content and provenance.
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记
|
|||
## 2. 环境要求
|
||||
|
||||
- ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。
|
||||
- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4` 或 `0.2.1-rc.2`。
|
||||
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。
|
||||
- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2` 或 `0.2.2-alpha.2`。
|
||||
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致(DSH `0.2.2` 需要 Node 24.9 以上)。
|
||||
- DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。
|
||||
|
||||
ReMe HTTP 服务默认监听 `http://127.0.0.1:2333`,不使用 API Key 认证。因此不建议未经网络隔离直接暴露到公网。
|
||||
|
|
@ -115,26 +115,42 @@ dsh web --no-open --port 3090
|
|||
|
||||
截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下:
|
||||
|
||||
| 界面含义 | 配置键 | 默认值 | 说明 |
|
||||
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------- |
|
||||
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
|
||||
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
|
||||
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
|
||||
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
|
||||
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
|
||||
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
|
||||
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
|
||||
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
|
||||
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
|
||||
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
|
||||
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
|
||||
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
|
||||
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
|
||||
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
|
||||
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
|
||||
| 界面含义 | 配置键 | 默认值 | 说明 |
|
||||
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
|
||||
| 服务令牌(可选) | `authToken` | 空 | 自部署网关的 Bearer 令牌;仅通过 profile 配置(表单中不显示),明文保存,仅建议内网/受信环境使用。 |
|
||||
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
|
||||
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
|
||||
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
|
||||
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
|
||||
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
|
||||
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
|
||||
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
|
||||
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
|
||||
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
|
||||
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
|
||||
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
|
||||
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
|
||||
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
|
||||
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
|
||||
|
||||
部署层还支持 `REME_URL`,或组合使用 `REME_HOST` 与 `REME_PORT`。仅供定时器测试的 `dreamIntervalMs` 不出现在用户设置中。
|
||||
|
||||
## 4.1 环境管理(Environment 标签)
|
||||
|
||||
**插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「找 Python、装 ReMe、写凭据、启停服务」收进插件本体,无需另开终端;
|
||||
这些操作都发生在运行 DSH 的那台机器上:
|
||||
|
||||
| 步骤 | 做什么 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 本地 Python 环境 | 发现 PEP 514 注册表、`py` 启动器、`PATH` 与 conda 解释器,标注来源与 `reme-ai` 状态,选中即写入 `pythonPath` |
|
||||
| ReMe 依赖 | 检测 `reme-ai`、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`,日志实时可见、可取消 |
|
||||
| ReMe 参数 | 编辑 workspace 的 `.env`:密钥只保存在该文件、界面仅显示掩码;提供 LLM 连通性测试 |
|
||||
| 服务控制 | 启停本机 ReMe 服务,显示 PID、版本与日志 |
|
||||
|
||||
解释器发现与推荐规则、依赖安装、`.env` 校验、连接测试、服务生命周期、平台支持与故障排查见
|
||||
[环境管理指南](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment_ZH.md)。
|
||||
|
||||
## 5. 普通对话中的 memory 上下文注入
|
||||
|
||||
创建一个新会话后,插件监听 DSH 的 `agent/created`,把长期记忆使用规则作为一条原生 plugin context 注入。展开消息流中的 **上下文注入 · reme-memory** 可以直接检查内容与来源元数据。
|
||||
|
|
|
|||
262
integrations/dsh/docs/environment.md
Normal file
262
integrations/dsh/docs/environment.md
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
# Environment management
|
||||
|
||||
The **Environment** tab is the setup surface for a local ReMe install. It finds a Python interpreter on the
|
||||
machine that runs DSH, installs `reme-ai[core]` into it, writes the workspace `.env`, and starts or stops the
|
||||
local ReMe service — the work that otherwise needs several terminals.
|
||||
|
||||
Open it from **Plugins → ReMe bundle → reme-memory → Configure → Environment**.
|
||||
|
||||
The tab manages a **loopback** endpoint at the origin root (`http://127.0.0.1:…` or `http://localhost:…`). Any
|
||||
other service URL — a remote host, or a gateway mounted under a path prefix — is only reported as answering or
|
||||
not, and hides the local steps.
|
||||
|
||||
Everything the tab reports comes from the DSH **Host**, not from the browser: Python discovery, file access,
|
||||
the LLM probe and the service health check all run on the Host machine. A browser on another machine therefore
|
||||
sees the Host's environment, and the hero and the service card can never disagree about it.
|
||||
|
||||
## What each step does
|
||||
|
||||
| Section | Capability |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Local Python | Discovers PEP 514 registry entries, the `py` launcher, `PATH` and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` |
|
||||
| ReMe dependency | Checks the `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, a live log and cancel |
|
||||
| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked. Saving requires a non-empty `EMBEDDING_API_KEY` (use `EMPTY` when vector search is off). Provides an LLM connectivity test |
|
||||
| Service control | Starts and stops the local ReMe service, showing PID, version and log; the launch injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works |
|
||||
|
||||
The hero summarises the same state as three chips — service, Python, ReMe — plus the endpoint, the detection
|
||||
time and the two refresh actions.
|
||||
|
||||
## Python discovery
|
||||
|
||||
ReMe needs **Python 3.11 or newer**. The tab probes these channels on the Host and keeps only interpreters that
|
||||
answered its verification snippet (a `-c` probe reporting `sys.executable`, the version and the base prefix):
|
||||
|
||||
- Windows: PEP 514 registry entries (`HKLM`/`HKCU`), the `py` launcher list, `PATH`, and `conda env list`.
|
||||
- macOS and Linux: `PATH` (`which -a`), the well-known `/usr/bin`, `/usr/local`, Homebrew (`/opt/homebrew`),
|
||||
`~/.pyenv/shims` and `~/.local/bin` locations, and `conda env list`.
|
||||
|
||||
Microsoft Store placeholder links under `WindowsApps` are skipped, because they are shell aliases rather than
|
||||
interpreters. When conda is present, named environments are listed both under `conda env list` and as
|
||||
interpreters, and the conda root is reported separately.
|
||||
|
||||
Detection is cached for 60 seconds; **Refresh** (in the hero) probes again. The recommended interpreter is
|
||||
chosen in this order:
|
||||
|
||||
1. A named conda environment (`base` excluded) that already has a supported `reme-ai`.
|
||||
2. Another interpreter that already has a supported `reme-ai`.
|
||||
3. Any named conda environment, which is ready for a ReMe install.
|
||||
4. The first usable interpreter.
|
||||
|
||||
Hover the **recommended** tag to see which rule applied. Picking another interpreter writes `pythonPath` and
|
||||
nothing else; leaving it unset keeps following the recommendation. The list shows six rows and expands on
|
||||
demand, with a filter over version, origin and path.
|
||||
|
||||
## ReMe dependency
|
||||
|
||||
The check asks the selected interpreter three questions and reports them as metrics:
|
||||
|
||||
- `reme-ai` metadata version (`importlib.metadata.version("reme-ai")`), and whether it satisfies the minimum
|
||||
this integration targets.
|
||||
- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable.
|
||||
- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time.
|
||||
|
||||
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
|
||||
Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
|
||||
present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes
|
||||
depending on the network.
|
||||
|
||||
The installation is a Host-owned job:
|
||||
|
||||
- It keeps running when the configuration page closes, and reopening the tab restores it for the same
|
||||
interpreter. The Host looks the job up by interpreter, so two interpreters installing at the same time each
|
||||
see their own progress.
|
||||
- Progress is polled by job id, so a second installation for another interpreter cannot replace what you see.
|
||||
The install answer is bound to the interpreter that asked for it, so a late response after an interpreter
|
||||
switch is dropped instead of showing another interpreter's job.
|
||||
- A successful install re-checks `reme-ai` for that interpreter, so the dependency card and **Start service**
|
||||
follow the install without a manual **Check**.
|
||||
- **Cancel** stops pip and its build children, and the interpreter stays locked until the whole managed process
|
||||
group has exited. If pip exits while a build child keeps running, the group is force-killed and the job waits
|
||||
for the group to empty. If even that is not confirmed, the job stays _cancelling_ and the interpreter stays
|
||||
locked, because a second installation must not run while the first is still unaccounted for. The tab keeps
|
||||
showing the job instead of reporting a completed cancellation.
|
||||
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place. Each install
|
||||
opens a new file and only the newest 20 are kept; the long-lived service log rotates to `service.log.1` once it
|
||||
passes 5 MB.
|
||||
- When the plugin unloads, running jobs are stopped through the same path: a graceful signal first, then a
|
||||
force-kill, and the managed process group is watched until it is empty, so a pip that ignores `SIGTERM` — or a
|
||||
build child that outlives pip — cannot outlive the Host.
|
||||
|
||||
## Workspace `.env`
|
||||
|
||||
ReMe reads credentials from the `.env` in the workspace directory at service start; the tab never stores them in
|
||||
DSH settings.
|
||||
|
||||
| Group | Keys |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| LLM | `LLM_BACKEND`, `LLM_MODEL_NAME`, `LLM_API_KEY`, `LLM_BASE_URL` |
|
||||
| Embedding | `EMBEDDING_BACKEND`, `EMBEDDING_MODEL_NAME`, `EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL` |
|
||||
|
||||
- The workspace directory defaults to `~/.reme` and must be absolute; changing it reloads the `.env` it points at.
|
||||
- API keys are write-only: the Host returns a mask (last four characters) and a blank field means “keep the
|
||||
stored key”. Leaving a key untouched never rewrites it.
|
||||
- Emptying a **secret** field and saving keeps the stored key, even when a replacement was typed first and then
|
||||
cleared: a credential cannot be deleted by emptying its control, so the blank field always means “keep what is
|
||||
stored”. Emptying a **non-secret** field and saving **clears** it. Only fields you edited are written, so the rest
|
||||
of the file keeps its comments, unknown keys and order.
|
||||
- The two backend selections are the non-secret fields with a default: clearing one writes the default
|
||||
(`LLM_BACKEND=openai`, `EMBEDDING_BACKEND=openai`) instead of an empty value. ReMe applies
|
||||
`${LLM_BACKEND:-openai}` and `${EMBEDDING_BACKEND:-openai}` only when the variable is absent, so an empty
|
||||
value would name no backend at all and the service would refuse to start.
|
||||
- A save writes the edits that were submitted and leaves newer ones alone: typing another field while the save is in
|
||||
flight keeps that text, and the card says that unsaved edits are still pending.
|
||||
- Saving validates the whole merged file, not just the values you typed: ReMe 0.4.0.x instantiates the embedding
|
||||
component unconditionally, so `EMBEDDING_API_KEY` must be non-empty even without vector search (`EMPTY` is the
|
||||
documented placeholder). A duplicated key is rewritten in every position, and the file keeps its own line
|
||||
ending.
|
||||
- The write is atomic and owner-only (0600 on POSIX); the previous file is kept as `.env.bak`, so a failed save
|
||||
cannot lose unrelated keys.
|
||||
- **Test LLM connection** runs a one-token request from inside the workspace with the selected interpreter, and
|
||||
adds a hint for `401`/`403` (key), `404` (model or base URL) and network errors. The request follows the
|
||||
protocol of the `LLM_BACKEND` in the `.env`: `openai`, `dashscope`, `deepseek` and `moonshot` use the
|
||||
OpenAI-compatible `/chat/completions` (Bearer auth), `anthropic` uses `/v1/messages` (`x-api-key`), `gemini`
|
||||
uses `/v1beta/models/<model>:generateContent` (`x-goog-api-key`), `ollama` uses `/api/chat` (a local server
|
||||
needs no key) and `xai` uses the OpenAI-compatible `/chat/completions` at `api.x.ai`.
|
||||
- The base URL follows ReMe's own credentials: `LLM_BASE_URL` is read for the OpenAI-compatible providers and for
|
||||
Anthropic, while Gemini, Ollama and xAI carry their own endpoint and ignore it. A stale value left in the `.env`
|
||||
therefore cannot send the probe to a host the service never calls, and a configuration ReMe accepts is never
|
||||
reported as broken. The base URL is used as the provider's own SDK builds it, so a base that already carries the
|
||||
version suffix fails in the probe exactly as it fails in the service.
|
||||
- ReMe reaches xAI through its gRPC SDK, so the xAI probe is a REST-level approximation of that channel.
|
||||
- The probe reads the `.env` on disk, so a configuration that is only picked in the form is not tested before the
|
||||
file is saved; the button says so while it waits.
|
||||
- A `.env` that sets `LLM_BACKEND` to an empty value is reported as an incomplete configuration
|
||||
(`LLM config incomplete (LLM_BACKEND)`) and no request is sent: ReMe would start with no backend at all, so the
|
||||
probe must not test a configuration the service cannot use. A `.env` without the key is probed with the default
|
||||
backend, exactly as ReMe would start it.
|
||||
- A known backend written in the wrong case (`LLM_BACKEND=OpenAI`) is reported as invalid, with the spelling to
|
||||
use: ReMe's backend registry is case-sensitive and refuses that name. A backend name the plugin has never heard
|
||||
of is still probed tolerantly — the list here is a snapshot, and ReMe may know more than the plugin does. That
|
||||
holds however the name looks: only a backend the plugin actually knows can be reported as a wrong spelling.
|
||||
- The draft, the save message and the LLM-test result belong to the workspace they were produced for: switching
|
||||
the workspace disposes the section's state, so neither a key typed for one workspace nor a success reported for
|
||||
it can be carried into another.
|
||||
|
||||
## Connection testing
|
||||
|
||||
**Test connection** in the hero asks the Host to `POST /health_check` on the configured endpoint. A healthy answer
|
||||
must contain a ReMe health report — a version, a health flag and its components — so a generic HTTP server that
|
||||
answers `200` with `{}` is not reported as a running ReMe service. The Host reuses the same ReMe client as the rest
|
||||
of the plugin, so the request, the authentication and the three-second timeout are the ones the service itself is
|
||||
called with. When the settings carry an `authToken`, the Host sends it as `Authorization: Bearer`, scoped to that
|
||||
endpoint.
|
||||
|
||||
## Service lifetime
|
||||
|
||||
Starting the service here is equivalent to running `reme start` in a terminal — with one deliberate difference:
|
||||
**the service outlives DSH**. Closing DSH does not stop it, and stopping it is an explicit action in the tab.
|
||||
|
||||
That is why the plugin does not hand the service to DSH's local subprocess provider, which terminates the
|
||||
processes it manages when the Host exits. The plugin owns a small service adapter instead:
|
||||
|
||||
- The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time)
|
||||
**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 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. The card then offers
|
||||
**Forget this record**, which deletes only `service.json` and never signals the process; the user confirms what to do
|
||||
with it themselves. The Host names the record that blocked the action, and **Forget** targets exactly that record —
|
||||
which may belong to another port, not to the endpoint on screen — together with its pid. When the record changed in
|
||||
the meantime, the Host refuses instead of dropping the new record silently.
|
||||
- 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**.
|
||||
- A conflict caused by a **recorded** process whose identity cannot be read is a different case: the process is
|
||||
never signalled, so the tab offers **Forget this record** instead. The record travels with the status, so the way
|
||||
out does not depend on a start that the conflict card disables.
|
||||
- A record left by an older version under a path prefix of the same origin (for example
|
||||
`127.0.0.1:2333/reme`) describes the **same** local service, because the launch configures only host
|
||||
and port. When it can be proven to be this plugin's service, such a record is re-keyed to the endpoint on
|
||||
screen and then managed like any other launch: **Stop** is offered, and **Start** replaces it while it is
|
||||
not healthy. It is then not reported as a foreign process the tab cannot act on, which would leave the
|
||||
user with a process to kill by hand.
|
||||
- **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 both
|
||||
**Stop** and **Start**: Stop clears the record, while Start makes the Host stop a provably owned previous launch or
|
||||
discard a proven-foreign one. 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; 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.
|
||||
|
||||
## Platform support
|
||||
|
||||
| Area | Notes |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DSH | `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, `0.2.2-alpha.2` |
|
||||
| Node.js | `^22.19.0` or `>=24.0.0`; current DSH releases need Node 24.9 or newer, and the compatibility matrix is validated with Node 24.16 |
|
||||
| Windows | Tested on Windows 11 with Miniconda: registry/PATH/conda discovery, `tasklist`/`taskkill` for identity and stop, `PYTHONUTF8`/`PYTHONIOENCODING` for pip output |
|
||||
| macOS / Linux | Discovery, detached spawn, identity lookup and stop are covered by unit tests that run a real child process; still less exercised on real machines than Windows |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No usable Python found.** Install Miniconda or any Python 3.11+, then press **Refresh**. Store placeholders
|
||||
are filtered by design, so install a real distribution instead of the Store alias.
|
||||
|
||||
**The selected interpreter is not in the list.** The tab shows it with a “not detected” tag and still uses it.
|
||||
Refresh, or pick a detected interpreter.
|
||||
|
||||
**Installation fails.** Open the log tail: missing wheels, a stale proxy or a restricted network usually show
|
||||
there. Try a mirror. In a conda environment `pip` works, but a dedicated environment avoids dependency conflicts
|
||||
with other projects.
|
||||
|
||||
**Saving `.env` is refused.** `LLM_API_KEY` and `EMBEDDING_API_KEY` must both be non-empty in the saved file;
|
||||
use `EMPTY` for the embedding key when vector search is off. Emptying a secret field never reaches the file, so
|
||||
the stored key is kept; a required key that is not stored at all still fails the save, and 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. 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 and start again. If the pid
|
||||
was recycled by another program, that record is provably foreign: **Start** discards it and launches a fresh service.
|
||||
If the identity cannot be verified at all, use **Forget this record** — it deletes only the plugin's record and
|
||||
leaves the process untouched.
|
||||
|
||||
**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.
|
||||
|
||||
**Stop is disabled.** The running service does not belong to this endpoint, or its record is gone. Restart it
|
||||
from the tab to make it managed.
|
||||
|
||||
**LLM test fails.** The result distinguishes `401`/`403` (key), `404` (model name or base URL) and network
|
||||
errors. The probe reads the workspace `.env`, so save before testing, and it follows the `LLM_BACKEND` there: a
|
||||
native provider (Anthropic, Gemini, Ollama) is probed with its own protocol, not with an OpenAI-style request.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
```text
|
||||
integrations/dsh/src/environment/
|
||||
├── types.ts shared, browser-safe RPC types
|
||||
├── env-fields.ts the managed `.env` keys and the rules for updating them
|
||||
├── dependency.ts what one interpreter can host
|
||||
├── endpoint-rules.ts the endpoints a local service can be managed on
|
||||
├── host/ detection, install, service adapter, manager, gateway
|
||||
└── client/ the tab, one module per step, copy and styles
|
||||
```
|
||||
|
||||
The Host modules are the only place that touches processes or files; the client renders what the Host returns.
|
||||
211
integrations/dsh/docs/environment_ZH.md
Normal file
211
integrations/dsh/docs/environment_ZH.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# 环境管理
|
||||
|
||||
**Environment(环境管理)** 标签页用于在本机完成 ReMe 的准备工作:找到运行 DSH 那台机器上的 Python、把
|
||||
`reme-ai[core]` 装进该解释器、写入 workspace 的 `.env`、并启动或停止本地 ReMe 服务——这些原本需要在多个终端里完成。
|
||||
|
||||
入口:**插件 → ReMe Bundle → reme-memory → 配置 → 环境管理**。
|
||||
|
||||
该标签页只管理**根路径上的回环地址**(`http://127.0.0.1:…` 或 `http://localhost:…`)。其他服务地址——远程主机,或挂在
|
||||
路径前缀下的网关——只显示该地址是否可用,并隐藏本地步骤。
|
||||
|
||||
页面上的所有结果都来自 DSH **Host**,而不是浏览器:Python 探测、文件读写、LLM 探测、服务健康检查都在 Host 上执行。因此即使
|
||||
浏览器在另一台机器上,看到的也是 Host 的真实环境,顶部概览与服务卡片不会给出不一致的结论。
|
||||
|
||||
## 每一步做什么
|
||||
|
||||
| 分组 | 能力 |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 本地 Python | 探测 PEP 514 注册表、`py` 启动器、`PATH` 与 conda(base 与命名环境),标注来源和 `reme-ai` 状态,选择结果写入 `pythonPath` |
|
||||
| ReMe 依赖 | 针对所选解释器检查 `reme-ai` 版本、`[core]` 附加依赖与 `reme` CLI;可选用软件源安装或升级 `reme-ai[core]`,显示实时日志并支持取消 |
|
||||
| ReMe 参数 | 编辑 workspace 的 `.env`;密钥只存在该文件中,界面仅显示掩码。保存要求 `EMBEDDING_API_KEY` 非空(关闭向量检索时用 `EMPTY`)。提供 LLM 连通性测试 |
|
||||
| 服务控制 | 启动/停止本地 ReMe 服务,显示 PID、版本与日志;启动时注入 UTF-8 与 `NO_PROXY`、设置 `enable_logo=false`,并在 workspace 内运行以保证 `.env` 能被发现 |
|
||||
|
||||
顶部的概览区用三个标签(服务、Python、ReMe)概括同一份状态,并显示服务地址、探测时间与两个刷新操作。
|
||||
|
||||
## Python 探测
|
||||
|
||||
ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能通过验证片段(上报 `sys.executable`、
|
||||
版本号与 base prefix 的 `-c` 探测)的解释器:
|
||||
|
||||
- Windows:PEP 514 注册表项(`HKLM`/`HKCU`)、`py` 启动器列表、`PATH`、`conda env list`。
|
||||
- macOS / Linux:`PATH`(`which -a`)、常见的 `/usr/bin`、`/usr/local`、Homebrew(`/opt/homebrew`)、
|
||||
`~/.pyenv/shims` 与 `~/.local/bin` 路径,以及 `conda env list`。
|
||||
|
||||
`WindowsApps` 下的 Microsoft Store 占位符会被跳过,因为它们只是别名而不是解释器。装有 conda 时,命名环境会同时出现在
|
||||
`conda env list` 与环境列表中,conda 根目录单独上报。
|
||||
|
||||
探测结果缓存 60 秒,点击概览区的**重新探测**会重新执行。推荐解释器按以下顺序选出:
|
||||
|
||||
1. 已安装受支持 `reme-ai` 的命名 conda 环境(排除 `base`)。
|
||||
2. 其他已安装受支持 `reme-ai` 的解释器。
|
||||
3. 任意命名 conda 环境(可直接安装 ReMe)。
|
||||
4. 第一个可用解释器。
|
||||
|
||||
把鼠标悬停在**推荐**标签上可以看命中的是哪条规则。选择其它解释器只会写入 `pythonPath`;保持未选择则一直跟随推荐。列表默认
|
||||
显示 6 行并可展开,支持按版本、来源、路径筛选。
|
||||
|
||||
## ReMe 依赖
|
||||
|
||||
检查会向所选解释器询问三件事,并以指标形式展示:
|
||||
|
||||
- `reme-ai` 元数据版本(`importlib.metadata.version("reme-ai")`),以及是否达到本集成要求的最低版本。
|
||||
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
|
||||
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
|
||||
|
||||
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
|
||||
`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
|
||||
依赖,视网络情况通常 5–20 分钟。
|
||||
|
||||
安装是 Host 侧的任务:
|
||||
|
||||
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。Host 按解释器查找正在运行的任务,所以两个解释器同时安装时,
|
||||
各自看到的是自己的进度。
|
||||
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示;安装的响应也绑定在发起它的解释器上,切换解释器后迟到的响应会被丢弃。
|
||||
- 安装成功后会自动重新检测 `reme-ai`(同一个解释器),依赖卡片与**启动服务**按钮随之更新,无需手动点“检测”。
|
||||
- **取消**会同时终止 pip 及其构建子进程,且在**整个受管进程组**真正退出前该解释器保持占用:pip 先退出而构建子进程
|
||||
仍在时,会对进程组强制结束并等待组清空。若连这一步也无法确认,任务仍保持“正在取消”状态、该解释器依然被占用——
|
||||
在第一个安装还没有交代清楚之前,不允许第二个安装开始。页面会继续显示该任务,而不是把它报告成一次已完成的取消。
|
||||
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部。每次安装都会新建文件,目录只保留最近
|
||||
20 个;长期运行的服务日志超过 5 MB 后轮转为 `service.log.1`。
|
||||
- 插件卸载时,仍在运行的安装走同一条终止路径:先发优雅信号,再强制结束,并对受管进程组持续确认,因此忽略
|
||||
`SIGTERM` 的 pip 或比 pip 活得更久的构建子进程都不会活得比 Host 更久。
|
||||
|
||||
## Workspace `.env`
|
||||
|
||||
ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把密钥写进 DSH 设置。
|
||||
|
||||
| 分组 | 键 |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| LLM | `LLM_BACKEND`、`LLM_MODEL_NAME`、`LLM_API_KEY`、`LLM_BASE_URL` |
|
||||
| Embedding | `EMBEDDING_BACKEND`、`EMBEDDING_MODEL_NAME`、`EMBEDDING_API_KEY`、`EMBEDDING_BASE_URL` |
|
||||
|
||||
- workspace 目录默认 `~/.reme`,必须是绝对路径;修改后会重新读取它指向的 `.env`。
|
||||
- API Key 是只写的:Host 只返回掩码(末四位),留空表示保留已存的值。没有编辑过的键不会被重写。
|
||||
- 清空**密钥**字段并保存会保留已存的密钥——即便先输入过替代值再清空也是如此:凭据无法通过清空输入框来删除,
|
||||
因此留空始终表示“保留已存的值”。清空**非密钥**字段并保存即表示**删除**该值。只有编辑过的字段会被写入,
|
||||
文件其余部分的注释、未知键与顺序保持不变。
|
||||
- 两个后端选择是带默认值的非密钥字段:清空后会写入默认值(`LLM_BACKEND=openai`、`EMBEDDING_BACKEND=openai`),
|
||||
而不是空值。ReMe 只在变量**不存在**时才应用 `${LLM_BACKEND:-openai}` 与 `${EMBEDDING_BACKEND:-openai}`,
|
||||
空值等于没有指定任何后端,服务将无法启动。
|
||||
- 保存只清除本次提交的那部分编辑,更晚的编辑会被保留:保存进行中再改动其它字段时,那份文本不会丢失,页面也会
|
||||
提示仍有未保存的改动。
|
||||
- 保存时会校验合并后的整份文件,而不只是本次输入:ReMe 0.4.0.x 会无条件实例化 embedding 组件,因此即使不使用向量检索
|
||||
`EMBEDDING_API_KEY` 也必须非空(`EMPTY` 是文档化的占位值)。重复出现的键会在每一处被改写,文件原有的换行风格也会保留。
|
||||
- 写入是原子且仅属主可读写的(POSIX 上为 0600);旧文件会保留为 `.env.bak`,因此一次失败的保存不会丢掉其它键。
|
||||
- **测试 LLM 连接**在 workspace 内、用所选解释器发送一次只需 1 个 token 的请求,并对 `401`/`403`
|
||||
(密钥)、`404`(模型名或 Base URL)与网络错误给出提示。请求按 `.env` 里的 `LLM_BACKEND` 选择协议:
|
||||
`openai`、`dashscope`、`deepseek`、`moonshot` 走 OpenAI 兼容的 `/chat/completions`(Bearer 认证),
|
||||
`anthropic` 走 `/v1/messages`(`x-api-key`),`gemini` 走 `/v1beta/models/<model>:generateContent`
|
||||
(`x-goog-api-key`),`ollama` 走 `/api/chat`(本地服务无需密钥),`xai` 走 `api.x.ai` 上 OpenAI 兼容的
|
||||
`/chat/completions`。
|
||||
- Base URL 的来源与 ReMe 自身的凭据保持一致:`LLM_BASE_URL` 只对 OpenAI 兼容族与 Anthropic 生效,Gemini、Ollama
|
||||
与 xAI 自带地址、并不读取该变量。因此 `.env` 里残留的旧值不会把探测引到服务根本不会访问的主机,ReMe 能接受的
|
||||
配置也不会被报成故障。Base URL 的拼接方式与服务商 SDK 完全一致,因此已经带版本后缀的地址在探测与服务中都会
|
||||
同样失败。
|
||||
- ReMe 通过 gRPC SDK 连接 xAI,因此 xAI 的探测只是该通道在 REST 层面的近似。
|
||||
- 探测读取的是磁盘上的 `.env`,所以只在表单里改过配置而未保存时不会发起测试;等待期间按钮会给出提示。
|
||||
- 若 `.env` 把 `LLM_BACKEND` 设为空值,探测会报告配置不完整(`LLM config incomplete (LLM_BACKEND)`)并且不发送
|
||||
任何请求:此时 ReMe 启动时并没有任何后端,探测不能去测试服务根本无法使用的配置。若 `.env` 里没有这个键,
|
||||
探测会与 ReMe 一样使用默认后端。
|
||||
- 已知后端名写错了大小写(如 `LLM_BACKEND=OpenAI`)会被报告为无效,并给出正确写法:ReMe 的后端注册表区分大小写,
|
||||
会拒绝这个名字。而插件从未听说过的后端名仍被宽容地探测——这里的列表只是一份快照,ReMe 可能认识得更多。这条规则与名字
|
||||
长什么样无关:只有插件确实认识的后端才会被判定为大小写写错。
|
||||
- 草稿、保存提示与 LLM 测试结果都属于产生它们的 workspace:切换 workspace 会随旧区块一并丢弃,因此为某个 workspace
|
||||
输入的密钥、或属于它的一次成功提示,都不会被带到另一个。
|
||||
|
||||
## 连接测试
|
||||
|
||||
概览区的**测试连接**让 Host 对配置的服务地址执行 `POST /health_check`。健康响应必须包含可解析的 ReMe 健康报告——版本、
|
||||
健康标记与各组件明细——所以只回 `200` 与 `{}` 的普通 HTTP 服务不会被当成运行中的 ReMe。Host 复用插件其余部分使用的
|
||||
同一个 ReMe 客户端,因此请求、鉴权与三秒超时都与服务本身被调用时一致。设置里填了 `authToken` 时,Host 会以
|
||||
`Authorization: Bearer` 发送,且只作用于该服务地址。
|
||||
|
||||
## 服务生命周期
|
||||
|
||||
在这里启动服务等价于在终端执行 `reme start`,但有一点是刻意的差异:**服务会比 DSH 活得更久**。关闭 DSH 不会停止它,停止
|
||||
它是在标签页里的一次显式操作。
|
||||
|
||||
因此本插件没有把服务交给 DSH 的本地子进程提供方——后者会在 Host 退出时终止它托管的进程。插件自带一个小型服务适配器:
|
||||
|
||||
- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间),
|
||||
所以解释器缺失时不会留下残留状态。
|
||||
- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与
|
||||
`service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行能排除大多数 pid 复用(更长的
|
||||
workspace 路径、另一个端口都不会命中),但无法区分“用户照抄同一命令行手工启动”的同一服务。2333 端口上的记录绝不会
|
||||
声称管理 2444 端口上的服务。
|
||||
- 证据读不到时(`tasklist`/`ps` 输出本地化、权限不足等)归属保持“未证明”:启动与停止都不会碰进程、也不会丢弃记录——
|
||||
停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;此时卡片会给出**忘记这条记录**,
|
||||
它只删除 `service.json`、绝不向进程发信号,由用户自行确认后续处理。Host 会在结果里指明挡住操作的是哪一条记录,
|
||||
**忘记**针对的正是那条记录(可能是另一个端口上的记录,而不是当前显示的地址),并带上它的 pid:记录在此期间被换掉时,
|
||||
Host 会拒绝而不是静默丢弃新记录。
|
||||
- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。
|
||||
- 占用端口的是**已记录**但身份无法验证的进程时情况不同:该进程绝不会被发信号,因此标签页改为给出**忘记这条记录**。
|
||||
这条记录随状态一起返回,所以出路不依赖被“端口被占用”状态禁用的**启动**按钮。
|
||||
- 旧版本遗留在同一源(origin)某个路径前缀下(例如 `127.0.0.1:2333/reme`)的记录,描述的是**同一个**本地服务——
|
||||
启动只配置 host 与端口。只要能证明它是本插件启动的,这种记录就会被重新登记到当前显示的地址,随后与其他启动
|
||||
一样被管理:给出**停止**按钮,**启动**在它尚未健康时替换它。它不会再被当成标签页无从处理的外部进程,否则用户
|
||||
只能自己去杀进程。
|
||||
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX:
|
||||
`SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,
|
||||
只会被观察。
|
||||
- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并同时给出**停止**与**启动**:停止负责清记录,启动则让 Host
|
||||
停掉可证明归属的旧启动、或丢弃已证明属于外部的记录。只有本标签页发起的启动仍在进行时才会显示“启动中”。
|
||||
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
|
||||
归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。
|
||||
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。
|
||||
|
||||
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。
|
||||
|
||||
## 平台支持
|
||||
|
||||
| 方面 | 说明 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DSH | `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2`、`0.2.2-alpha.2` |
|
||||
| Node.js | `^22.19.0` 或 `>=24.0.0`;当前 DSH 需要 Node 24.9 以上,兼容矩阵以 Node 24.16 验证 |
|
||||
| Windows | 在 Windows 11 + Miniconda 上实测:注册表/PATH/conda 探测、`tasklist`/`taskkill` 身份校验与停止、`PYTHONUTF8`/`PYTHONIOENCODING` 保证 pip 输出不乱码 |
|
||||
| macOS/Linux | 探测、分离启动、身份查询与停止均有运行真实子进程的单元测试覆盖;真机验证程度仍低于 Windows |
|
||||
|
||||
## 常见问题
|
||||
|
||||
**未检测到可用的 Python。** 安装 Miniconda 或任意 Python 3.11+,再点**重新探测**。Store 占位符会被过滤,请安装真实的发行版
|
||||
而不是 Store 别名。
|
||||
|
||||
**所选解释器不在列表里。** 标签页会用“不在探测结果中”标注它,但仍然使用它。重新探测,或改选探测到的解释器。
|
||||
|
||||
**安装失败。** 先看日志尾部:缺少 wheel、代理配置过期或网络受限通常都能看出来,可以换一个软件源。conda 环境里 `pip` 也可
|
||||
用,但独立环境更能避免与其它项目的依赖冲突。
|
||||
|
||||
**保存 `.env` 被拒绝。** 保存后的文件中 `LLM_API_KEY` 与 `EMBEDDING_API_KEY` 都必须非空;关闭向量检索时 embedding 用
|
||||
`EMPTY`。清空密钥字段不会写入文件,已存的密钥会被保留;而一个根本没有存过的必需键仍会让保存失败,文件保持原样。
|
||||
|
||||
**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。
|
||||
|
||||
**服务迟迟未就绪。** 启动会轮询 120 秒;如果端口有响应但 `/health_check` 一直不通过,卡片会切到“端口被占用”状态,**停止**按钮
|
||||
始终可用。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、`.env` 不合法、端口冲突都会体现在那里。
|
||||
|
||||
**服务显示“上次启动未就绪”。** 本插件记录的进程仍存活,但一直没有通过 `/health_check`(例如启动途中 DSH 被关闭)。点**停止**
|
||||
清掉它后重新启动。若 pid 已被其它程序复用,这条记录可证明属于外部:点**启动**会丢弃记录并拉起新服务。若完全无法验证身份,
|
||||
点**忘记这条记录**——它只删除本插件的记录,不会碰进程。
|
||||
|
||||
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从
|
||||
这里启动。
|
||||
|
||||
**停止按钮不可用。** 运行中的服务不属于当前服务地址,或记录已丢失。从标签页重新启动一次即可纳入管理。
|
||||
|
||||
**LLM 测试失败。** 结果会区分 `401`/`403`(密钥)、`404`(模型名或 Base URL)与网络错误。探测读取的是 workspace 的
|
||||
`.env`,所以请先保存再测试;探测协议也取自其中的 `LLM_BACKEND`:原生后端(Anthropic、Gemini、Ollama)用自己的协议
|
||||
探测,不会被当成 OpenAI 兼容接口。
|
||||
|
||||
## 代码位置
|
||||
|
||||
```text
|
||||
integrations/dsh/src/environment/
|
||||
├── types.ts 浏览器安全的共享 RPC 类型
|
||||
├── env-fields.ts 受管的 `.env` 键及其更新规则
|
||||
├── dependency.ts 一个解释器能承载什么
|
||||
├── endpoint-rules.ts 哪些服务地址可以作为本地服务管理
|
||||
├── host/ 探测、安装、服务适配器、manager、gateway
|
||||
└── client/ 标签页:每个步骤一个模块,以及文案与样式
|
||||
```
|
||||
|
||||
只有 Host 模块会接触进程与文件;客户端只负责渲染 Host 返回的结果。
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_ZH.md",
|
||||
"docs",
|
||||
"figures"
|
||||
],
|
||||
"dsh": {
|
||||
|
|
@ -51,12 +52,12 @@
|
|||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
|
||||
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
|
||||
"@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1"
|
||||
"@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1 || ~4.0.6-alpha.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
|
||||
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
|
||||
"@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1 || ~3.18.6-alpha.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/cordis": {
|
||||
|
|
|
|||
|
|
@ -38,24 +38,62 @@ const releases = [
|
|||
schema: "3.18.5-alpha.2",
|
||||
},
|
||||
{ dsh: "0.2.1-rc.2", cordis: "4.0.5", loader: "1.0.6", schema: "3.18.5" },
|
||||
{
|
||||
dsh: "0.2.2-alpha.2",
|
||||
cordis: "4.0.6-alpha.1",
|
||||
loader: "1.0.7-alpha.1",
|
||||
schema: "3.18.6-alpha.1",
|
||||
},
|
||||
];
|
||||
|
||||
async function run(command, args, cwd) {
|
||||
// `npm` is `npm.cmd` on Windows, which execFile cannot launch directly; the
|
||||
// script runs the npm CLI through node instead, using the path npm exported to
|
||||
// this process.
|
||||
const npmCommand = process.env.npm_execpath
|
||||
? [process.execPath, [process.env.npm_execpath]]
|
||||
: ["npm", []];
|
||||
|
||||
async function run(command, args, cwd, label = "command") {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(error.stdout || "");
|
||||
process.stderr.write(error.stderr || "");
|
||||
throw error;
|
||||
const stdout = String(error.stdout ?? "");
|
||||
const stderr = String(error.stderr ?? "");
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
// The raw TAP buffer is far too large for a CI log and hides which step
|
||||
// failed. Report the failing cases and a short tail instead.
|
||||
const failures = combined
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^not ok \d+ - /.test(line) || line.startsWith("✖ "))
|
||||
.slice(0, 20);
|
||||
const tail = combined.trim().split("\n").slice(-40).join("\n");
|
||||
process.stderr.write(
|
||||
`\n[test-package] ${label} failed (exit ${error.code ?? "error"})\n`,
|
||||
);
|
||||
if (failures.length > 0)
|
||||
process.stderr.write(
|
||||
`[test-package] failing tests:\n${failures.join("\n")}\n`,
|
||||
);
|
||||
if (tail) process.stderr.write(`[test-package] last output:\n${tail}\n`);
|
||||
// A short error keeps Node's unhandled-rejection dump readable; the large
|
||||
// stdout/stderr buffers stay out of it.
|
||||
const failure = new Error(`${label} failed: ${error.message}`);
|
||||
failure.code = error.code;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the npm CLI on the platform this check runs on. */
|
||||
function runNpm(args, cwd, label = "npm") {
|
||||
return run(npmCommand[0], [...npmCommand[1], ...args], cwd, label);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
"npm",
|
||||
const { stdout } = await runNpm(
|
||||
[
|
||||
"pack",
|
||||
"--json",
|
||||
|
|
@ -64,6 +102,7 @@ try {
|
|||
temporaryDirectory,
|
||||
],
|
||||
root,
|
||||
"npm pack",
|
||||
);
|
||||
const [result] = JSON.parse(stdout);
|
||||
const files = new Set(result.files.map(({ path: file }) => file));
|
||||
|
|
@ -72,6 +111,7 @@ try {
|
|||
"dist/client.js",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"docs/environment.md",
|
||||
"figures/reme-status-overview.png",
|
||||
]) {
|
||||
assert.ok(files.has(file), `missing ${file}`);
|
||||
|
|
@ -112,8 +152,7 @@ try {
|
|||
JSON.stringify({ private: true, type: "module", dependencies }),
|
||||
);
|
||||
// Install as a consumer with normal peer resolution, then check the shipped artifact.
|
||||
await run(
|
||||
"npm",
|
||||
await runNpm(
|
||||
[
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
|
|
@ -123,6 +162,7 @@ try {
|
|||
"--fetch-timeout=30000",
|
||||
],
|
||||
consumer,
|
||||
`install DSH ${release.dsh}`,
|
||||
);
|
||||
await run(
|
||||
process.execPath,
|
||||
|
|
@ -136,6 +176,7 @@ try {
|
|||
`,
|
||||
],
|
||||
consumer,
|
||||
`import DSH ${release.dsh}`,
|
||||
);
|
||||
|
||||
const installed = path.join(consumer, "node_modules", manifest.name);
|
||||
|
|
@ -156,8 +197,9 @@ try {
|
|||
process.execPath,
|
||||
["--test", ...testFiles],
|
||||
installed,
|
||||
`tests DSH ${release.dsh}`,
|
||||
);
|
||||
await run("npm", ["run", "build"], installed);
|
||||
await runNpm(["run", "build"], installed, `build DSH ${release.dsh}`);
|
||||
const count = /# tests (\d+)/.exec(tested.stdout)?.[1];
|
||||
process.stdout.write(
|
||||
`DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type {
|
||||
SettingsFieldState,
|
||||
SettingsFormShell,
|
||||
} from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
||||
|
||||
import type { ReMeSettings } from "../types.js";
|
||||
import { validEndpoint } from "../endpoint.js";
|
||||
import {
|
||||
ReMeEnvironmentTab,
|
||||
envEn,
|
||||
envZh,
|
||||
type EnvTranslator,
|
||||
} from "../environment/client/tab.js";
|
||||
import { environmentStyles } from "../environment/client/styles.js";
|
||||
import {
|
||||
ReMeStatusPage,
|
||||
statusEn,
|
||||
|
|
@ -10,38 +21,23 @@ import {
|
|||
type StatusRpc,
|
||||
type StatusTranslator,
|
||||
} from "./status-page.js";
|
||||
import { SETTINGS_SPECS } from "./settings-specs.js";
|
||||
import { styles } from "./styles.js";
|
||||
|
||||
const NS = "reme.settings";
|
||||
const STATUS_NS = "reme.status";
|
||||
const ENV_NS = "reme.environment";
|
||||
const SETTINGS_NS = "reme-memory";
|
||||
const FIELDS = [
|
||||
"endpoint",
|
||||
"requestTimeoutMs",
|
||||
"backgroundTimeoutMs",
|
||||
"shutdownTimeoutMs",
|
||||
"autoMemoryRetryMs",
|
||||
"autoMemoryRetryMaxMs",
|
||||
"autoMemoryEnabled",
|
||||
"autoMemoryInterval",
|
||||
"autoDreamEnabled",
|
||||
"dreamCron",
|
||||
"dreamHint",
|
||||
"rootAgentsOnly",
|
||||
"language",
|
||||
"searchLimit",
|
||||
"timezone",
|
||||
] as const satisfies readonly (keyof ReMeSettings)[];
|
||||
|
||||
type Field = (typeof FIELDS)[number];
|
||||
type Translator = (key: keyof typeof en) => string;
|
||||
|
||||
interface ClientContext {
|
||||
get(name: string): { rpc: StatusRpc };
|
||||
effect(factory: () => (() => void) | void, label: string): void;
|
||||
effect(factory: () => void | (() => void), label: string): void;
|
||||
locale: {
|
||||
bind(namespace: typeof NS): Translator;
|
||||
bind(namespace: typeof STATUS_NS): StatusTranslator;
|
||||
bind(namespace: typeof ENV_NS): EnvTranslator;
|
||||
register(
|
||||
namespace: string,
|
||||
dictionaries: {
|
||||
|
|
@ -63,27 +59,61 @@ interface ClientContext {
|
|||
};
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
endpoint: string;
|
||||
requestTimeoutMs: string;
|
||||
backgroundTimeoutMs: string;
|
||||
shutdownTimeoutMs: string;
|
||||
autoMemoryRetryMs: string;
|
||||
autoMemoryRetryMaxMs: string;
|
||||
autoMemoryEnabled: boolean;
|
||||
autoMemoryInterval: string;
|
||||
autoDreamEnabled: boolean;
|
||||
dreamTime: string;
|
||||
dreamHint: string;
|
||||
rootAgentsOnly: boolean;
|
||||
language: "en" | "zh";
|
||||
searchLimit: string;
|
||||
timezone: string;
|
||||
/** One control as the model currently stages it. */
|
||||
type FieldState = SettingsFieldState;
|
||||
|
||||
/**
|
||||
* The slice of DSH's snapshot store this card reads.
|
||||
*
|
||||
* `@deepseek-ai/dsh-client-store` is not a dependency of this package, so the
|
||||
* store's own type is not available here; the card declares the two members it
|
||||
* uses instead of depending on the package.
|
||||
*/
|
||||
interface BoundStore<T> {
|
||||
getSnapshot(): T;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
/** Everything the card renders, rebuilt from the model on every publish. */
|
||||
interface CardState {
|
||||
/** Whether the Host still serves this namespace to this client. */
|
||||
status: "loading" | "ready" | "unavailable";
|
||||
/** What a save would do, and whether one is crossing the wire. */
|
||||
shell: SettingsFormShell;
|
||||
/** Every control's staged state, keyed by field name. */
|
||||
fields: Record<string, FieldState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the card's view state from the model.
|
||||
*
|
||||
* The model publishes when the scope changes, when a draft is staged, when a
|
||||
* save starts and when it settles, so this projection is the card's only
|
||||
* source of truth: what it renders is what the model holds.
|
||||
*
|
||||
* The projection carries the model's own state and nothing the settings
|
||||
* service owns: the store freezes what it is given, and the resolved section
|
||||
* belongs to the service, which replaces it rather than editing it.
|
||||
*/
|
||||
function projectCard(
|
||||
model: SettingsFormModel<ReMeSettings>,
|
||||
scope: ConfigForm<ReMeSettings>,
|
||||
): CardState {
|
||||
const fields: Record<string, FieldState> = {};
|
||||
for (const spec of SETTINGS_SPECS)
|
||||
fields[spec.field] = model.field(spec.field);
|
||||
return {
|
||||
status: scope.getSnapshot().status,
|
||||
shell: model.shell(),
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
interface ReMeCardProps {
|
||||
scope: ConfigForm<ReMeSettings>;
|
||||
t: Translator;
|
||||
rpc?: StatusRpc;
|
||||
envT?: EnvTranslator;
|
||||
view?: "summary" | "page";
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +151,8 @@ const en = {
|
|||
retryDelay: "Retry delay (ms)",
|
||||
retryMaxDelay: "Maximum retry delay (ms)",
|
||||
diagnostics: "Service diagnostics",
|
||||
tabMemory: "Memory settings",
|
||||
tabEnvironment: "Environment",
|
||||
processMemory: "Process memory",
|
||||
componentMemory: "Component memory",
|
||||
components: "Health components",
|
||||
|
|
@ -196,6 +228,8 @@ const zh: typeof en = {
|
|||
retryDelay: "失败重试间隔(毫秒)",
|
||||
retryMaxDelay: "最大重试间隔(毫秒)",
|
||||
diagnostics: "服务诊断",
|
||||
tabMemory: "记忆设置",
|
||||
tabEnvironment: "环境管理",
|
||||
processMemory: "进程内存",
|
||||
componentMemory: "组件内存",
|
||||
components: "健康组件",
|
||||
|
|
@ -250,7 +284,12 @@ export function apply(ctx: ClientContext): void {
|
|||
() => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }),
|
||||
"remeMemory.statusLocale()",
|
||||
);
|
||||
ctx.effect(
|
||||
() => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }),
|
||||
"remeMemory.envLocale()",
|
||||
);
|
||||
ctx.effect(() => installStyles(), "remeMemory.settingsStyles()");
|
||||
const envT = ctx.locale.bind(ENV_NS);
|
||||
const scope = ctx.configForms.get<ReMeSettings>(SETTINGS_NS);
|
||||
ctx.effect(
|
||||
() =>
|
||||
|
|
@ -262,7 +301,12 @@ export function apply(ctx: ClientContext): void {
|
|||
key: "@agentscope-ai/reme-dsh-plugin#reme-memory",
|
||||
label: () => t("title"),
|
||||
locale: NS,
|
||||
inject: () => ({ scope, t }),
|
||||
inject: () => ({
|
||||
scope,
|
||||
t,
|
||||
rpc: ctx.get("connection").rpc,
|
||||
envT,
|
||||
}),
|
||||
},
|
||||
ReMeSettingsCard,
|
||||
),
|
||||
|
|
@ -291,177 +335,217 @@ export function apply(ctx: ClientContext): void {
|
|||
function ReMeSettingsCard({
|
||||
scope,
|
||||
t,
|
||||
rpc,
|
||||
envT,
|
||||
view,
|
||||
}: ReMeCardProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => scope.subscribe(listener),
|
||||
() => scope.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.
|
||||
//
|
||||
// The model, its actions and the store it publishes to are built once: the
|
||||
// model owns one subscription to the scope, and `bind` registers one
|
||||
// listener on the model, so a second build would publish every change twice.
|
||||
const [form] = useState(() => {
|
||||
const model = new SettingsFormModel<ReMeSettings>(scope, SETTINGS_SPECS);
|
||||
return {
|
||||
model,
|
||||
actions: model.actions(),
|
||||
store: model.bind(() =>
|
||||
projectCard(model, scope),
|
||||
) as BoundStore<CardState>,
|
||||
};
|
||||
});
|
||||
// The model publishes on a scope change, on every staged edit, when a save
|
||||
// starts and when it settles. The card reads those publications and nothing
|
||||
// else, so a staged edit repaints the controls on its own.
|
||||
const state = useSyncExternalStore(
|
||||
form.store.subscribe,
|
||||
form.store.getSnapshot,
|
||||
);
|
||||
const [draftOverride, setDraft] = useState<{
|
||||
value: Draft;
|
||||
revision: number | undefined;
|
||||
}>();
|
||||
const [resetAll, setResetAll] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
|
||||
const value = snapshot.value;
|
||||
const draft =
|
||||
draftOverride?.value ??
|
||||
(value === undefined ? undefined : draftFrom(value));
|
||||
const disposeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [tab, setTab] = useState<"memory" | "environment">("memory");
|
||||
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;
|
||||
form.model.dispose();
|
||||
}, 0);
|
||||
};
|
||||
}, [form.model]);
|
||||
|
||||
if (view === "summary") return <>{t("description")}</>;
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
const dirty =
|
||||
resetAll ||
|
||||
(value !== undefined &&
|
||||
draft !== undefined &&
|
||||
!sameDraft(draft, draftFrom(value)));
|
||||
const validation = draft === undefined ? undefined : parseDraft(draft);
|
||||
if (state.status === "unavailable") return null;
|
||||
|
||||
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
|
||||
if (value === undefined) return;
|
||||
setDraft((current) => ({
|
||||
value: { ...(current?.value ?? draftFrom(value)), [field]: next },
|
||||
revision: current ? current.revision : snapshot.revision,
|
||||
}));
|
||||
setResetAll(false);
|
||||
setSaveState("");
|
||||
const { shell, fields } = state;
|
||||
// The model's own saving state: it is true from the moment the save starts
|
||||
// until the write settles, so the card keeps no second flag for it.
|
||||
const saving = shell.saving;
|
||||
// "The maximum retry delay is at least the initial one" is a relation between
|
||||
// two fields, which a per-field spec cannot state, so the page keeps it.
|
||||
const retryMax = (fields["autoMemoryRetryMaxMs"]?.text ?? "").trim();
|
||||
const retryBase = (fields["autoMemoryRetryMs"]?.text ?? "").trim();
|
||||
const inconsistent =
|
||||
retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase);
|
||||
const blocked = shell.invalid || inconsistent;
|
||||
const edit = (name: string, text: string): void => {
|
||||
setSaved(false);
|
||||
form.actions.edit(name, text);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (
|
||||
value === undefined ||
|
||||
draft === undefined ||
|
||||
validation === undefined ||
|
||||
!snapshot.writable ||
|
||||
!dirty ||
|
||||
saving
|
||||
)
|
||||
const save = async (): Promise<void> => {
|
||||
const current = form.store.getSnapshot().shell;
|
||||
if (!current.writable || !current.dirty || blocked || current.saving)
|
||||
return;
|
||||
setSaving(true);
|
||||
setSaveState("");
|
||||
try {
|
||||
const ops = resetAll
|
||||
? FIELDS.map((field) => ({ op: "unset" as const, path: [field] }))
|
||||
: FIELDS.flatMap((field) =>
|
||||
Object.is(value[field], validation[field])
|
||||
? []
|
||||
: [
|
||||
{
|
||||
op: "set" as const,
|
||||
path: [field],
|
||||
value: validation[field],
|
||||
},
|
||||
],
|
||||
);
|
||||
const success = await scope.mutate(
|
||||
ops,
|
||||
draftOverride?.revision ?? snapshot.revision,
|
||||
);
|
||||
setSaveState(success ? "saved" : "failed");
|
||||
if (success) {
|
||||
setDraft(undefined);
|
||||
setResetAll(false);
|
||||
}
|
||||
} catch {
|
||||
setSaveState("failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaved(false);
|
||||
await form.model.save();
|
||||
// The model published the settled state before its promise resolved, so the
|
||||
// store already carries the outcome of the write.
|
||||
const after = form.store.getSnapshot().shell;
|
||||
setSaved(!after.failed && !after.dirty);
|
||||
};
|
||||
const reset = (): void => {
|
||||
setSaved(false);
|
||||
for (const spec of SETTINGS_SPECS) form.actions.resetField(spec.field);
|
||||
};
|
||||
const discard = (): void => {
|
||||
setSaved(false);
|
||||
form.actions.discard();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reme-settings-card">
|
||||
<div className="reme-settings-body">
|
||||
{dirty ? <p className="reme-settings-pending">{t("unsaved")}</p> : null}
|
||||
{snapshot.status === "loading" ||
|
||||
draft === undefined ||
|
||||
value === undefined ? (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<fieldset
|
||||
className="reme-settings-fields"
|
||||
disabled={saving || !snapshot.writable}
|
||||
>
|
||||
<SettingsForm draft={draft} update={update} t={t} />
|
||||
</fieldset>
|
||||
{!snapshot.writable ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("readOnly")}
|
||||
</p>
|
||||
) : null}
|
||||
{validation === undefined ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("invalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reme-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset"
|
||||
disabled={!snapshot.writable || saving}
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
value: draftFrom(baseSettings(snapshot.base, value)),
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
setResetAll(true);
|
||||
setSaveState("");
|
||||
}}
|
||||
<div className="reme-settings-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="reme-tab-memory"
|
||||
aria-controls={
|
||||
tab === "memory" ? "reme-tabpanel-memory" : undefined
|
||||
}
|
||||
aria-selected={tab === "memory"}
|
||||
className={tab === "memory" ? "active" : ""}
|
||||
onClick={() => setTab("memory")}
|
||||
>
|
||||
{t("tabMemory")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="reme-tab-environment"
|
||||
aria-controls={
|
||||
tab === "environment" ? "reme-tabpanel-environment" : undefined
|
||||
}
|
||||
aria-selected={tab === "environment"}
|
||||
className={tab === "environment" ? "active" : ""}
|
||||
onClick={() => setTab("environment")}
|
||||
>
|
||||
{t("tabEnvironment")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`reme-tabpanel-${tab}`}
|
||||
aria-labelledby={`reme-tab-${tab}`}
|
||||
>
|
||||
{tab === "environment" ? (
|
||||
rpc && envT ? (
|
||||
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
|
||||
) : (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
)
|
||||
) : state.status === "ready" ? (
|
||||
<>
|
||||
{shell.dirty ? (
|
||||
<p className="reme-settings-pending">{t("unsaved")}</p>
|
||||
) : null}
|
||||
<fieldset
|
||||
className="reme-settings-fields"
|
||||
disabled={saving || !shell.writable}
|
||||
>
|
||||
{t("reset")}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{saveState === "failed" ? (
|
||||
<span className="reme-settings-error">{t("saveFailed")}</span>
|
||||
) : null}
|
||||
{saveState === "saved" ? (
|
||||
<span className="reme-settings-success">{t("saved")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => {
|
||||
setDraft(undefined);
|
||||
setResetAll(false);
|
||||
setSaveState("");
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
!snapshot.writable ||
|
||||
!dirty ||
|
||||
validation === undefined ||
|
||||
saving
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t(saving ? "saving" : "save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SettingsForm fields={fields} edit={edit} t={t} />
|
||||
</fieldset>
|
||||
{!shell.writable ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("readOnly")}
|
||||
</p>
|
||||
) : null}
|
||||
{blocked ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("invalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reme-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset"
|
||||
disabled={!shell.writable || saving}
|
||||
onClick={reset}
|
||||
>
|
||||
{t("reset")}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{shell.failed ? (
|
||||
<span className="reme-settings-error">
|
||||
{t("saveFailed")}
|
||||
</span>
|
||||
) : null}
|
||||
{saved && !shell.dirty ? (
|
||||
<span className="reme-settings-success">{t("saved")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={!shell.dirty || saving}
|
||||
onClick={discard}
|
||||
>
|
||||
{t("discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
!shell.writable || !shell.dirty || blocked || saving
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t(saving ? "saving" : "save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The memory-settings controls, all reading and writing through the model. */
|
||||
function SettingsForm({
|
||||
draft,
|
||||
update,
|
||||
fields,
|
||||
edit,
|
||||
t,
|
||||
}: {
|
||||
draft: Draft;
|
||||
update: <K extends keyof Draft>(field: K, value: Draft[K]) => void;
|
||||
fields: Record<string, FieldState>;
|
||||
edit: (name: string, text: string) => void;
|
||||
t: Translator;
|
||||
}): JSX.Element {
|
||||
const text = (name: string): string => fields[name]?.text ?? "";
|
||||
const invalid = (name: string): boolean => fields[name]?.invalid ?? false;
|
||||
const checked = (name: string): boolean => text(name) === "true";
|
||||
return (
|
||||
<>
|
||||
<section className="reme-settings-section">
|
||||
|
|
@ -469,17 +553,15 @@ function SettingsForm({
|
|||
<div className="reme-settings-grid">
|
||||
<Field wide label={t("endpoint")} hint={t("endpointHint")}>
|
||||
<input
|
||||
value={draft.endpoint}
|
||||
aria-invalid={!validEndpoint(draft.endpoint)}
|
||||
onChange={(event) => update("endpoint", event.target.value)}
|
||||
value={text("endpoint")}
|
||||
aria-invalid={invalid("endpoint")}
|
||||
onChange={(event) => edit("endpoint", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("language")}>
|
||||
<select
|
||||
value={draft.language}
|
||||
onChange={(event) =>
|
||||
update("language", event.target.value as "en" | "zh")
|
||||
}
|
||||
value={text("language")}
|
||||
onChange={(event) => edit("language", event.target.value)}
|
||||
>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
|
|
@ -490,8 +572,8 @@ function SettingsForm({
|
|||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={draft.searchLimit}
|
||||
onChange={(event) => update("searchLimit", event.target.value)}
|
||||
value={text("searchLimit")}
|
||||
onChange={(event) => edit("searchLimit", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("requestTimeout")}>
|
||||
|
|
@ -499,10 +581,8 @@ function SettingsForm({
|
|||
type="number"
|
||||
min="1000"
|
||||
max="120000"
|
||||
value={draft.requestTimeoutMs}
|
||||
onChange={(event) =>
|
||||
update("requestTimeoutMs", event.target.value)
|
||||
}
|
||||
value={text("requestTimeoutMs")}
|
||||
onChange={(event) => edit("requestTimeoutMs", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
@ -512,30 +592,32 @@ function SettingsForm({
|
|||
<div className="reme-settings-grid">
|
||||
<Check
|
||||
label={t("autoMemoryEnabled")}
|
||||
checked={draft.autoMemoryEnabled}
|
||||
onChange={(value) => update("autoMemoryEnabled", value)}
|
||||
checked={checked("autoMemoryEnabled")}
|
||||
onChange={(next) =>
|
||||
edit("autoMemoryEnabled", next ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Check
|
||||
label={t("rootOnly")}
|
||||
checked={draft.rootAgentsOnly}
|
||||
onChange={(value) => update("rootAgentsOnly", value)}
|
||||
checked={checked("rootAgentsOnly")}
|
||||
onChange={(next) => edit("rootAgentsOnly", next ? "true" : "false")}
|
||||
/>
|
||||
<Field label={t("autoMemoryInterval")}>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={draft.autoMemoryInterval}
|
||||
value={text("autoMemoryInterval")}
|
||||
onChange={(event) =>
|
||||
update("autoMemoryInterval", event.target.value)
|
||||
edit("autoMemoryInterval", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("timezone")}>
|
||||
<input
|
||||
value={draft.timezone}
|
||||
aria-invalid={!validTimezone(draft.timezone)}
|
||||
onChange={(event) => update("timezone", event.target.value)}
|
||||
value={text("timezone")}
|
||||
aria-invalid={invalid("timezone")}
|
||||
onChange={(event) => edit("timezone", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
@ -545,20 +627,22 @@ function SettingsForm({
|
|||
<div className="reme-settings-grid">
|
||||
<Check
|
||||
label={t("autoDreamEnabled")}
|
||||
checked={draft.autoDreamEnabled}
|
||||
onChange={(value) => update("autoDreamEnabled", value)}
|
||||
checked={checked("autoDreamEnabled")}
|
||||
onChange={(next) =>
|
||||
edit("autoDreamEnabled", next ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Field label={t("dreamTime")}>
|
||||
<input
|
||||
type="time"
|
||||
value={draft.dreamTime}
|
||||
onChange={(event) => update("dreamTime", event.target.value)}
|
||||
value={text("dreamCron")}
|
||||
onChange={(event) => edit("dreamCron", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field wide label={t("dreamHint")}>
|
||||
<textarea
|
||||
value={draft.dreamHint}
|
||||
onChange={(event) => update("dreamHint", event.target.value)}
|
||||
value={text("dreamHint")}
|
||||
onChange={(event) => edit("dreamHint", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
@ -571,31 +655,31 @@ function SettingsForm({
|
|||
type="number"
|
||||
min="1000"
|
||||
max="3600000"
|
||||
value={draft.backgroundTimeoutMs}
|
||||
value={text("backgroundTimeoutMs")}
|
||||
onChange={(event) =>
|
||||
update("backgroundTimeoutMs", event.target.value)
|
||||
edit("backgroundTimeoutMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("retryDelay")}>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
max={60000}
|
||||
value={draft.autoMemoryRetryMs}
|
||||
min="1000"
|
||||
max="60000"
|
||||
value={text("autoMemoryRetryMs")}
|
||||
onChange={(event) =>
|
||||
update("autoMemoryRetryMs", event.target.value)
|
||||
edit("autoMemoryRetryMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("retryMaxDelay")}>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
max={3600000}
|
||||
value={draft.autoMemoryRetryMaxMs}
|
||||
min="1000"
|
||||
max="3600000"
|
||||
value={text("autoMemoryRetryMaxMs")}
|
||||
onChange={(event) =>
|
||||
update("autoMemoryRetryMaxMs", event.target.value)
|
||||
edit("autoMemoryRetryMaxMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -604,9 +688,9 @@ function SettingsForm({
|
|||
type="number"
|
||||
min="100"
|
||||
max="60000"
|
||||
value={draft.shutdownTimeoutMs}
|
||||
value={text("shutdownTimeoutMs")}
|
||||
onChange={(event) =>
|
||||
update("shutdownTimeoutMs", event.target.value)
|
||||
edit("shutdownTimeoutMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -657,130 +741,10 @@ function Check({
|
|||
);
|
||||
}
|
||||
|
||||
function draftFrom(value: ReMeSettings): Draft {
|
||||
return {
|
||||
endpoint: value.endpoint,
|
||||
requestTimeoutMs: String(value.requestTimeoutMs),
|
||||
backgroundTimeoutMs: String(value.backgroundTimeoutMs),
|
||||
shutdownTimeoutMs: String(value.shutdownTimeoutMs),
|
||||
autoMemoryRetryMs: String(value.autoMemoryRetryMs),
|
||||
autoMemoryRetryMaxMs: String(value.autoMemoryRetryMaxMs),
|
||||
autoMemoryEnabled: value.autoMemoryEnabled,
|
||||
autoMemoryInterval: String(value.autoMemoryInterval),
|
||||
autoDreamEnabled: value.autoDreamEnabled,
|
||||
dreamTime: timeFromCron(value.dreamCron),
|
||||
dreamHint: value.dreamHint,
|
||||
rootAgentsOnly: value.rootAgentsOnly,
|
||||
language: value.language,
|
||||
searchLimit: String(value.searchLimit),
|
||||
timezone: value.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDraft(draft: Draft): ReMeSettings | undefined {
|
||||
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
|
||||
const backgroundTimeoutMs = boundedInteger(
|
||||
draft.backgroundTimeoutMs,
|
||||
1000,
|
||||
3600000,
|
||||
);
|
||||
const shutdownTimeoutMs = boundedInteger(draft.shutdownTimeoutMs, 100, 60000);
|
||||
const autoMemoryRetryMs = boundedInteger(
|
||||
draft.autoMemoryRetryMs,
|
||||
1000,
|
||||
60000,
|
||||
);
|
||||
const autoMemoryRetryMaxMs = boundedInteger(
|
||||
draft.autoMemoryRetryMaxMs,
|
||||
autoMemoryRetryMs ?? 1000,
|
||||
3600000,
|
||||
);
|
||||
const autoMemoryInterval = boundedInteger(draft.autoMemoryInterval, 1, 1000);
|
||||
const searchLimit = boundedInteger(draft.searchLimit, 1, 50);
|
||||
if (
|
||||
!validEndpoint(draft.endpoint) ||
|
||||
!validTimezone(draft.timezone) ||
|
||||
!/^\d{2}:\d{2}$/.test(draft.dreamTime)
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
[
|
||||
requestTimeoutMs,
|
||||
backgroundTimeoutMs,
|
||||
shutdownTimeoutMs,
|
||||
autoMemoryRetryMs,
|
||||
autoMemoryRetryMaxMs,
|
||||
autoMemoryInterval,
|
||||
searchLimit,
|
||||
].some((value) => value === undefined)
|
||||
)
|
||||
return undefined;
|
||||
const [hour, minute] = draft.dreamTime.split(":");
|
||||
return {
|
||||
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
|
||||
requestTimeoutMs: requestTimeoutMs!,
|
||||
backgroundTimeoutMs: backgroundTimeoutMs!,
|
||||
shutdownTimeoutMs: shutdownTimeoutMs!,
|
||||
autoMemoryRetryMs: autoMemoryRetryMs!,
|
||||
autoMemoryRetryMaxMs: autoMemoryRetryMaxMs!,
|
||||
autoMemoryEnabled: draft.autoMemoryEnabled,
|
||||
autoMemoryInterval: autoMemoryInterval!,
|
||||
autoDreamEnabled: draft.autoDreamEnabled,
|
||||
dreamCron: `${Number(minute)} ${Number(hour)} * * *`,
|
||||
dreamHint: draft.dreamHint,
|
||||
rootAgentsOnly: draft.rootAgentsOnly,
|
||||
language: draft.language,
|
||||
searchLimit: searchLimit!,
|
||||
timezone: draft.timezone.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
text: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number | undefined {
|
||||
const value = Number(text);
|
||||
return Number.isInteger(value) && value >= minimum && value <= maximum
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validTimezone(value: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: value.trim() }).format(0);
|
||||
return value.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function timeFromCron(cron: string): string {
|
||||
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
|
||||
if (match === null) return "";
|
||||
return `${String(Number(match[2])).padStart(2, "0")}:${String(
|
||||
Number(match[1]),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function baseSettings(base: unknown, fallback: ReMeSettings): ReMeSettings {
|
||||
return isRecord(base) ? (base as unknown as ReMeSettings) : fallback;
|
||||
}
|
||||
|
||||
function sameDraft(left: Draft, right: Draft): boolean {
|
||||
return (Object.keys(left) as (keyof Draft)[]).every((key) =>
|
||||
Object.is(left[key], right[key]),
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function installStyles(): () => void {
|
||||
const tag = document.createElement("style");
|
||||
tag.dataset.pluginCss = "@agentscope-ai/reme-dsh-plugin/settings";
|
||||
tag.textContent = styles;
|
||||
tag.textContent = `${styles}\n${environmentStyles}`;
|
||||
document.head.appendChild(tag);
|
||||
return () => tag.remove();
|
||||
}
|
||||
|
|
|
|||
140
integrations/dsh/src/client/settings-specs.ts
Normal file
140
integrations/dsh/src/client/settings-specs.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import type { SettingsFieldSpec } from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
|
||||
import { validEndpoint } from "../endpoint.js";
|
||||
import { validTimezone } from "../scheduling.js";
|
||||
|
||||
// Re-exported for callers that imported it from this module before it moved.
|
||||
export { validTimezone };
|
||||
|
||||
/*
|
||||
* How each memory-settings field converts between its stored value and the text
|
||||
* a control shows, in display and reset order.
|
||||
*
|
||||
* DSH's `SettingsFormModel` stages these drafts, reports which ones are invalid
|
||||
* and writes them in one revision-fenced mutation; the table below is the only
|
||||
* per-field code this plugin owns, and the field list and the field-name type
|
||||
* are derived from it, so a field cannot be added in one place and forgotten in
|
||||
* another.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A whole number inside the range the Host accepts; empty clears the field.
|
||||
*
|
||||
* The field name stays a literal in the returned type, so the one spec table
|
||||
* below keeps the closed union of setting names.
|
||||
*/
|
||||
function boundedInteger<Field extends string>(
|
||||
field: Field,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): SettingsFieldSpec & { readonly field: Field } {
|
||||
return {
|
||||
field,
|
||||
format: (value) => (typeof value === "number" ? String(value) : ""),
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") return { kind: "clear" };
|
||||
const value = Number(trimmed);
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
||||
return undefined;
|
||||
return { kind: "set", value };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A checkbox: the control shows and writes "true" or "false". */
|
||||
function flag<Field extends string>(
|
||||
field: Field,
|
||||
): SettingsFieldSpec & { readonly field: Field } {
|
||||
return {
|
||||
field,
|
||||
format: (value) => (value === true ? "true" : "false"),
|
||||
parse: (text) => ({ kind: "set", value: text === "true" }),
|
||||
};
|
||||
}
|
||||
|
||||
/** "HH:MM" in the control, a five-field cron expression in the document. */
|
||||
function timeOfDay<Field extends string>(
|
||||
field: Field,
|
||||
): SettingsFieldSpec & { readonly field: Field } {
|
||||
return {
|
||||
field,
|
||||
format: (value) => (typeof value === "string" ? timeFromCron(value) : ""),
|
||||
parse: (text) => {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
||||
if (match === null) return undefined;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour > 23 || minute > 59) return undefined;
|
||||
return { kind: "set", value: `${minute} ${hour} * * *` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** "HH:MM" for a daily `m h * * *` schedule; anything else shows nothing. */
|
||||
export function timeFromCron(cron: string): string {
|
||||
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
|
||||
if (match === null) return "";
|
||||
return `${String(Number(match[2])).padStart(2, "0")}:${String(
|
||||
Number(match[1]),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one definition of the settings fields: which exist, in which order, and
|
||||
* how each one converts between its stored value and its control text.
|
||||
*
|
||||
* `as const` keeps every field name a string literal (helpers preserve theirs
|
||||
* through a generic) and `satisfies` keeps each entry checked against DSH's
|
||||
* spec, so `SettingField` remains a closed union of the names defined here.
|
||||
*/
|
||||
const SETTING_SPEC_DEFS = [
|
||||
{
|
||||
// The endpoint loses its trailing slashes on the way in, the way the
|
||||
// client appends job names to it.
|
||||
field: "endpoint",
|
||||
format: (value: unknown) => (typeof value === "string" ? value : ""),
|
||||
parse: (text: string) => {
|
||||
const trimmed = text.trim().replace(/\/+$/, "");
|
||||
return validEndpoint(trimmed)
|
||||
? { kind: "set", value: trimmed }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
boundedInteger("requestTimeoutMs", 1000, 120000),
|
||||
boundedInteger("backgroundTimeoutMs", 1000, 3600000),
|
||||
boundedInteger("shutdownTimeoutMs", 100, 60000),
|
||||
boundedInteger("autoMemoryRetryMs", 1000, 60000),
|
||||
boundedInteger("autoMemoryRetryMaxMs", 1000, 3600000),
|
||||
flag("autoMemoryEnabled"),
|
||||
boundedInteger("autoMemoryInterval", 1, 1000),
|
||||
flag("autoDreamEnabled"),
|
||||
timeOfDay("dreamCron"),
|
||||
{
|
||||
field: "dreamHint",
|
||||
format: (value: unknown) => (typeof value === "string" ? value : ""),
|
||||
parse: (text: string) => ({ kind: "set", value: text }),
|
||||
},
|
||||
flag("rootAgentsOnly"),
|
||||
{
|
||||
field: "language",
|
||||
format: (value: unknown) => (value === "en" ? "en" : "zh"),
|
||||
parse: (text: string) =>
|
||||
text === "en" || text === "zh" ? { kind: "set", value: text } : undefined,
|
||||
},
|
||||
boundedInteger("searchLimit", 1, 50),
|
||||
{
|
||||
field: "timezone",
|
||||
format: (value: unknown) => (typeof value === "string" ? value : ""),
|
||||
parse: (text: string) =>
|
||||
validTimezone(text.trim())
|
||||
? { kind: "set", value: text.trim() }
|
||||
: undefined,
|
||||
},
|
||||
] as const satisfies readonly SettingsFieldSpec[];
|
||||
|
||||
/** Every settings field name, in display and reset order. */
|
||||
export type SettingField = (typeof SETTING_SPEC_DEFS)[number]["field"];
|
||||
|
||||
/** The specs `SettingsFormModel` stages drafts against, in display order. */
|
||||
export const SETTINGS_SPECS: SettingsFieldSpec[] = [...SETTING_SPEC_DEFS];
|
||||
|
|
@ -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: "" });
|
||||
|
|
@ -1186,6 +1188,9 @@ function diagnosticClient(settings: ReMeSettings): ReMeClient {
|
|||
endpoint: settings.endpoint,
|
||||
requestTimeoutMs: settings.requestTimeoutMs,
|
||||
backgroundTimeoutMs: settings.backgroundTimeoutMs,
|
||||
// The memory runtime authenticates, so diagnostics must too; otherwise a
|
||||
// gateway-protected service shows 401s that look like a broken workspace.
|
||||
authToken: settings.authToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,5 +31,11 @@ export const styles = `
|
|||
.reme-components-hero{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:linear-gradient(135deg,color-mix(in srgb,#21a366 6%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:18px 20px}.reme-components-hero h3{margin:3px 0 0;font-size:20px}.reme-components-hero p{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px}.reme-components-score{display:grid;grid-template-columns:auto auto;align-items:baseline}.reme-components-score strong{font-size:25px}.reme-components-score>span{color:var(--dsw-alias-label-tertiary);font-size:13px}.reme-components-score small{grid-column:1 / -1;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-status-components .reme-status-component-grid{margin-top:12px}.reme-status-component{position:relative;overflow:hidden;padding:15px}.reme-status-component:before{position:absolute;top:0;right:0;left:0;height:2px;background:var(--dsw-alias-label-dimmed);content:""}.reme-status-component.healthy:before{background:#21a366}.reme-component-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px!important}.reme-component-identity>span{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:10px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:700}.reme-component-identity>div{display:flex;min-width:0;flex-direction:column;gap:2px}
|
||||
.reme-library-hero{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:13px;border:1px solid var(--dsw-alias-border-l2);border-bottom:0;border-radius:14px 14px 0 0;background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:15px 17px}.reme-library-hero.knowledge{background:linear-gradient(135deg,color-mix(in srgb,#7656c9 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3))}.reme-library-mark{display:grid;width:38px;height:38px;place-items:center;border-radius:11px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:700}.reme-library-hero h3{margin:0;font-size:15px}.reme-library-hero p{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px}.reme-library-count{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:0 4px;text-align:right}.reme-library-count strong{font-size:18px}.reme-library-count span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-library-count code{grid-column:1 / -1;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-file-search{position:sticky;z-index:1;top:-8px;display:flex;align-items:center;gap:6px;background:inherit;padding:0 0 8px}.reme-file-search input{width:100%;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:7px 9px;color:var(--dsw-alias-label-primary);font:inherit;font-size:10px}.reme-file-search input:focus{border-color:var(--dsw-alias-brand-primary);outline:0}.reme-file-search>span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header{position:sticky;z-index:1;top:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 92%,transparent);padding:14px 18px;backdrop-filter:blur(10px)}.reme-document-preview>header span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header h3{margin:2px 0;font-size:15px}.reme-document-preview>header code{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-frontmatter,.reme-document-content{padding:16px 18px}.reme-frontmatter{border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,transparent)}.reme-frontmatter h4,.reme-document-content>h4{margin:0 0 11px;color:var(--dsw-alias-label-tertiary);font-size:9px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.reme-frontmatter dl{display:grid;gap:8px;margin:0}.reme-frontmatter dl>div{display:grid;grid-template-columns:minmax(76px,max-content) minmax(0,1fr);gap:14px}.reme-frontmatter dt{color:var(--dsw-alias-label-tertiary);font:600 10px ui-monospace,SFMono-Regular,monospace}.reme-frontmatter dd{margin:0;overflow-wrap:anywhere;font-size:11px;line-height:1.5;white-space:pre-wrap}.reme-document-content{padding-bottom:38px}
|
||||
@media(max-width:900px){.reme-status-metric-grid,.reme-status-dream-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.reme-settings-tabs{display:flex;gap:2px;margin-top:10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-settings-tabs button{position:relative;appearance:none;border:0;background:none;padding:9px 12px;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;cursor:pointer}
|
||||
.reme-settings-tabs button:hover{color:var(--dsw-alias-label-primary)}
|
||||
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
|
||||
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
|
||||
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,28 @@ export const Config = z.object({
|
|||
"ReMe HTTP service URL",
|
||||
assertEndpoint,
|
||||
),
|
||||
authToken: z
|
||||
.string()
|
||||
.default("")
|
||||
.description(
|
||||
"Optional bearer token for a self-hosted gateway in front of ReMe",
|
||||
)
|
||||
.volatile(),
|
||||
pythonPath: z
|
||||
.string()
|
||||
.default("")
|
||||
.description("Local Python interpreter selected in environment management")
|
||||
.volatile(),
|
||||
remeWorkspaceDir: z
|
||||
.string()
|
||||
.default("")
|
||||
.description("ReMe workspace directory (empty means ~/.reme)")
|
||||
.volatile(),
|
||||
autoStart: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.description("Start a stopped local ReMe service when DSH starts")
|
||||
.volatile(),
|
||||
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
|
||||
backgroundTimeoutMs: z
|
||||
.natural()
|
||||
|
|
@ -86,6 +108,10 @@ function checkedString(
|
|||
|
||||
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
authToken: "",
|
||||
pythonPath: "",
|
||||
remeWorkspaceDir: "",
|
||||
autoStart: false,
|
||||
requestTimeoutMs: 10000,
|
||||
backgroundTimeoutMs: 3600000,
|
||||
shutdownTimeoutMs: 5000,
|
||||
|
|
@ -138,6 +164,19 @@ export function resolveConfig(
|
|||
|
||||
config.endpoint = stripTrailingSlashes(String(config.endpoint));
|
||||
assertEndpoint(config.endpoint);
|
||||
config.authToken =
|
||||
typeof config.authToken === "string" ? config.authToken.trim() : "";
|
||||
if (config.authToken && !/^[\x21-\x7e]{1,512}$/.test(config.authToken))
|
||||
throw new TypeError(
|
||||
"ReMe auth token must be 1-512 visible ASCII characters without newlines",
|
||||
);
|
||||
config.pythonPath =
|
||||
typeof config.pythonPath === "string" ? config.pythonPath.trim() : "";
|
||||
config.remeWorkspaceDir =
|
||||
typeof config.remeWorkspaceDir === "string"
|
||||
? config.remeWorkspaceDir.trim()
|
||||
: "";
|
||||
config.autoStart = config.autoStart === true;
|
||||
const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`;
|
||||
const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`;
|
||||
config.autoMemoryQueueDir = resolve(
|
||||
|
|
|
|||
626
integrations/dsh/src/environment/client/dependency.tsx
Normal file
626
integrations/dsh/src/environment/client/dependency.tsx
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { interpreterKey } from "../interpreter-key.js";
|
||||
import type { InstallStatus, RemeCheck } from "../types.js";
|
||||
import { fill, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import { envCall, parseInstallStatus, parseRemeCheck } from "./rpc.js";
|
||||
|
||||
export interface CheckState {
|
||||
phase: "idle" | "ready" | "error";
|
||||
data?: RemeCheck;
|
||||
error?: string;
|
||||
/** The interpreter this result belongs to. */
|
||||
forPath: string;
|
||||
}
|
||||
|
||||
export interface InstallState {
|
||||
phase: "idle" | "running" | "done";
|
||||
/** Interpreter this state belongs to; another one cannot replace it. */
|
||||
pythonPath: string;
|
||||
/** Job this section polls; another installation cannot replace it. */
|
||||
jobId?: string;
|
||||
job?: InstallStatus;
|
||||
error?: string;
|
||||
/**
|
||||
* Request generation. An answer from an older request never paints this
|
||||
* card, so a late response cannot resurrect a job the user moved on from.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
/** The state of an interpreter that has nothing installed or running. */
|
||||
const IDLE_INSTALL: InstallState = {
|
||||
phase: "idle",
|
||||
pythonPath: "",
|
||||
generation: 0,
|
||||
};
|
||||
|
||||
export interface DependencyState {
|
||||
/** Check result, only when it belongs to the interpreter on screen. */
|
||||
checkData: RemeCheck | undefined;
|
||||
checkError: string | undefined;
|
||||
checking: boolean;
|
||||
install: InstallState;
|
||||
/** Wall clock tick while an installation runs, for the elapsed label. */
|
||||
now: number;
|
||||
recheck(): void;
|
||||
start(
|
||||
upgrade: boolean,
|
||||
indexUrl?: string,
|
||||
forceReinstall?: boolean,
|
||||
): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dependency section's asynchronous state: one reme-ai check per
|
||||
* interpreter, and one installation job bound to its own interpreter, job id
|
||||
* and request generation.
|
||||
*/
|
||||
export function useDependency(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
pythonPath: string;
|
||||
/** False for a remote endpoint, whose local steps are hidden. */
|
||||
enabled?: boolean;
|
||||
onInstalled(): void;
|
||||
},
|
||||
): DependencyState {
|
||||
const { pythonPath, enabled = true, onInstalled } = options;
|
||||
const [check, setCheck] = useState<CheckState>({
|
||||
phase: "idle",
|
||||
forPath: "",
|
||||
});
|
||||
const [install, setInstall] = useState<InstallState>(IDLE_INSTALL);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [now, setNow] = useState(0);
|
||||
const installed = useRef(onInstalled);
|
||||
installed.current = onInstalled;
|
||||
/** Monotonic request counter: only the newest answer may paint the card. */
|
||||
const generation = useRef(0);
|
||||
/** Interpreter the state on screen belongs to. */
|
||||
const currentPath = useRef(pythonPath);
|
||||
/**
|
||||
* The installation on screen belongs to the interpreter on screen: a job for
|
||||
* another interpreter is neither shown nor cancellable from here. The card
|
||||
* and the actions read this one state, so they cannot disagree.
|
||||
*/
|
||||
const visibleInstall =
|
||||
install.pythonPath === pythonPath
|
||||
? install
|
||||
: { ...IDLE_INSTALL, pythonPath };
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !pythonPath) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"checkReme",
|
||||
{ pythonPath },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setCheck({
|
||||
phase: "ready",
|
||||
data: parseRemeCheck(value),
|
||||
error: undefined,
|
||||
forPath: pythonPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setCheck({
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
forPath: pythonPath,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, pythonPath, tick, enabled]);
|
||||
|
||||
const recheck = useCallback(() => {
|
||||
setCheck({ phase: "idle", forPath: "" });
|
||||
setTick((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The job finished. A successful install changed what the interpreter has,
|
||||
* so the dependency check has to run again — otherwise the card keeps
|
||||
* reporting "not installed" and Start stays disabled.
|
||||
*/
|
||||
const finishInstall = useCallback(
|
||||
(status: InstallStatus, path: string) => {
|
||||
if (status.phase !== "succeeded") return;
|
||||
// The success belongs to the interpreter on screen; anything else is a
|
||||
// job the user already moved on from.
|
||||
if (currentPath.current !== path) return;
|
||||
installed.current();
|
||||
recheck();
|
||||
},
|
||||
[recheck],
|
||||
);
|
||||
|
||||
// Progress belongs to an interpreter: switching interpreters must not keep
|
||||
// showing (or cancelling) a job that was started for the previous one. The
|
||||
// new generation retires every answer the previous interpreter is owed.
|
||||
useEffect(() => {
|
||||
currentPath.current = pythonPath;
|
||||
const request = (generation.current += 1);
|
||||
setInstall({ phase: "idle", pythonPath, generation: request });
|
||||
}, [pythonPath]);
|
||||
|
||||
// Restoring a running job is per interpreter: a job started for another
|
||||
// interpreter belongs to that interpreter's section, not this one. The Host
|
||||
// looks the job up by interpreter, because two interpreters may install at
|
||||
// the same time and the newest job overall is not necessarily this one.
|
||||
useEffect(() => {
|
||||
if (!enabled || !pythonPath) return;
|
||||
const controller = new AbortController();
|
||||
const request = generation.current;
|
||||
void (async () => {
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(
|
||||
rpc,
|
||||
"installStatus",
|
||||
{ pythonPath },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
// An installation the user started meanwhile owns the card now.
|
||||
if (request !== generation.current) return;
|
||||
if (!status || status.phase !== "running") return;
|
||||
// The Host looks the job up by interpreter, with the path semantics of
|
||||
// the path's own platform; the tab compares the same way, so a job the
|
||||
// Host found is not dropped again here.
|
||||
if (interpreterKey(status.pythonPath) !== interpreterKey(pythonPath))
|
||||
return;
|
||||
setInstall({
|
||||
phase: "running",
|
||||
pythonPath,
|
||||
jobId: status.id,
|
||||
job: status,
|
||||
generation: request,
|
||||
});
|
||||
} catch {
|
||||
// Restoring is best-effort; a later action reports real failures.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, pythonPath, enabled]);
|
||||
|
||||
const jobId = install.jobId;
|
||||
const installPath = install.pythonPath;
|
||||
// The generation is not read in the effect: it is listed so that a replaced
|
||||
// state restarts the poll instead of leaving the old one running.
|
||||
const installGeneration = install.generation;
|
||||
useEffect(() => {
|
||||
// A running job always has an id; without one the Host would answer with
|
||||
// the newest job overall, which may belong to another interpreter.
|
||||
if (install.phase !== "running" || !jobId) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(rpc, "installStatus", { jobId }, controller.signal),
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!status) {
|
||||
setInstall((current) => ({ ...current, phase: "idle" }));
|
||||
return;
|
||||
}
|
||||
if (status.phase === "running") {
|
||||
setInstall((current) => ({ ...current, job: status }));
|
||||
timer = setTimeout(() => void poll(), 2000);
|
||||
return;
|
||||
}
|
||||
setInstall((current) => ({ ...current, phase: "done", job: status }));
|
||||
finishInstall(status, installPath);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "done",
|
||||
error: safeError(error),
|
||||
}));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
install.phase,
|
||||
jobId,
|
||||
installPath,
|
||||
installGeneration,
|
||||
rpc,
|
||||
finishInstall,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (install.phase !== "running") return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [install.phase]);
|
||||
|
||||
const start = useCallback(
|
||||
async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
|
||||
// The card shows the derived state, so the guard reads it too: a job for
|
||||
// another interpreter must not block this one.
|
||||
if (!pythonPath || visibleInstall.phase === "running") return;
|
||||
const target = pythonPath;
|
||||
// Every request gets its own generation, so a late answer cannot
|
||||
// replace a newer installation or survive an interpreter switch.
|
||||
const request = (generation.current += 1);
|
||||
setInstall({ phase: "idle", pythonPath: target, generation: request });
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(rpc, "install", {
|
||||
pythonPath: target,
|
||||
upgrade,
|
||||
...(indexUrl ? { indexUrl } : {}),
|
||||
...(forceReinstall ? { forceReinstall } : {}),
|
||||
}),
|
||||
);
|
||||
// The answer belongs to the request that asked for it.
|
||||
if (request !== generation.current || target !== currentPath.current)
|
||||
return;
|
||||
if (!status) {
|
||||
setInstall({
|
||||
phase: "idle",
|
||||
pythonPath: target,
|
||||
generation: request,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setInstall({
|
||||
phase: status.phase === "running" ? "running" : "done",
|
||||
pythonPath: target,
|
||||
jobId: status.id,
|
||||
job: status,
|
||||
generation: request,
|
||||
});
|
||||
if (status.phase !== "running") finishInstall(status, target);
|
||||
} catch (error) {
|
||||
if (request !== generation.current || target !== currentPath.current)
|
||||
return;
|
||||
setInstall({
|
||||
phase: "done",
|
||||
pythonPath: target,
|
||||
generation: request,
|
||||
error: safeError(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
[finishInstall, pythonPath, rpc, visibleInstall.phase],
|
||||
);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
await envCall(rpc, "installCancel", { jobId });
|
||||
} catch {
|
||||
// Polling reflects the final phase either way.
|
||||
}
|
||||
}, [jobId, rpc]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
const request = (generation.current += 1);
|
||||
setInstall({
|
||||
phase: "idle",
|
||||
pythonPath: currentPath.current,
|
||||
generation: request,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const current = check.forPath === pythonPath ? check : undefined;
|
||||
return {
|
||||
checkData: current?.data,
|
||||
checkError: current?.error,
|
||||
checking: Boolean(pythonPath) && check.forPath !== pythonPath,
|
||||
// The installation on screen belongs to the interpreter on screen.
|
||||
install: visibleInstall,
|
||||
now,
|
||||
recheck,
|
||||
start,
|
||||
cancel,
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
function elapsedText(job: InstallStatus, now: number): string {
|
||||
const started = Date.parse(job.startedAt);
|
||||
if (!Number.isFinite(started) || now < started) return "";
|
||||
const seconds = Math.floor((now - started) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Step 2: reme-ai in the selected interpreter, and its one-click install. */
|
||||
export function DependencySection({
|
||||
t,
|
||||
pythonPath,
|
||||
dependency,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
pythonPath: string;
|
||||
dependency: DependencyState;
|
||||
}): JSX.Element {
|
||||
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
|
||||
: !checkData.installed
|
||||
? { tone: "progress" as const, text: t("depMissing") }
|
||||
: checkData.versionOk && checkData.hasCore
|
||||
? { tone: "ok" as const, text: t("depReady") }
|
||||
: checkData.versionOk
|
||||
? { tone: "progress" as const, text: t("coreMissingBadge") }
|
||||
: { tone: "progress" as const, text: t("depUpgrade") };
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("dependencyTitle")}</h4>
|
||||
<p>{t("dependencyHint")}</p>
|
||||
</div>
|
||||
{badge ? (
|
||||
<aside>
|
||||
<StatusBadge tone={badge.tone}>{badge.text}</StatusBadge>
|
||||
</aside>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
<div className="reme-env-factrow">
|
||||
<span className="reme-env-field-label">{t("interpreterLabel")}</span>
|
||||
<code className="reme-env-path" title={pythonPath}>
|
||||
{pythonPath || t("noInterpreter")}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!pythonPath}
|
||||
onClick={dependency.recheck}
|
||||
>
|
||||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{!pythonPath ? (
|
||||
<p className="reme-settings-muted">{t("selectInterpreterFirst")}</p>
|
||||
) : checking ? (
|
||||
<p className="reme-settings-muted">{t("checkingReme")}</p>
|
||||
) : null}
|
||||
{checkError ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("checkRemeFailed")}
|
||||
{checkError ? `: ${checkError}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{checkData?.error ? (
|
||||
<>
|
||||
<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">
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("remeLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
<>
|
||||
<StatusBadge
|
||||
tone={checkData.versionOk ? "ok" : "progress"}
|
||||
>{`reme-ai ${checkData.version}`}</StatusBadge>
|
||||
{checkData.versionOk ? null : (
|
||||
<small>{t("upgradeRecommended")}</small>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("notInstalled")}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("coreLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
<StatusBadge tone={checkData.hasCore ? "ok" : "progress"}>
|
||||
{checkData.hasCore ? t("coreOk") : t("coreMissing")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("cliLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
checkData.cliPath ? (
|
||||
<code
|
||||
className="reme-env-metric-path"
|
||||
title={checkData.cliPath}
|
||||
>
|
||||
{checkData.cliPath}
|
||||
</code>
|
||||
) : (
|
||||
<StatusBadge tone="progress">
|
||||
{t("cliFallback")}
|
||||
</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{checkData && install.phase !== "running" ? (
|
||||
<div className="reme-env-cta">
|
||||
<label className="reme-env-mirror">
|
||||
<span>{t("mirrorLabel")}</span>
|
||||
<select
|
||||
value={mirror}
|
||||
onChange={(event) => setMirror(event.target.value)}
|
||||
>
|
||||
<option value="">{t("mirrorDefault")}</option>
|
||||
<option value="https://pypi.org/simple">
|
||||
{t("mirrorPypi")}
|
||||
</option>
|
||||
<option value="https://pypi.tuna.tsinghua.edu.cn/simple">
|
||||
{t("mirrorTsinghua")}
|
||||
</option>
|
||||
<option value="https://mirrors.aliyun.com/pypi/simple">
|
||||
{t("mirrorAliyun")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>
|
||||
{fill(t(reinstall ? "reinstallConfirm" : "installConfirm"), {
|
||||
path: pythonPath,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
// Fresh install: no upgrade. Outdated: upgrade. Current
|
||||
// but suspect (Reinstall): force pip to reinstall.
|
||||
// The mirror choice reaches pip as `--index-url`; without
|
||||
// it the selector would be decorative.
|
||||
void dependency.start(
|
||||
checkData.installed && !checkData.versionOk,
|
||||
mirror || undefined,
|
||||
reinstall,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("confirmInstall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : checkData.installed && checkData.versionOk ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("reinstall")}
|
||||
</button>
|
||||
) : checkData.installed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("upgradeButton")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("installButton")}
|
||||
</button>
|
||||
)}
|
||||
<p className="reme-settings-muted reme-env-hint-inline">
|
||||
{t("condaHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "running" && install.job ? (
|
||||
<div className="reme-env-progress">
|
||||
<div className="reme-env-row">
|
||||
<StatusBadge tone="progress">
|
||||
{t("installing")}
|
||||
{elapsedText(install.job, now)
|
||||
? ` · ${elapsedText(install.job, now)}`
|
||||
: ""}
|
||||
</StatusBadge>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={install.job.cancelling === true}
|
||||
onClick={() => void dependency.cancel()}
|
||||
>
|
||||
{t(install.job.cancelling ? "cancelling" : "cancelInstall")}
|
||||
</button>
|
||||
</div>
|
||||
{install.job.cancelling ? (
|
||||
<p className="reme-settings-muted">{t("cancellingHint")}</p>
|
||||
) : null}
|
||||
<pre className="reme-env-log">
|
||||
{install.job.logTail || t("waitingForOutput")}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "done" ? (
|
||||
<div className="reme-env-result">
|
||||
{install.job?.phase === "succeeded" ? (
|
||||
<p className="reme-settings-success">{t("installSucceeded")}</p>
|
||||
) : null}
|
||||
{install.job?.phase === "cancelled" ? (
|
||||
<p className="reme-settings-muted">{t("installCancelled")}</p>
|
||||
) : null}
|
||||
{install.job?.phase === "failed" ? (
|
||||
<>
|
||||
<p className="reme-settings-error">{t("installFailed")}</p>
|
||||
{install.job.logTail ? (
|
||||
<pre className="reme-env-log">{install.job.logTail}</pre>
|
||||
) : null}
|
||||
<p className="reme-settings-muted">{install.job.logPath}</p>
|
||||
</>
|
||||
) : null}
|
||||
{install.error ? (
|
||||
<p className="reme-settings-error">{install.error}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
dependency.dismiss();
|
||||
}}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
404
integrations/dsh/src/environment/client/locales.ts
Normal file
404
integrations/dsh/src/environment/client/locales.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/** Copy for the Environment tab, registered under `reme.environment`. */
|
||||
export const envEn = {
|
||||
endpointLabel: "Service URL",
|
||||
checking: "Checking…",
|
||||
connected: "Connected",
|
||||
disconnected: "Not connected",
|
||||
retest: "Test connection",
|
||||
remoteNotice:
|
||||
"This service URL is not a loopback URL at the origin root, so only its connection is probed. A gateway mounted under a path prefix cannot be managed locally.",
|
||||
pythonTitle: "Local Python",
|
||||
pythonHint:
|
||||
"ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
|
||||
detecting: "Detecting Python environments…",
|
||||
detectFailed: "Detection failed",
|
||||
refresh: "Refresh",
|
||||
emptyTitle: "No usable Python found",
|
||||
emptyHint:
|
||||
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
|
||||
miniconda: "Miniconda download",
|
||||
recommended: "recommended",
|
||||
tooOld: "Needs 3.11+",
|
||||
originConda: "Conda ({name})",
|
||||
originPythonOrg: "python.org installer",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv environment",
|
||||
originSystem: "System PATH",
|
||||
originUnknown: "Unknown origin",
|
||||
errorsTitle: "Probe details",
|
||||
dependencyTitle: "ReMe dependency",
|
||||
dependencyHint:
|
||||
"The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
|
||||
interpreterLabel: "Interpreter",
|
||||
noInterpreter: "No interpreter selected",
|
||||
selectInterpreterFirst: "Select or install a Python interpreter above first.",
|
||||
recheck: "Check",
|
||||
checkingReme: "Checking ReMe installation…",
|
||||
checkRemeFailed: "ReMe check failed",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] extras",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "Not installed",
|
||||
upgradeRecommended: "upgrade recommended",
|
||||
coreOk: "Available",
|
||||
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
||||
coreMissingBadge: "core missing",
|
||||
cliFallback: "python wrapper fallback",
|
||||
installButton: "Install reme-ai[core]",
|
||||
upgradeButton: "Upgrade reme-ai",
|
||||
reinstall: "Reinstall reme-ai[core]",
|
||||
installConfirm:
|
||||
"Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
|
||||
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",
|
||||
cancelInstall: "Cancel install",
|
||||
cancelling: "Cancelling…",
|
||||
cancellingHint:
|
||||
"The interpreter stays locked until the install process has actually stopped.",
|
||||
waitingForOutput: "Waiting for pip output…",
|
||||
installSucceeded:
|
||||
"ReMe installed. Search, automatic memory and memory consolidation are now available.",
|
||||
installFailed: "Installation failed. See the log below.",
|
||||
installCancelled: "Installation cancelled.",
|
||||
dismiss: "Dismiss",
|
||||
mirrorLabel: "Package index",
|
||||
mirrorDefault: "Default (pip config)",
|
||||
mirrorPypi: "PyPI",
|
||||
mirrorTsinghua: "Tsinghua",
|
||||
mirrorAliyun: "Aliyun",
|
||||
condaHint:
|
||||
"In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
|
||||
configHint:
|
||||
"ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
|
||||
workspaceDir: "Workspace directory",
|
||||
workspaceDirHint:
|
||||
"Empty means ~/.reme. Restart the service after changing it.",
|
||||
saveWorkspace: "Save path",
|
||||
envLoading: "Loading .env…",
|
||||
envMissing: "No .env yet; saving creates it.",
|
||||
envPath: "File",
|
||||
llmGroup: "LLM (auto memory / consolidation)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "Backend",
|
||||
modelLabel: "Model",
|
||||
apiKeyLabel: "API key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "Set {masked}; leave blank to keep",
|
||||
secretUnset: "Not set",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
|
||||
saveEnv: "Save to .env",
|
||||
savingEnv: "Saving…",
|
||||
savedEnv: "Saved to {path}",
|
||||
savedEnvPending: "Saved to {path}. Newer edits are still pending.",
|
||||
saveEnvFailed: "Saving failed",
|
||||
testLlmButton: "Test LLM connection",
|
||||
testingLlm: "Testing…",
|
||||
testLlmSaveFirst:
|
||||
"Save the .env first: the test uses the configuration that is saved on disk.",
|
||||
llmOk: "LLM reachable ({ms} ms)",
|
||||
llmFailed: "LLM test failed",
|
||||
serviceHint:
|
||||
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
|
||||
serviceBadgeStarting: "Starting…",
|
||||
serviceBadgeConflict: "Port occupied",
|
||||
serviceBadgeUnhealthy: "Not healthy",
|
||||
serviceBadgeStopped: "Stopped",
|
||||
serviceBadgeStale: "Last launch not ready",
|
||||
serviceRunning: "Running",
|
||||
startService: "Start service",
|
||||
stopService: "Stop service",
|
||||
startTimeout: "The service did not become healthy within 120 seconds",
|
||||
startFailedStopped:
|
||||
"The service exited before it became healthy. Open the log below.",
|
||||
startErrorOtherEndpoint:
|
||||
"A service start for another endpoint is still in progress.",
|
||||
startErrorConflict: "Another process is already listening on this endpoint.",
|
||||
startErrorOtherEndpointRunning:
|
||||
"A service for another endpoint is still running; stop it there first.",
|
||||
startErrorPreviousNotStopped:
|
||||
"The previous service could not be stopped; check the log.",
|
||||
startErrorUnverifiedRecord:
|
||||
"The recorded service identity could not be verified, so it was left untouched; stop it manually, then retry.",
|
||||
startErrorUnverifiedRecordPid:
|
||||
"The recorded service identity could not be verified (PID {pid}), 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.",
|
||||
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.",
|
||||
stopRecordChanged:
|
||||
"The recorded service changed since it was inspected, so the offer was withdrawn. Run the action again to see the current record.",
|
||||
stopConfirm:
|
||||
"Stop the ReMe service? Automatic memory in flight may need a retry.",
|
||||
foreignNote:
|
||||
"This service was started outside DSH; its state is shown but it is not managed here.",
|
||||
conflictNote:
|
||||
"Another program occupies this port. Change the service URL port in Memory settings.",
|
||||
conflictManagedNote:
|
||||
"This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
|
||||
staleNote:
|
||||
"A recorded launch is still running but never became healthy. Stop it, then start again.",
|
||||
forgetRecord: "Forget this record",
|
||||
forgetHint:
|
||||
"The plugin cannot clear this service record: Start and Stop both refuse to act on it. Forgetting it deletes only the record; the process keeps running.",
|
||||
forgetConfirmRecord:
|
||||
"Forget the service recorded for {endpoint}? This only deletes the plugin's record; it does not stop the process. Continue only if the process is already gone or you will manage it yourself.",
|
||||
forgetConfirmPid: "The record describes process {pid}.",
|
||||
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",
|
||||
managedNote: "Managed by this plugin · PID {pid}",
|
||||
serviceError: "Service operation failed",
|
||||
settingsWriteFailed:
|
||||
"The setting was not saved. The form may have changed in another window; retry.",
|
||||
configTitle: "ReMe parameters",
|
||||
controlTitle: "Service control",
|
||||
heroEyebrow: "ReMe local runtime",
|
||||
heroReady: "Ready — memory runs locally",
|
||||
heroSetup: "Finish the setup in the steps below",
|
||||
heroService: "Service",
|
||||
heroPython: "Python",
|
||||
heroReme: "ReMe",
|
||||
noneSelected: "not selected",
|
||||
noneDetected: "not detected",
|
||||
envCount: "{count} environments",
|
||||
envRemeCount: "{count} with ReMe",
|
||||
envOthers: "{count} other environments",
|
||||
scannedAt: "Detected at {time}",
|
||||
currentInterpreter: "Current interpreter",
|
||||
notInDetection: "not detected",
|
||||
savingSelection: "Saving…",
|
||||
changeInterpreter: "Change interpreter",
|
||||
collapseList: "Collapse list",
|
||||
searchPlaceholder: "Filter by version, origin or path",
|
||||
noMatch: "No environment matches the filter.",
|
||||
showAllEnvs: "Show all {count}",
|
||||
showFewer: "Show fewer",
|
||||
depReady: "Ready",
|
||||
depUpgrade: "Upgrade available",
|
||||
depMissing: "Not installed",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
recommendWhyReme:
|
||||
"Already has a supported reme-ai, and it is a dedicated conda environment.",
|
||||
recommendWhyRemeAny: "Already has a supported reme-ai.",
|
||||
recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.",
|
||||
recommendWhyFallback: "Fallback: the first usable interpreter.",
|
||||
};
|
||||
|
||||
export const envZh: typeof envEn = {
|
||||
endpointLabel: "服务地址",
|
||||
checking: "正在检查…",
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
retest: "测试连接",
|
||||
remoteNotice:
|
||||
"当前服务地址不是本机根路径地址,仅探测其连通性;带路径前缀的网关地址不支持本地环境管理。",
|
||||
pythonTitle: "本地 Python 环境",
|
||||
pythonHint:
|
||||
"ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
|
||||
detecting: "正在探测本地 Python 环境…",
|
||||
detectFailed: "探测失败",
|
||||
refresh: "重新探测",
|
||||
emptyTitle: "未检测到可用的 Python 环境",
|
||||
emptyHint:
|
||||
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
|
||||
miniconda: "下载 Miniconda",
|
||||
recommended: "推荐",
|
||||
tooOld: "需要 3.11+",
|
||||
originConda: "Conda({name})",
|
||||
originPythonOrg: "python.org 安装器",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv 虚拟环境",
|
||||
originSystem: "系统 PATH",
|
||||
originUnknown: "来源未知",
|
||||
errorsTitle: "探测细节",
|
||||
dependencyTitle: "ReMe 依赖",
|
||||
dependencyHint:
|
||||
"本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
|
||||
interpreterLabel: "解释器",
|
||||
noInterpreter: "尚未选择解释器",
|
||||
selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
|
||||
recheck: "检测",
|
||||
checkingReme: "正在检测 ReMe 安装…",
|
||||
checkRemeFailed: "ReMe 检测失败",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] 依赖",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "未安装",
|
||||
upgradeRecommended: "建议升级",
|
||||
coreOk: "可用",
|
||||
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
||||
coreMissingBadge: "缺少 core",
|
||||
cliFallback: "回退到 python 包装",
|
||||
installButton: "安装 reme-ai[core]",
|
||||
upgradeButton: "升级 reme-ai",
|
||||
reinstall: "重装 reme-ai[core]",
|
||||
installConfirm:
|
||||
"将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
|
||||
reinstallConfirm:
|
||||
"将使用 --force-reinstall 重新安装到 {path},依赖会重新下载,进度见下方日志。",
|
||||
confirmInstall: "确认安装",
|
||||
cancel: "取消",
|
||||
installing: "正在安装",
|
||||
cancelInstall: "取消安装",
|
||||
cancelling: "正在取消…",
|
||||
cancellingHint: "在安装进程真正退出之前,该解释器会一直保持占用。",
|
||||
waitingForOutput: "等待 pip 输出…",
|
||||
installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
|
||||
installFailed: "安装失败,请查看下方日志。",
|
||||
installCancelled: "安装已取消。",
|
||||
dismiss: "关闭",
|
||||
mirrorLabel: "软件源",
|
||||
mirrorDefault: "默认(pip 配置)",
|
||||
mirrorPypi: "PyPI 官方",
|
||||
mirrorTsinghua: "清华镜像",
|
||||
mirrorAliyun: "阿里云镜像",
|
||||
condaHint:
|
||||
"conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
|
||||
configHint:
|
||||
"ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
|
||||
workspaceDir: "Workspace 目录",
|
||||
workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
|
||||
saveWorkspace: "保存路径",
|
||||
envLoading: "正在读取 .env…",
|
||||
envMissing: "尚未创建 .env;保存后自动创建。",
|
||||
envPath: "文件",
|
||||
llmGroup: "LLM(自动记忆 / 记忆整理)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "后端",
|
||||
modelLabel: "模型",
|
||||
apiKeyLabel: "API Key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "已设置 {masked};留空保持不变",
|
||||
secretUnset: "未设置",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
|
||||
saveEnv: "保存到 .env",
|
||||
savingEnv: "正在保存…",
|
||||
savedEnv: "已保存到 {path}",
|
||||
savedEnvPending: "已保存到 {path}。仍有更新的修改待保存。",
|
||||
saveEnvFailed: "保存失败",
|
||||
testLlmButton: "测试 LLM 连接",
|
||||
testingLlm: "正在测试…",
|
||||
testLlmSaveFirst: "请先保存 .env:测试使用的是磁盘上已保存的配置。",
|
||||
llmOk: "LLM 连接正常({ms} ms)",
|
||||
llmFailed: "LLM 测试失败",
|
||||
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
|
||||
serviceBadgeStarting: "启动中…",
|
||||
serviceBadgeConflict: "端口被占用",
|
||||
serviceBadgeUnhealthy: "未就绪",
|
||||
serviceBadgeStopped: "已停止",
|
||||
serviceBadgeStale: "上次启动未就绪",
|
||||
serviceRunning: "运行中",
|
||||
startService: "启动服务",
|
||||
stopService: "停止服务",
|
||||
startTimeout: "服务在 120 秒内未就绪",
|
||||
startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
|
||||
startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
|
||||
startErrorConflict: "该端口已被其他程序占用。",
|
||||
startErrorOtherEndpointRunning:
|
||||
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
|
||||
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
|
||||
startErrorUnverifiedRecord:
|
||||
"无法验证记录中的进程身份,已保持其运行;请手动停止后重试。",
|
||||
startErrorUnverifiedRecordPid:
|
||||
"无法验证记录中的进程身份(PID {pid}),已保持其运行;请手动停止后重试。",
|
||||
startErrorNoPython: "请先选择一个 Python 解释器。",
|
||||
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
|
||||
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
|
||||
startErrorFailed: "服务未能启动,请查看日志。",
|
||||
stopNoRecord: "本插件没有启动过该服务。",
|
||||
stopForeign: "记录中的服务不属于当前服务地址。",
|
||||
stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
|
||||
stopNotExited: "服务未退出;已保留记录,可稍后重试。",
|
||||
stopRecordChanged:
|
||||
"记录的服务在检查之后发生了变化,因此已撤回该操作。请重新执行一次操作以查看当前记录。",
|
||||
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
|
||||
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
|
||||
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
||||
conflictManagedNote:
|
||||
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
|
||||
staleNote: "上次启动的进程仍在运行,但从未就绪;请停止它后重新启动。",
|
||||
forgetRecord: "忘记这条记录",
|
||||
forgetHint:
|
||||
"本插件无法清除这条服务记录:启动与停止都会拒绝操作它。忘记记录只删除记录,进程仍会继续运行。",
|
||||
forgetConfirmRecord:
|
||||
"忘记 {endpoint} 上记录的服务?此操作只删除本插件的记录,不会结束进程。请确认进程已结束或你将自行管理后再继续。",
|
||||
forgetConfirmPid: "该记录描述的是进程 {pid}。",
|
||||
healthErrorHttp: "服务地址返回了 HTTP 错误,而不是 ReMe 健康报告。",
|
||||
healthErrorNotReme: "服务地址有响应,但返回的不是 ReMe 健康报告。",
|
||||
healthErrorReme: "服务在健康检查中报告了错误。",
|
||||
healthErrorUnreachable: "无法从本机连接到该服务地址。",
|
||||
viewLogs: "查看日志",
|
||||
logEmpty: "服务日志为空。",
|
||||
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
||||
managedNote: "由本插件托管 · PID {pid}",
|
||||
serviceError: "服务操作失败",
|
||||
settingsWriteFailed: "设置未保存;表单可能已在其他窗口更新,请重试。",
|
||||
configTitle: "ReMe 参数",
|
||||
controlTitle: "服务控制",
|
||||
heroEyebrow: "ReMe 本地运行时",
|
||||
heroReady: "已就绪,记忆保存在本地",
|
||||
heroSetup: "按下方步骤完成配置",
|
||||
heroService: "服务",
|
||||
heroPython: "Python",
|
||||
heroReme: "ReMe",
|
||||
noneSelected: "未选择",
|
||||
noneDetected: "未探测",
|
||||
envCount: "{count} 个环境",
|
||||
envRemeCount: "{count} 个已装 ReMe",
|
||||
envOthers: "另有 {count} 个环境",
|
||||
scannedAt: "探测于 {time}",
|
||||
currentInterpreter: "当前解释器",
|
||||
notInDetection: "不在探测结果中",
|
||||
savingSelection: "正在保存…",
|
||||
changeInterpreter: "更换解释器",
|
||||
collapseList: "收起列表",
|
||||
searchPlaceholder: "按版本、来源或路径筛选",
|
||||
noMatch: "没有匹配的环境。",
|
||||
showAllEnvs: "显示全部 {count} 个",
|
||||
showFewer: "收起多余项",
|
||||
depReady: "就绪",
|
||||
depUpgrade: "可升级",
|
||||
depMissing: "未安装",
|
||||
expand: "展开",
|
||||
collapse: "收起",
|
||||
recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。",
|
||||
recommendWhyRemeAny: "已安装可用的 reme-ai。",
|
||||
recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。",
|
||||
recommendWhyFallback: "兜底选择:第一个可用的解释器。",
|
||||
};
|
||||
|
||||
export type EnvTranslator = (key: keyof typeof envEn) => string;
|
||||
|
||||
/** One translated string key of this tab. */
|
||||
export type EnvKey = keyof typeof envEn;
|
||||
|
||||
/** Fill `{placeholder}` tokens in a translated string. */
|
||||
export function fill(
|
||||
text: string,
|
||||
values: Record<string, string | number>,
|
||||
): string {
|
||||
return text.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in values ? String(values[name]) : match,
|
||||
);
|
||||
}
|
||||
118
integrations/dsh/src/environment/client/presentation.tsx
Normal file
118
integrations/dsh/src/environment/client/presentation.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import type { PythonEnv, PythonOrigin } from "../types.js";
|
||||
import type { EnvTranslator } from "./locales.js";
|
||||
|
||||
/** Tone of a status chip: neutral, healthy, broken, or in progress. */
|
||||
export type BadgeTone = "" | "ok" | "bad" | "progress";
|
||||
|
||||
export interface Badge {
|
||||
tone: BadgeTone;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One dot-plus-text chip, shared by the hero, the cards and the metrics. */
|
||||
export function StatusBadge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: BadgeTone;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span className={`reme-status-badge ${tone}`}>
|
||||
<i />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pill used for origins, versions and capability markers. */
|
||||
export function Tag({
|
||||
tone = "",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone?: "" | "ok" | "bad" | "warn" | "brand";
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span className={`reme-env-tag ${tone}`} title={title}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
|
||||
switch (origin.kind) {
|
||||
case "conda":
|
||||
return t("originConda").replace("{name}", origin.envName);
|
||||
case "python.org":
|
||||
return t("originPythonOrg");
|
||||
case "windows-store":
|
||||
return t("originStore");
|
||||
case "venv":
|
||||
return t("originVenv");
|
||||
case "system":
|
||||
return t("originSystem");
|
||||
default:
|
||||
return t("originUnknown");
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable reason behind the "recommended" marker. */
|
||||
export function recommendReason(t: EnvTranslator, env: PythonEnv): string {
|
||||
const named = env.origin.kind === "conda" && env.origin.envName !== "base";
|
||||
if (env.reme?.installed)
|
||||
return t(named ? "recommendWhyReme" : "recommendWhyRemeAny");
|
||||
return t(named ? "recommendWhyNamed" : "recommendWhyFallback");
|
||||
}
|
||||
|
||||
/** The reme-ai, recommendation and version pills one interpreter carries. */
|
||||
export function InterpreterBadges({
|
||||
t,
|
||||
env,
|
||||
suggested,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
env: PythonEnv;
|
||||
suggested: string;
|
||||
}): JSX.Element {
|
||||
const outdated = env.reme?.versionOk === false;
|
||||
return (
|
||||
<>
|
||||
{env.reme?.installed ? (
|
||||
<Tag
|
||||
tone={outdated ? "warn" : "ok"}
|
||||
title={outdated ? t("upgradeRecommended") : undefined}
|
||||
>{`ReMe ${env.reme.version}`}</Tag>
|
||||
) : null}
|
||||
{env.exe === suggested ? (
|
||||
<Tag tone="brand" title={recommendReason(t, env)}>
|
||||
{t("recommended")}
|
||||
</Tag>
|
||||
) : null}
|
||||
{env.ok ? null : <Tag tone="bad">{t("tooOld")}</Tag>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label, control and hint of one settings field. */
|
||||
export function EnvField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="reme-settings-field">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
{hint ? <small>{hint}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
integrations/dsh/src/environment/client/python.tsx
Normal file
340
integrations/dsh/src/environment/client/python.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { PythonDetection, PythonEnv } from "../types.js";
|
||||
import { fill, type EnvTranslator } from "./locales.js";
|
||||
import { InterpreterBadges, originLabel } from "./presentation.js";
|
||||
import { envCall, parseDetection } from "./rpc.js";
|
||||
|
||||
const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
|
||||
|
||||
/** Interpreter rows shown before the list asks to expand. */
|
||||
const ENV_PAGE = 6;
|
||||
|
||||
export interface DetectionState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: PythonDetection;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PythonSelection {
|
||||
detection: DetectionState;
|
||||
/** Interpreter the Host recommends for this machine. */
|
||||
suggested: string;
|
||||
/** Selected interpreter from settings, or the recommendation when unset. */
|
||||
selectedPath: string;
|
||||
selectedEnv: PythonEnv | undefined;
|
||||
/** Interpreter path whose selection is being written. */
|
||||
saving: string;
|
||||
/** Probe again; the Host caches detection for a minute. */
|
||||
refresh(): void;
|
||||
select(env: PythonEnv): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interpreter section's asynchronous state: one detection probe per
|
||||
* refresh, plus the selection write that settings owns.
|
||||
*/
|
||||
export function usePython(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
pythonPath: string;
|
||||
writable: boolean;
|
||||
/** False for a remote endpoint, whose local steps are hidden. */
|
||||
enabled?: boolean;
|
||||
onSelect(exe: string): Promise<unknown>;
|
||||
},
|
||||
): PythonSelection {
|
||||
const { pythonPath, writable, enabled = true, onSelect } = options;
|
||||
const [detection, setDetection] = useState<DetectionState>({
|
||||
phase: "loading",
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
const [saving, setSaving] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"python",
|
||||
{ refresh: tick > 0 },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setDetection({ phase: "ready", data: parseDetection(value) });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setDetection((current) => ({
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
...(current.data ? { data: current.data } : {}),
|
||||
}));
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, tick, enabled]);
|
||||
|
||||
// A refresh keeps the last detection data, so the selected interpreter (and
|
||||
// the install/progress panels keyed off it) does not flash away while the
|
||||
// new probe is in flight.
|
||||
const detected = detection.data;
|
||||
const suggested = detected?.suggested ?? "";
|
||||
const selectedPath = pythonPath || suggested;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setDetection((current) => ({
|
||||
phase: "loading",
|
||||
...(current.data ? { data: current.data } : {}),
|
||||
}));
|
||||
setTick((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(env: PythonEnv) => {
|
||||
if (!writable || saving) return;
|
||||
setSaving(env.exe);
|
||||
void onSelect(env.exe)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSaving(""));
|
||||
},
|
||||
[onSelect, saving, writable],
|
||||
);
|
||||
|
||||
return {
|
||||
detection,
|
||||
suggested,
|
||||
selectedPath,
|
||||
selectedEnv: detected?.envs.find((env) => env.exe === selectedPath),
|
||||
saving,
|
||||
refresh,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
/** Step 1: the interpreter a local service runs on. */
|
||||
export function PythonSection({
|
||||
t,
|
||||
selection,
|
||||
writable,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
selection: PythonSelection;
|
||||
writable: boolean;
|
||||
}): JSX.Element {
|
||||
const { detection, suggested, selectedPath, saving } = selection;
|
||||
// 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("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const selectedEnv = selection.selectedEnv;
|
||||
const others = envs.filter((env) => env.exe !== selectedPath);
|
||||
// The selected interpreter stays in the radio group, checked and disabled,
|
||||
// so assistive technology can hear which one is current. Dropping it from the
|
||||
// group left every radio unchecked, which conveys no selection at all.
|
||||
const listed = selectedEnv ? [selectedEnv, ...others] : others;
|
||||
const filter = query.trim().toLowerCase();
|
||||
const matches = filter
|
||||
? listed.filter((env) =>
|
||||
`${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
|
||||
.toLowerCase()
|
||||
.includes(filter),
|
||||
)
|
||||
: listed;
|
||||
const visible = showAll ? matches : matches.slice(0, ENV_PAGE);
|
||||
const hidden = matches.length - visible.length;
|
||||
const withReme = envs.filter((env) => env.reme?.installed === true).length;
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("pythonTitle")}</h4>
|
||||
<p>{t("pythonHint")}</p>
|
||||
</div>
|
||||
{envs.length > 0 ? (
|
||||
<aside>
|
||||
<span className="reme-env-count">
|
||||
{fill(t("envCount"), { count: envs.length })}
|
||||
{withReme > 0
|
||||
? ` · ${fill(t("envRemeCount"), { count: withReme })}`
|
||||
: ""}
|
||||
</span>
|
||||
</aside>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
{detection.phase === "loading" ? (
|
||||
<p className="reme-settings-muted">{t("detecting")}</p>
|
||||
) : null}
|
||||
{detection.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("detectFailed")}
|
||||
{detection.error ? `: ${detection.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{(detection.phase === "error" && !detected) ||
|
||||
(detection.phase === "ready" && envs.length === 0) ? (
|
||||
<div className="reme-env-hint">
|
||||
<strong>{t("emptyTitle")}</strong>
|
||||
<span>{t("emptyHint")}</span>
|
||||
<a
|
||||
className="reme-settings-button"
|
||||
href={MINICONDA_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("miniconda")}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedEnv ? (
|
||||
<div className="reme-env-picked">
|
||||
<span className="reme-env-picked-mark" aria-hidden="true" />
|
||||
<span className="reme-env-picked-main">
|
||||
<span className="reme-env-title">
|
||||
<strong>{`Python ${selectedEnv.version || "?"}`}</strong>
|
||||
<span className="reme-env-tag">
|
||||
{originLabel(t, selectedEnv.origin)}
|
||||
</span>
|
||||
<InterpreterBadges
|
||||
t={t}
|
||||
env={selectedEnv}
|
||||
suggested={suggested}
|
||||
/>
|
||||
</span>
|
||||
<code className="reme-env-path" title={selectedEnv.exe}>
|
||||
{selectedEnv.exe}
|
||||
</code>
|
||||
</span>
|
||||
{saving === selectedEnv.exe ? (
|
||||
<span className="reme-settings-muted">
|
||||
{t("savingSelection")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : selectedPath ? (
|
||||
<div className="reme-env-picked">
|
||||
<span className="reme-env-picked-mark" aria-hidden="true" />
|
||||
<span className="reme-env-picked-main">
|
||||
<span className="reme-env-title">
|
||||
<strong>{t("currentInterpreter")}</strong>
|
||||
<span className="reme-env-tag bad">{t("notInDetection")}</span>
|
||||
</span>
|
||||
<code className="reme-env-path" title={selectedPath}>
|
||||
{selectedPath}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<div className="reme-env-picker">
|
||||
<div className="reme-env-picker-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setListOpen((open) => !open)}
|
||||
>
|
||||
{t(listOpen ? "collapseList" : "changeInterpreter")}
|
||||
</button>
|
||||
{listOpen ? (
|
||||
<input
|
||||
className="reme-env-search"
|
||||
value={query}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setShowAll(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="reme-settings-muted">
|
||||
{fill(t("envOthers"), { count: others.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{listOpen ? (
|
||||
<>
|
||||
{visible.length > 0 ? (
|
||||
<div
|
||||
className="reme-env-options"
|
||||
role="radiogroup"
|
||||
aria-label={t("pythonTitle")}
|
||||
>
|
||||
{visible.map((env) => (
|
||||
<label key={env.exe} className="reme-env-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="reme-python-interpreter"
|
||||
checked={env.exe === selectedPath}
|
||||
disabled={
|
||||
env.exe === selectedPath ||
|
||||
!env.ok ||
|
||||
saving !== "" ||
|
||||
!writable
|
||||
}
|
||||
onChange={() => {
|
||||
selection.select(env);
|
||||
setListOpen(false);
|
||||
}}
|
||||
/>
|
||||
<strong>{`Python ${env.version || "?"}`}</strong>
|
||||
<em>
|
||||
{env.exe === selectedPath
|
||||
? t("currentInterpreter")
|
||||
: originLabel(t, env.origin)}
|
||||
</em>
|
||||
<InterpreterBadges
|
||||
t={t}
|
||||
env={env}
|
||||
suggested={suggested}
|
||||
/>
|
||||
<code className="reme-env-option-path" title={env.exe}>
|
||||
{env.exe}
|
||||
</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="reme-settings-muted">{t("noMatch")}</p>
|
||||
)}
|
||||
{hidden > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset reme-env-more"
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{fill(t("showAllEnvs"), { count: matches.length })}
|
||||
</button>
|
||||
) : matches.length > ENV_PAGE ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset reme-env-more"
|
||||
onClick={() => setShowAll(false)}
|
||||
>
|
||||
{t("showFewer")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detected && detected.errors.length > 0 ? (
|
||||
<details className="reme-settings-details">
|
||||
<summary>{t("errorsTitle")}</summary>
|
||||
<pre>{detected.errors.join("\n")}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
300
integrations/dsh/src/environment/client/rpc.ts
Normal file
300
integrations/dsh/src/environment/client/rpc.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import type {
|
||||
ConnectionHealth,
|
||||
EnvSummary,
|
||||
InstallStatus,
|
||||
LlmTestResult,
|
||||
PythonDetection,
|
||||
RemeCheck,
|
||||
ServiceForgetResult,
|
||||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
|
||||
/** Methods the Host exposes on the `remeEnv` wire namespace. */
|
||||
export type EnvMethod =
|
||||
| "python"
|
||||
| "checkReme"
|
||||
| "install"
|
||||
| "installStatus"
|
||||
| "installCancel"
|
||||
| "readEnv"
|
||||
| "writeEnv"
|
||||
| "testLlm"
|
||||
| "health"
|
||||
| "serviceStatus"
|
||||
| "serviceStart"
|
||||
| "serviceStop"
|
||||
| "serviceForget"
|
||||
| "serviceLog";
|
||||
|
||||
/**
|
||||
* Call one environment method. DSH exposes the SRC gateway with a single plain
|
||||
* `input` parameter, so every call sends `{ args: { input } }`.
|
||||
*/
|
||||
export async function envCall(
|
||||
rpc: StatusRpc,
|
||||
method: EnvMethod,
|
||||
input: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
`remeEnv/${method}`,
|
||||
{ args: { input } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.message || result.error.code);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function record(value: unknown, what: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new Error(`Invalid ${what} response`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function fail(what: string): never {
|
||||
throw new Error(`Invalid ${what} response`);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): string {
|
||||
const value = body[key];
|
||||
if (typeof value !== "string") fail(what);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): boolean {
|
||||
const value = body[key];
|
||||
if (typeof value !== "boolean") fail(what);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): void {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record a refused action was about. The Host names it so the client can
|
||||
* offer a recovery for that record, which is not necessarily the endpoint the
|
||||
* request was made for.
|
||||
*/
|
||||
function optionalRecordField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): void {
|
||||
const value = body[key];
|
||||
if (value === undefined) return;
|
||||
const entry = record(value, what);
|
||||
stringField(entry, "endpoint", what);
|
||||
optionalPidField(entry, "pid", what);
|
||||
}
|
||||
|
||||
function arrayField(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
what: string,
|
||||
): unknown[] {
|
||||
const value = body[key];
|
||||
if (!Array.isArray(value)) fail(what);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard a detection payload. The renderers dereference `errors`, `origin` and
|
||||
* the per-interpreter fields, so a Host contract drift must surface as an
|
||||
* error in the section, not as a blank page.
|
||||
*/
|
||||
export function parseDetection(value: unknown): PythonDetection {
|
||||
const what = "environment detection";
|
||||
const body = record(value, what);
|
||||
stringField(body, "platform", what);
|
||||
stringField(body, "scannedAt", what);
|
||||
stringField(body, "suggested", what);
|
||||
stringField(body, "condaRoot", what);
|
||||
arrayField(body, "errors", what);
|
||||
for (const item of arrayField(body, "envs", what)) {
|
||||
const env = record(item, what);
|
||||
stringField(env, "exe", what);
|
||||
stringField(env, "version", what);
|
||||
booleanField(env, "ok", 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;
|
||||
}
|
||||
|
||||
export function parseRemeCheck(value: unknown): RemeCheck {
|
||||
const what = "ReMe check";
|
||||
const body = record(value, what);
|
||||
booleanField(body, "installed", what);
|
||||
booleanField(body, "versionOk", what);
|
||||
booleanField(body, "hasCore", what);
|
||||
stringField(body, "version", what);
|
||||
stringField(body, "cliPath", what);
|
||||
stringField(body, "error", what);
|
||||
return value as unknown as RemeCheck;
|
||||
}
|
||||
|
||||
/** `null` is the valid "no job yet" answer, so it is not an error. */
|
||||
export function parseInstallStatus(value: unknown): InstallStatus | null {
|
||||
if (value === null) return null;
|
||||
const what = "install status";
|
||||
const body = record(value, what);
|
||||
stringField(body, "id", what);
|
||||
stringField(body, "pythonPath", what);
|
||||
stringField(body, "startedAt", what);
|
||||
stringField(body, "logPath", what);
|
||||
stringField(body, "logTail", what);
|
||||
if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
|
||||
const phase = stringField(body, "phase", what);
|
||||
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
|
||||
fail(what);
|
||||
return value as unknown as InstallStatus;
|
||||
}
|
||||
|
||||
export function parseEnvSummary(value: unknown): EnvSummary {
|
||||
const what = ".env summary";
|
||||
const body = record(value, what);
|
||||
stringField(body, "workspaceDir", what);
|
||||
stringField(body, "envPath", what);
|
||||
booleanField(body, "exists", what);
|
||||
for (const item of arrayField(body, "entries", what)) {
|
||||
const entry = record(item, what);
|
||||
stringField(entry, "key", what);
|
||||
booleanField(entry, "secret", what);
|
||||
booleanField(entry, "set", what);
|
||||
stringField(entry, "masked", what);
|
||||
stringField(entry, "value", what);
|
||||
}
|
||||
return value as unknown as EnvSummary;
|
||||
}
|
||||
|
||||
export function parseLlmResult(value: unknown): LlmTestResult {
|
||||
const what = "LLM test";
|
||||
const body = record(value, what);
|
||||
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;
|
||||
}
|
||||
|
||||
export function parseHealth(value: unknown): ConnectionHealth {
|
||||
const what = "health";
|
||||
const body = record(value, what);
|
||||
booleanField(body, "ok", what);
|
||||
stringField(body, "version", what);
|
||||
stringField(body, "error", what);
|
||||
stringField(body, "endpoint", what);
|
||||
optionalStringField(body, "code", what);
|
||||
return value as unknown as ConnectionHealth;
|
||||
}
|
||||
|
||||
export function parseServiceState(value: unknown): ServiceState {
|
||||
const what = "service status";
|
||||
const body = record(value, what);
|
||||
const phase = stringField(body, "phase", what);
|
||||
if (!["running", "stopped", "conflict"].includes(phase)) fail(what);
|
||||
booleanField(body, "managed", what);
|
||||
stringField(body, "endpoint", what);
|
||||
stringField(body, "logPath", what);
|
||||
optionalPidField(body, "pid", what);
|
||||
optionalStringField(body, "version", what);
|
||||
optionalStringField(body, "startedAt", what);
|
||||
optionalRecordField(body, "record", what);
|
||||
return value as unknown as ServiceState;
|
||||
}
|
||||
|
||||
export function parseServiceStart(value: unknown): ServiceStartResult {
|
||||
const what = "service start";
|
||||
const body = record(value, what);
|
||||
booleanField(body, "started", what);
|
||||
booleanField(body, "alreadyRunning", what);
|
||||
stringField(body, "command", what);
|
||||
stringField(body, "logPath", what);
|
||||
stringField(body, "startedAt", what);
|
||||
optionalStringField(body, "error", what);
|
||||
optionalStringField(body, "code", what);
|
||||
optionalStringField(body, "version", what);
|
||||
optionalPidField(body, "pid", what);
|
||||
optionalRecordField(body, "record", what);
|
||||
return value as unknown as ServiceStartResult;
|
||||
}
|
||||
|
||||
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);
|
||||
optionalPidField(body, "pid", what);
|
||||
optionalRecordField(body, "record", what);
|
||||
return value as unknown as ServiceStopResult;
|
||||
}
|
||||
|
||||
export function parseServiceForget(value: unknown): ServiceForgetResult {
|
||||
const what = "service forget";
|
||||
const body = record(value, what);
|
||||
booleanField(body, "forgotten", what);
|
||||
optionalStringField(body, "reason", what);
|
||||
optionalStringField(body, "code", what);
|
||||
optionalPidField(body, "pid", what);
|
||||
optionalRecordField(body, "record", what);
|
||||
return value as unknown as ServiceForgetResult;
|
||||
}
|
||||
|
||||
export function parseServiceLog(value: unknown): ServiceLog {
|
||||
const what = "service log";
|
||||
const body = record(value, what);
|
||||
stringField(body, "lines", what);
|
||||
stringField(body, "logPath", what);
|
||||
return value as unknown as ServiceLog;
|
||||
}
|
||||
851
integrations/dsh/src/environment/client/service.tsx
Normal file
851
integrations/dsh/src/environment/client/service.tsx
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { serviceEndpointKey } from "../endpoint-key.js";
|
||||
import { canHostService } from "../dependency.js";
|
||||
import type {
|
||||
HealthErrorCode,
|
||||
RemeCheck,
|
||||
ServiceRecordRef,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
} from "../types.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import {
|
||||
envCall,
|
||||
parseHealth,
|
||||
parseServiceForget,
|
||||
parseServiceLog,
|
||||
parseServiceStart,
|
||||
parseServiceState,
|
||||
parseServiceStop,
|
||||
} from "./rpc.js";
|
||||
|
||||
/**
|
||||
* How long a detached start may take to answer `/health_check`. Kept at the
|
||||
* Host's start grace period, so the client never gives up while the Host still
|
||||
* considers the launch in flight.
|
||||
*/
|
||||
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",
|
||||
"unverified-record": "startErrorUnverifiedRecord",
|
||||
"no-python": "startErrorNoPython",
|
||||
"not-installed": "startErrorNotInstalled",
|
||||
"version-too-old": "startErrorVersionTooOld",
|
||||
"start-failed": "startErrorFailed",
|
||||
};
|
||||
|
||||
function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
|
||||
// The unverified record carries the pid it refused to touch, so the message
|
||||
// can tell the user what to look for instead of a generic sentence.
|
||||
if (result.code === "unverified-record" && result.pid !== undefined)
|
||||
return fill(t("startErrorUnverifiedRecordPid"), { pid: result.pid });
|
||||
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",
|
||||
"record-changed": "stopRecordChanged",
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose refusal leaves the record as the only way out: Start and Stop
|
||||
* both refuse to act on such a record, so it can only be forgotten.
|
||||
*/
|
||||
const RECORD_ONLY_CODES = new Set([
|
||||
"unverified-record",
|
||||
"previous-not-stopped",
|
||||
"unverified",
|
||||
"not-exited",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The recovery a refused action allows.
|
||||
*
|
||||
* The Host names the record it refused on: that record may belong to another
|
||||
* endpoint than the one on screen, and forgetting the endpoint on screen
|
||||
* would be refused as foreign. A refusal that names no record (a conflict, a
|
||||
* missing interpreter) has no record-only way out. An older Host names no
|
||||
* record either, so the endpoint on screen stays the fallback.
|
||||
*/
|
||||
function recoveryFor(
|
||||
result: { code?: string; record?: ServiceRecordRef },
|
||||
endpoint: string,
|
||||
): ServiceRecordRef | undefined {
|
||||
if (!result.code || !RECORD_ONLY_CODES.has(result.code)) return undefined;
|
||||
return result.record ?? { endpoint };
|
||||
}
|
||||
|
||||
export interface ServiceViewState {
|
||||
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
|
||||
data?: ServiceState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ServiceLogView {
|
||||
open: boolean;
|
||||
lines: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface ServiceControl {
|
||||
state: ServiceViewState;
|
||||
stopping: boolean;
|
||||
/** Why the last Stop request left the service alone, if it did. */
|
||||
stopError: string;
|
||||
/**
|
||||
* The record the Host refused to act on. It can only be forgotten, and
|
||||
* forgetting targets this record, not the endpoint on screen.
|
||||
*/
|
||||
forgetTarget?: ServiceRecordRef;
|
||||
forgetting: boolean;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
forget(): Promise<void>;
|
||||
refresh(): void;
|
||||
log: ServiceLogView;
|
||||
viewLog(): Promise<void>;
|
||||
dismissLog(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service section's asynchronous state, keyed by endpoint: one status read
|
||||
* per endpoint, plus the start and stop operations for the managed instance.
|
||||
*/
|
||||
export function useService(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
t: EnvTranslator;
|
||||
endpoint: string;
|
||||
/** Only a loopback endpoint can be managed, so remote ones are read-only. */
|
||||
local: boolean;
|
||||
pythonPath: string;
|
||||
workspaceDir: string;
|
||||
authToken: string;
|
||||
},
|
||||
): ServiceControl {
|
||||
const { t, endpoint, local, pythonPath, workspaceDir, authToken } = options;
|
||||
const [state, setState] = useState<ServiceViewState>({ phase: "checking" });
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [stopError, setStopError] = useState("");
|
||||
const [forgetTarget, setForgetTarget] = useState<
|
||||
ServiceRecordRef | undefined
|
||||
>(undefined);
|
||||
const [forgetting, setForgetting] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [log, setLog] = useState<ServiceLogView>({
|
||||
open: false,
|
||||
lines: "",
|
||||
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);
|
||||
setForgetTarget(undefined);
|
||||
setForgetting(false);
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => () => startController.current?.abort(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!local || !endpoint) return;
|
||||
const controller = new AbortController();
|
||||
const request = statusSeq.current + 1;
|
||||
statusSeq.current = request;
|
||||
void (async () => {
|
||||
try {
|
||||
const next = parseServiceState(
|
||||
await envCall(
|
||||
rpc,
|
||||
"serviceStatus",
|
||||
{ endpoint, authToken },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted || request !== statusSeq.current) return;
|
||||
// The record is gone (dead pid cleared by the Host, or just forgotten),
|
||||
// so there is nothing left to forget. This status read only covers the
|
||||
// endpoint on screen, so a record for another endpoint stays.
|
||||
if (next.phase === "stopped" && next.pid === undefined)
|
||||
setForgetTarget((current) =>
|
||||
current &&
|
||||
serviceEndpointKey(current.endpoint) ===
|
||||
serviceEndpointKey(endpoint)
|
||||
? undefined
|
||||
: current,
|
||||
);
|
||||
// A record the Host could not verify is the only way out of a port it
|
||||
// cannot clear, and the conflict card offers no other action: the
|
||||
// status names it, so the recovery is offered without waiting for a
|
||||
// refused start the card does not allow.
|
||||
const unverified = next.record;
|
||||
if (unverified)
|
||||
setForgetTarget((current) =>
|
||||
current &&
|
||||
current.endpoint === unverified.endpoint &&
|
||||
current.pid === unverified.pid
|
||||
? current
|
||||
: {
|
||||
endpoint: unverified.endpoint,
|
||||
...(unverified.pid === undefined
|
||||
? {}
|
||||
: { pid: unverified.pid }),
|
||||
},
|
||||
);
|
||||
// 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 &&
|
||||
startInFlight.current
|
||||
? "starting"
|
||||
: next.phase,
|
||||
data: next,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || request !== statusSeq.current) return;
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, endpoint, local, authToken, tick]);
|
||||
|
||||
const refresh = useCallback(() => setTick((current) => current + 1), []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!pythonPath || state.phase === "starting") return;
|
||||
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: "" });
|
||||
try {
|
||||
const started = parseServiceStart(
|
||||
await envCall(
|
||||
rpc,
|
||||
"serviceStart",
|
||||
{ endpoint, pythonPath, workspaceDir, authToken },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (started.error) {
|
||||
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 {
|
||||
// A switch of endpoint aborts this call; its error belongs to the
|
||||
// old endpoint and must not be painted (or bump the sequence) on
|
||||
// the new card.
|
||||
if (controller.signal.aborted) return;
|
||||
// Otherwise fall back to the reported start error.
|
||||
}
|
||||
}
|
||||
// The Host refused because the record describes a live process it
|
||||
// cannot stop or verify. Neither Start nor Stop can act on it, so offer
|
||||
// the record-only escape hatch, aimed at the record the Host named.
|
||||
// A refusal that names no record leaves an existing offer alone: the
|
||||
// record it describes is still there.
|
||||
const recovery = recoveryFor(started, endpoint);
|
||||
if (recovery) setForgetTarget(recovery);
|
||||
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) {
|
||||
// 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 {
|
||||
const status = parseServiceState(
|
||||
await envCall(
|
||||
rpc,
|
||||
"serviceStatus",
|
||||
{ endpoint, authToken },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (status.phase === "running") {
|
||||
setForgetTarget(undefined);
|
||||
applyState({ phase: "running", data: status });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "conflict") {
|
||||
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.
|
||||
applyState({ phase: "error", error: t("startFailedStopped") });
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
applyState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
if (startController.current === controller) {
|
||||
startController.current = undefined;
|
||||
startInFlight.current = false;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
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: target }),
|
||||
);
|
||||
if (endpointRef.current !== target) return;
|
||||
setStopError(result.stopped ? "" : stopReasonText(t, result));
|
||||
// A record the Host refuses to stop (unverifiable, or a process that
|
||||
// survived the force-kill) cannot be cleared by either button: offer the
|
||||
// record-only way out instead of leaving two actions that both fail.
|
||||
const recovery = recoveryFor(result, target);
|
||||
if (recovery) setForgetTarget(recovery);
|
||||
// A stopped service has no record left; a refusal that names none (a
|
||||
// foreign record, a missing one) leaves an existing offer alone.
|
||||
else if (result.stopped || result.code === "no-record")
|
||||
setForgetTarget(undefined);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
if (endpointRef.current !== target) return;
|
||||
applyState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
// 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);
|
||||
}
|
||||
}, [applyState, endpoint, refresh, rpc, stopping, t]);
|
||||
|
||||
const forget = useCallback(async () => {
|
||||
if (forgetting || !forgetTarget) return;
|
||||
const card = endpoint;
|
||||
// The record is named by the Host, not by the endpoint on screen, so the
|
||||
// request carries the record's own endpoint and the pid it described.
|
||||
const target = forgetTarget;
|
||||
setForgetting(true);
|
||||
try {
|
||||
const result = parseServiceForget(
|
||||
await envCall(rpc, "serviceForget", {
|
||||
endpoint: target.endpoint,
|
||||
...(target.pid === undefined ? {} : { expectPid: target.pid }),
|
||||
}),
|
||||
);
|
||||
if (endpointRef.current !== card) return;
|
||||
if (result.forgotten) {
|
||||
setForgetTarget(undefined);
|
||||
setStopError("");
|
||||
// Drop the stale pid immediately; the refresh confirms it.
|
||||
applyState({ phase: "checking" });
|
||||
refresh();
|
||||
} else {
|
||||
setStopError(stopReasonText(t, result));
|
||||
if (result.code === "no-record") {
|
||||
// The record is already gone: there is nothing left to forget, so the
|
||||
// button must not offer an action that can never succeed.
|
||||
setForgetTarget(undefined);
|
||||
} else if (result.code === "record-changed") {
|
||||
// The record the user confirmed is not the one that is there now.
|
||||
// The offer is dropped instead of being re-aimed at a record the
|
||||
// user never saw: the next refusal names the current one.
|
||||
setForgetTarget(undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (endpointRef.current !== card) return;
|
||||
applyState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
if (endpointRef.current === card) setForgetting(false);
|
||||
}
|
||||
}, [applyState, endpoint, forgetTarget, forgetting, refresh, rpc, t]);
|
||||
|
||||
const viewLog = useCallback(async () => {
|
||||
try {
|
||||
const value = parseServiceLog(
|
||||
await envCall(rpc, "serviceLog", { workspaceDir }),
|
||||
);
|
||||
setLog({ open: true, lines: value.lines, logPath: value.logPath });
|
||||
} catch {
|
||||
// Log viewing is diagnostic only.
|
||||
}
|
||||
}, [rpc, workspaceDir]);
|
||||
|
||||
const dismissLog = useCallback(
|
||||
() => setLog({ open: false, lines: "", logPath: "" }),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
stopping,
|
||||
stopError,
|
||||
forgetTarget,
|
||||
forgetting,
|
||||
start,
|
||||
stop,
|
||||
forget,
|
||||
refresh,
|
||||
log,
|
||||
viewLog,
|
||||
dismissLog,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
phase: "loading" | "ok" | "fail";
|
||||
endpoint: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
/** Stable Host reason, localized by the hero instead of showing `error`. */
|
||||
code?: HealthErrorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of a ReMe service that this plugin cannot manage locally (a remote
|
||||
* URL), so the hero still reports whether the configured endpoint answers.
|
||||
*/
|
||||
export function useHealth(options: {
|
||||
rpc: StatusRpc;
|
||||
endpoint: string;
|
||||
enabled: boolean;
|
||||
tick: number;
|
||||
authToken: string;
|
||||
}): ConnectionState {
|
||||
const { rpc, endpoint, enabled, tick, authToken } = options;
|
||||
const [health, setHealth] = useState<ConnectionState>({
|
||||
phase: "loading",
|
||||
endpoint: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !endpoint) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = parseHealth(
|
||||
await envCall(
|
||||
rpc,
|
||||
"health",
|
||||
{ endpoint, authToken },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setHealth(
|
||||
result.ok
|
||||
? { phase: "ok", endpoint, version: result.version }
|
||||
: {
|
||||
phase: "fail",
|
||||
endpoint,
|
||||
error: result.error,
|
||||
...(result.code === undefined ? {} : { code: result.code }),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setHealth({ phase: "fail", endpoint, error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [authToken, endpoint, enabled, rpc, tick]);
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Step 4: the local ReMe service that DSH talks to. */
|
||||
export function ServiceSection({
|
||||
t,
|
||||
endpoint,
|
||||
pythonPath,
|
||||
control,
|
||||
remeCheck,
|
||||
autoStart,
|
||||
writable,
|
||||
current,
|
||||
onAutoStart,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
endpoint: string;
|
||||
pythonPath: string;
|
||||
control: ServiceControl;
|
||||
/** What the selected interpreter has; the shared rule decides a start. */
|
||||
remeCheck: RemeCheck | undefined;
|
||||
autoStart: boolean;
|
||||
writable: boolean;
|
||||
/** False while the state on screen belongs to another endpoint. */
|
||||
current: boolean;
|
||||
onAutoStart(checked: boolean): void;
|
||||
}): JSX.Element {
|
||||
const { state, stopping, stopError, forgetTarget, forgetting, log } = control;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [forgetConfirm, setForgetConfirm] = useState(false);
|
||||
// The confirmation belongs to one phase and endpoint: keeping it open across
|
||||
// a transition would re-show "Stop?" on top of a live service later.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setConfirming(false);
|
||||
setForgetConfirm(false);
|
||||
}, [endpoint, state.phase, state.data?.pid, forgetTarget]);
|
||||
// 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. `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 === "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);
|
||||
// A stale record may be a recycled pid that now belongs to a foreign
|
||||
// process. Stop then refuses, so Start must stay available: the Host stops a
|
||||
// provably owned previous launch, discards a foreign record, and reports an
|
||||
// unverifiable one. Hiding Start would leave that card without a way out.
|
||||
const canRestart = state.phase === "stopped" && state.data?.pid !== undefined;
|
||||
// The record can only be forgotten when the Host named one: it is the
|
||||
// record-only way out of a process the Host refuses to signal.
|
||||
const canForget = current && forgetTarget !== undefined;
|
||||
// The confirmation names the record, so the user sees what they are about
|
||||
// to delete and not only where it lives.
|
||||
const forgetText = forgetTarget
|
||||
? `${fill(t("forgetConfirmRecord"), { endpoint: forgetTarget.endpoint })}${
|
||||
forgetTarget.pid === undefined
|
||||
? ""
|
||||
: ` ${fill(t("forgetConfirmPid"), { pid: forgetTarget.pid })}`
|
||||
}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("controlTitle")}</h4>
|
||||
<p>{t("serviceHint")}</p>
|
||||
</div>
|
||||
<aside>
|
||||
{!current ||
|
||||
state.phase === "checking" ||
|
||||
state.phase === "starting" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t(
|
||||
!current
|
||||
? "checking"
|
||||
: state.phase === "starting"
|
||||
? "serviceBadgeStarting"
|
||||
: "checking",
|
||||
)}
|
||||
</StatusBadge>
|
||||
) : state.phase === "running" ? (
|
||||
<StatusBadge tone="ok">
|
||||
{state.data?.version
|
||||
? `ReMe ${state.data.version}`
|
||||
: t("serviceRunning")}
|
||||
</StatusBadge>
|
||||
) : state.phase === "conflict" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t(
|
||||
state.data?.managed
|
||||
? "serviceBadgeUnhealthy"
|
||||
: "serviceBadgeConflict",
|
||||
)}
|
||||
</StatusBadge>
|
||||
) : state.phase === "stopped" && state.data?.pid !== undefined ? (
|
||||
<StatusBadge tone="progress">{t("serviceBadgeStale")}</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
||||
)}
|
||||
</aside>
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
<div className="reme-env-factrow">
|
||||
<span className="reme-env-field-label">{t("endpointLabel")}</span>
|
||||
<code className="reme-env-path" title={endpoint}>
|
||||
{endpoint}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={control.refresh}
|
||||
>
|
||||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{current &&
|
||||
(state.phase === "running" || state.phase === "conflict") ? (
|
||||
<p className="reme-settings-muted">
|
||||
{state.data?.managed
|
||||
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
|
||||
: state.phase === "running"
|
||||
? t("foreignNote")
|
||||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
{current && state.phase === "conflict" ? (
|
||||
<p className="reme-settings-muted">
|
||||
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
|
||||
</p>
|
||||
) : null}
|
||||
{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">
|
||||
{stopError}
|
||||
</p>
|
||||
) : null}
|
||||
{current && canForget ? (
|
||||
<p className="reme-settings-muted">{t("forgetHint")}</p>
|
||||
) : null}
|
||||
<div className="reme-env-cta">
|
||||
{canStop ? (
|
||||
confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>{t("stopConfirm")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={stopping}
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
void control.stop();
|
||||
}}
|
||||
>
|
||||
{t("stopService")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={stopping || !canSignal}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("stopService")}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{!canStop || canRestart ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
!current ||
|
||||
state.phase === "starting" ||
|
||||
state.phase === "conflict" ||
|
||||
!canHostService(remeCheck) ||
|
||||
!pythonPath
|
||||
}
|
||||
onClick={() => void control.start()}
|
||||
>
|
||||
{t("startService")}
|
||||
</button>
|
||||
) : null}
|
||||
{current && canForget ? (
|
||||
forgetConfirm ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>{forgetText}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={forgetting}
|
||||
onClick={() => {
|
||||
setForgetConfirm(false);
|
||||
void control.forget();
|
||||
}}
|
||||
>
|
||||
{t("forgetRecord")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setForgetConfirm(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={forgetting}
|
||||
onClick={() => setForgetConfirm(true)}
|
||||
>
|
||||
{t("forgetRecord")}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => void control.viewLog()}
|
||||
>
|
||||
{t("viewLogs")}
|
||||
</button>
|
||||
<label className="reme-settings-check reme-env-autostart">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoStart}
|
||||
disabled={!writable}
|
||||
onChange={(event) => {
|
||||
if (!writable) return;
|
||||
onAutoStart(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>{t("autoStartLabel")}</span>
|
||||
</label>
|
||||
</div>
|
||||
{log.open ? (
|
||||
<div className="reme-env-result">
|
||||
<p className="reme-settings-muted">
|
||||
{`${t("envPath")}: ${log.logPath}`}
|
||||
</p>
|
||||
<pre className="reme-env-log">{log.lines || t("logEmpty")}</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={control.dismissLog}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
integrations/dsh/src/environment/client/styles.ts
Normal file
84
integrations/dsh/src/environment/client/styles.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/** Styles for the Environment tab: the hero, the step cards, and the .env form. */
|
||||
export const environmentStyles = `
|
||||
.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
|
||||
.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
|
||||
.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
|
||||
.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
|
||||
.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
|
||||
.reme-env-hero-copy{min-width:0}
|
||||
.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
|
||||
.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
|
||||
.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
|
||||
.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
||||
.reme-env-hero-error,.reme-env-notice{margin:0}
|
||||
.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
|
||||
.reme-env-card.collapsed>header{padding-bottom:14px}
|
||||
.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.reme-env-card>header>div{min-width:0;flex:1}
|
||||
.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
|
||||
.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
|
||||
.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
|
||||
.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
|
||||
.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
|
||||
.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
|
||||
.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
|
||||
.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
|
||||
.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
|
||||
.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
|
||||
.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
|
||||
.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-note{margin:0}
|
||||
.reme-env-picker{display:flex;flex-direction:column;gap:9px}
|
||||
.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.reme-env-picker-bar .reme-settings-muted{margin:0}
|
||||
.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
|
||||
.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
|
||||
.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
|
||||
.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
|
||||
.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
|
||||
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
|
||||
.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
|
||||
.reme-env-factrow{display:flex;align-items:center;gap:9px}
|
||||
.reme-env-factrow .reme-env-path{flex:1}
|
||||
.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
|
||||
.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
||||
.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
|
||||
.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
|
||||
.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
|
||||
.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
|
||||
.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
|
||||
.reme-env-metric .reme-status-badge i{margin-top:5px}
|
||||
.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
|
||||
.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
|
||||
.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
|
||||
.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-progress{display:flex;flex-direction:column;gap:8px}
|
||||
.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-result p{margin:0}
|
||||
.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-inline{display:flex;align-items:center;gap:8px}
|
||||
.reme-env-inline input{flex:1;min-width:0}
|
||||
.reme-env-config{padding:2px 0 0}
|
||||
.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
|
||||
.reme-env .reme-settings-details{margin:0}
|
||||
.reme-env .reme-settings-details pre{margin:8px 0 0}
|
||||
@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
|
||||
`;
|
||||
340
integrations/dsh/src/environment/client/tab.tsx
Normal file
340
integrations/dsh/src/environment/client/tab.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useState, useSyncExternalStore } from "react";
|
||||
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 { serviceEndpointKey } from "../endpoint-key.js";
|
||||
import { isLocalEndpoint } from "../endpoint-rules.js";
|
||||
import { hasCoreFeatures } from "../dependency.js";
|
||||
import type { HealthErrorCode, RemeCheck } from "../types.js";
|
||||
import type { ReMeSettings } from "../../types.js";
|
||||
import { DependencySection, useDependency } from "./dependency.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 {
|
||||
ServiceSection,
|
||||
useHealth,
|
||||
useService,
|
||||
type ConnectionState,
|
||||
} from "./service.js";
|
||||
import { WorkspaceSection } from "./workspace.js";
|
||||
|
||||
export { envEn, envZh } from "./locales.js";
|
||||
export type { EnvTranslator } from "./locales.js";
|
||||
|
||||
export interface ReMeEnvironmentTabProps {
|
||||
scope: ConfigForm<ReMeSettings>;
|
||||
rpc: StatusRpc;
|
||||
t: EnvTranslator;
|
||||
}
|
||||
|
||||
function connectionBadge(
|
||||
t: EnvTranslator,
|
||||
connection: ConnectionState,
|
||||
current: boolean,
|
||||
): Badge {
|
||||
const label = (detail: string) => `${t("heroService")} · ${detail}`;
|
||||
if (!current || connection.phase === "loading")
|
||||
return { tone: "", text: label(t("checking")) };
|
||||
if (connection.phase === "ok")
|
||||
return {
|
||||
tone: "ok",
|
||||
text: label(
|
||||
connection.version ? `ReMe ${connection.version}` : t("connected"),
|
||||
),
|
||||
};
|
||||
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)
|
||||
return {
|
||||
tone: "",
|
||||
text: `${t("heroPython")} · ${t(
|
||||
selection.detection.phase === "error" ? "noneDetected" : "noneSelected",
|
||||
)}`,
|
||||
};
|
||||
return {
|
||||
tone: env.ok ? "ok" : "progress",
|
||||
text: `${t("heroPython")} ${env.version || "?"}`,
|
||||
};
|
||||
}
|
||||
|
||||
function remeSummaryBadge(
|
||||
t: EnvTranslator,
|
||||
check: RemeCheck | undefined,
|
||||
checking: boolean,
|
||||
): Badge {
|
||||
if (!check)
|
||||
return {
|
||||
tone: "",
|
||||
text: `${t("heroReme")} · ${t(checking ? "checking" : "noneDetected")}`,
|
||||
};
|
||||
if (!check.installed)
|
||||
return {
|
||||
tone: "progress",
|
||||
text: `${t("heroReme")} · ${t("notInstalled")}`,
|
||||
};
|
||||
return {
|
||||
tone: check.versionOk && check.hasCore ? "ok" : "progress",
|
||||
text: `${t("heroReme")} ${check.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Environment tab: one hero with the local-runtime summary, then the four
|
||||
* steps. Each step owns its own asynchronous state; this component only reads
|
||||
* the settings document and lays the sections out.
|
||||
*/
|
||||
export function ReMeEnvironmentTab({
|
||||
scope,
|
||||
rpc,
|
||||
t,
|
||||
}: ReMeEnvironmentTabProps): JSX.Element | null {
|
||||
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);
|
||||
const workspaceDir = value?.remeWorkspaceDir ?? "";
|
||||
const authToken = value?.authToken ?? "";
|
||||
const [heroTick, setHeroTick] = useState(0);
|
||||
// Settings writes are invisible to the sections otherwise: a rejected
|
||||
// revision or a transport error would silently roll the control back. The
|
||||
// message carries its endpoint so a change of URL drops it without an effect.
|
||||
const [settingsError, setSettingsError] = useState<{
|
||||
endpoint: string;
|
||||
message: string;
|
||||
}>({ endpoint: "", message: "" });
|
||||
|
||||
const writeSetting = useCallback(
|
||||
async (field: string, next: unknown): Promise<boolean> => {
|
||||
if (!snapshot.writable) return false;
|
||||
try {
|
||||
const applied = await scope.mutate(
|
||||
[{ op: "set", path: [field], value: next }],
|
||||
snapshot.revision,
|
||||
);
|
||||
if (applied === false) {
|
||||
setSettingsError({ endpoint, message: t("settingsWriteFailed") });
|
||||
return false;
|
||||
}
|
||||
setSettingsError({ endpoint, message: "" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setSettingsError({ endpoint, message: safeError(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[endpoint, scope, snapshot.revision, snapshot.writable, t],
|
||||
);
|
||||
const settingsErrorText =
|
||||
settingsError.endpoint === endpoint ? settingsError.message : "";
|
||||
|
||||
const python = usePython(rpc, {
|
||||
pythonPath: value?.pythonPath ?? "",
|
||||
writable: snapshot.writable,
|
||||
enabled: local,
|
||||
onSelect: (exe) => writeSetting("pythonPath", exe),
|
||||
});
|
||||
const dependency = useDependency(rpc, {
|
||||
pythonPath: python.selectedPath,
|
||||
enabled: local,
|
||||
onInstalled: () => {
|
||||
python.refresh();
|
||||
},
|
||||
});
|
||||
const service = useService(rpc, {
|
||||
t,
|
||||
endpoint,
|
||||
local,
|
||||
pythonPath: python.selectedPath,
|
||||
workspaceDir,
|
||||
authToken,
|
||||
});
|
||||
// One health source per endpoint: the service probe already covers a local
|
||||
// endpoint, so only a remote URL needs its own check. Both run on the Host,
|
||||
// so the browser cannot disagree with it about what "local" means.
|
||||
const remote = useHealth({
|
||||
rpc,
|
||||
endpoint,
|
||||
enabled: !local,
|
||||
tick: heroTick,
|
||||
authToken,
|
||||
});
|
||||
// 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 =
|
||||
serviceEndpointKey(serviceEndpoint) === serviceEndpointKey(endpoint);
|
||||
const connection: ConnectionState = local
|
||||
? service.state.phase === "running"
|
||||
? {
|
||||
phase: "ok",
|
||||
endpoint: serviceEndpoint,
|
||||
version: service.state.data?.version ?? "",
|
||||
}
|
||||
: service.state.phase === "checking" || service.state.phase === "starting"
|
||||
? { phase: "loading", endpoint: serviceEndpoint }
|
||||
: {
|
||||
phase: "fail",
|
||||
endpoint: serviceEndpoint,
|
||||
error: service.state.error,
|
||||
}
|
||||
: remote;
|
||||
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
|
||||
const current =
|
||||
connection.phase !== "loading" &&
|
||||
serviceEndpointKey(connection.endpoint) === serviceEndpointKey(endpoint);
|
||||
const connectionError = healthErrorText(t, connection);
|
||||
const ready =
|
||||
current &&
|
||||
connection.phase === "ok" &&
|
||||
(!local || hasCoreFeatures(dependency.checkData));
|
||||
const scannedAt = python.detection.data?.scannedAt
|
||||
? Date.parse(python.detection.data.scannedAt)
|
||||
: Number.NaN;
|
||||
const scannedTime = Number.isFinite(scannedAt)
|
||||
? new Date(scannedAt).toLocaleTimeString()
|
||||
: "";
|
||||
const { tone: healthTone, text: healthText } = connectionBadge(
|
||||
t,
|
||||
connection,
|
||||
current,
|
||||
);
|
||||
const { tone: pythonTone, text: pythonText } = interpreterBadge(t, python);
|
||||
const { tone: remeTone, text: remeText } = remeSummaryBadge(
|
||||
t,
|
||||
dependency.checkData,
|
||||
dependency.checking,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="reme-env">
|
||||
<section className="reme-env-hero">
|
||||
<div className="reme-env-hero-brand">
|
||||
<span className="reme-env-hero-mark" aria-hidden="true">
|
||||
RE
|
||||
</span>
|
||||
<div className="reme-env-hero-copy">
|
||||
<span className="reme-env-eyebrow">{t("heroEyebrow")}</span>
|
||||
<h3>{t(ready ? "heroReady" : "heroSetup")}</h3>
|
||||
<p className="reme-env-hero-sub" title={endpoint}>
|
||||
{endpoint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="reme-env-hero-chips">
|
||||
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
|
||||
{local ? (
|
||||
<>
|
||||
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
|
||||
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="reme-env-hero-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setHeroTick((tick) => tick + 1);
|
||||
service.refresh();
|
||||
}}
|
||||
>
|
||||
{t("retest")}
|
||||
</button>
|
||||
{local ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={python.refresh}
|
||||
>
|
||||
{t("refresh")}
|
||||
</button>
|
||||
) : null}
|
||||
{scannedTime ? (
|
||||
<span className="reme-env-hero-meta">
|
||||
{fill(t("scannedAt"), { time: scannedTime })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{current && connectionError ? (
|
||||
<p className="reme-settings-error reme-env-hero-error">
|
||||
{connectionError}
|
||||
</p>
|
||||
) : null}
|
||||
{settingsErrorText ? (
|
||||
<p className="reme-settings-error reme-env-hero-error" role="alert">
|
||||
{settingsErrorText}
|
||||
</p>
|
||||
) : null}
|
||||
{!local ? (
|
||||
<p className="reme-settings-muted reme-env-notice">
|
||||
{t("remoteNotice")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<PythonSection
|
||||
t={t}
|
||||
selection={python}
|
||||
writable={snapshot.writable}
|
||||
/>
|
||||
<DependencySection
|
||||
t={t}
|
||||
pythonPath={python.selectedPath}
|
||||
dependency={dependency}
|
||||
/>
|
||||
<WorkspaceSection
|
||||
key={workspaceDir}
|
||||
t={t}
|
||||
rpc={rpc}
|
||||
workspaceDir={workspaceDir}
|
||||
pythonPath={python.selectedPath}
|
||||
writable={snapshot.writable}
|
||||
onWorkspaceDir={(dir) => writeSetting("remeWorkspaceDir", dir)}
|
||||
/>
|
||||
<ServiceSection
|
||||
t={t}
|
||||
endpoint={endpoint}
|
||||
pythonPath={python.selectedPath}
|
||||
control={service}
|
||||
remeCheck={dependency.checkData}
|
||||
autoStart={value?.autoStart === true}
|
||||
writable={snapshot.writable}
|
||||
current={serviceCurrent}
|
||||
onAutoStart={(checked) => {
|
||||
void writeSetting("autoStart", checked);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
689
integrations/dsh/src/environment/client/workspace.tsx
Normal file
689
integrations/dsh/src/environment/client/workspace.tsx
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import {
|
||||
ENV_FIELDS,
|
||||
envUpdates,
|
||||
MANAGED_KEYS,
|
||||
storedState,
|
||||
} from "../env-fields.js";
|
||||
import { llmBackendOf } from "../llm-backend.js";
|
||||
import type { EnvEntry, EnvSummary, LlmTestResult } from "../types.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { EnvField } from "./presentation.js";
|
||||
import { envCall, parseEnvSummary, parseLlmResult } from "./rpc.js";
|
||||
|
||||
const LLM_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"deepseek",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"moonshot",
|
||||
"ollama",
|
||||
"xai",
|
||||
] as const;
|
||||
const EMBEDDING_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"dashscope_multimodal",
|
||||
"gemini",
|
||||
"ollama",
|
||||
] as const;
|
||||
|
||||
interface EnvFieldSpec {
|
||||
key: string;
|
||||
label: EnvKey;
|
||||
kind: "select" | "text" | "secret";
|
||||
options?: readonly string[];
|
||||
placeholder?: string;
|
||||
hint?: EnvKey;
|
||||
/** The value a key that must not stay empty is written with. */
|
||||
disabledValue?: string;
|
||||
}
|
||||
|
||||
interface EnvGroup {
|
||||
title: EnvKey;
|
||||
fields: EnvFieldSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The wording of one managed key.
|
||||
*
|
||||
* Only the copy lives here: the key list, the secret flags and the update
|
||||
* rules come from the shared metadata, so the form and the Host cannot drift
|
||||
* apart about the same file. The group a key belongs to is named here, once.
|
||||
*/
|
||||
interface EnvFieldCopy {
|
||||
group: EnvKey;
|
||||
label: EnvKey;
|
||||
options?: readonly string[];
|
||||
placeholder?: string;
|
||||
hint?: EnvKey;
|
||||
}
|
||||
|
||||
const ENV_FIELD_COPY: Record<string, EnvFieldCopy> = {
|
||||
LLM_BACKEND: {
|
||||
group: "llmGroup",
|
||||
label: "backendLabel",
|
||||
options: LLM_BACKENDS,
|
||||
},
|
||||
LLM_MODEL_NAME: {
|
||||
group: "llmGroup",
|
||||
label: "modelLabel",
|
||||
placeholder: "qwen3.7-plus",
|
||||
},
|
||||
LLM_API_KEY: { group: "llmGroup", label: "apiKeyLabel" },
|
||||
LLM_BASE_URL: {
|
||||
group: "llmGroup",
|
||||
label: "baseUrlLabel",
|
||||
placeholder: "https://…/v1",
|
||||
},
|
||||
EMBEDDING_BACKEND: {
|
||||
group: "embeddingGroup",
|
||||
label: "backendLabel",
|
||||
options: EMBEDDING_BACKENDS,
|
||||
},
|
||||
EMBEDDING_MODEL_NAME: {
|
||||
group: "embeddingGroup",
|
||||
label: "modelLabel",
|
||||
placeholder: "text-embedding-v4",
|
||||
},
|
||||
EMBEDDING_API_KEY: { group: "embeddingGroup", label: "apiKeyLabel" },
|
||||
EMBEDDING_BASE_URL: {
|
||||
group: "embeddingGroup",
|
||||
label: "baseUrlLabel",
|
||||
placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
hint: "embeddingHint",
|
||||
},
|
||||
};
|
||||
|
||||
/** The groups the form shows, in order. */
|
||||
const ENV_GROUP_TITLES: EnvKey[] = ["llmGroup", "embeddingGroup"];
|
||||
|
||||
/**
|
||||
* The `.env` parameters this tab renders, derived from the shared metadata.
|
||||
*
|
||||
* The shared list owns which keys exist, in which order and which of them are
|
||||
* secrets — and therefore what an empty draft means; the wording comes from
|
||||
* `ENV_FIELD_COPY`. A managed key without a copy would be missing from the
|
||||
* form, which `tests/env-form.test.mjs` refuses.
|
||||
*/
|
||||
export const ENV_GROUPS: EnvGroup[] = ENV_GROUP_TITLES.map((title) => ({
|
||||
title,
|
||||
fields: ENV_FIELDS.flatMap((meta): EnvFieldSpec[] => {
|
||||
const copy = ENV_FIELD_COPY[meta.key];
|
||||
if (!copy || copy.group !== title) return [];
|
||||
// A secret is a password field; a key with options is a select.
|
||||
const kind: EnvFieldSpec["kind"] = meta.secret
|
||||
? "secret"
|
||||
: copy.options
|
||||
? "select"
|
||||
: "text";
|
||||
return [
|
||||
{
|
||||
key: meta.key,
|
||||
label: copy.label,
|
||||
kind,
|
||||
options: copy.options,
|
||||
placeholder: copy.placeholder,
|
||||
hint: copy.hint,
|
||||
disabledValue: meta.disabledValue,
|
||||
},
|
||||
];
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which managed keys the file already holds a value for.
|
||||
*
|
||||
* The tab reads this from the masked projection: a secret's own value never
|
||||
* rides a response, but a secret that is set is still masked to a non-empty
|
||||
* tail.
|
||||
*
|
||||
* A key the Host did not report counts as present: the placeholder that keeps
|
||||
* an unset key non-empty must never overwrite a credential the summary did
|
||||
* not mention.
|
||||
*/
|
||||
function storedValues(data: EnvSummary): Record<string, boolean> {
|
||||
const masked: Record<string, string> = {};
|
||||
for (const entry of data.entries) masked[entry.key] = entry.masked;
|
||||
const stored = storedState(masked);
|
||||
for (const key of MANAGED_KEYS) if (!(key in masked)) stored[key] = true;
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft entries a save did not resolve.
|
||||
*
|
||||
* The submission covered the keys the user touched: a value that travelled in
|
||||
* the payload, or a blank secret that means "keep the stored credential". An
|
||||
* entry whose value is still the submitted one is therefore resolved by the
|
||||
* save, while a value typed after the submission stays in the draft.
|
||||
*/
|
||||
function remainingEdits(
|
||||
values: Readonly<Record<string, string>>,
|
||||
submitted: Readonly<Record<string, string>>,
|
||||
): Record<string, string> {
|
||||
const remaining: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const sent = submitted[key];
|
||||
if (sent === undefined || sent !== value) remaining[key] = value;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
interface EnvState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: EnvSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
kind: "" | "saved" | "failed";
|
||||
message?: string;
|
||||
/**
|
||||
* True when the save left newer edits in the draft. The saved message then
|
||||
* has to say so: the form is not clean.
|
||||
*/
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
interface LlmTestState {
|
||||
phase: "idle" | "running" | "ok" | "fail";
|
||||
result?: LlmTestResult;
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
env: EnvState;
|
||||
workspaceInput: string | undefined;
|
||||
workspaceDraft: string;
|
||||
editWorkspace(next: string): void;
|
||||
saveWorkspace(): Promise<void>;
|
||||
value(key: string): string;
|
||||
entry(key: string): EnvEntry | undefined;
|
||||
edit(key: string, next: string): void;
|
||||
saving: boolean;
|
||||
result: SaveResult;
|
||||
save(): Promise<void>;
|
||||
llmTest: LlmTestState;
|
||||
testLlm(pythonPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkspaceOptions {
|
||||
workspaceDir: string;
|
||||
writable: boolean;
|
||||
onWorkspaceDir(dir: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace section's asynchronous state: one `.env` read, one draft, one
|
||||
* save result and one LLM probe, all belonging to the workspace directory the
|
||||
* section was mounted for.
|
||||
*
|
||||
* The section is mounted with the workspace directory as its React key, so a
|
||||
* workspace switch disposes this state (and aborts its probe) instead of
|
||||
* invalidating each piece field by field.
|
||||
*/
|
||||
function useWorkspace(
|
||||
rpc: StatusRpc,
|
||||
options: WorkspaceOptions,
|
||||
): WorkspaceState {
|
||||
const { workspaceDir, writable, onWorkspaceDir } = options;
|
||||
const [env, setEnv] = useState<EnvState>({ phase: "loading" });
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [result, setResult] = useState<SaveResult>({ kind: "" });
|
||||
const [llmTest, setLlmTest] = useState<LlmTestState>({ phase: "idle" });
|
||||
const [tick, setTick] = useState(0);
|
||||
const [workspaceInput, setWorkspaceInput] = useState<string | undefined>();
|
||||
const llmController = useRef<AbortController | undefined>(undefined);
|
||||
// A mirror of the draft. A save resolves against the draft as it is at that
|
||||
// moment, so the answer cannot depend on a React state updater that runs
|
||||
// later — and the draft may have moved on while the save was in flight.
|
||||
const draftRef = useRef<Record<string, string>>({});
|
||||
// False once the section unmounted, so a save that resolves afterwards does
|
||||
// not write into a disposed component.
|
||||
const alive = useRef(true);
|
||||
|
||||
/** Set the draft and keep the mirror in step with it. */
|
||||
const applyDraft = useCallback((next: Record<string, string>) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
// A probe for this workspace must not outlive its section.
|
||||
llmController.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"readEnv",
|
||||
{ workspaceDir },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setEnv({ phase: "ready", data: parseEnvSummary(value) });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setEnv({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, workspaceDir, tick]);
|
||||
|
||||
const entry = useCallback(
|
||||
(key: string) => env.data?.entries.find((item) => item.key === key),
|
||||
[env.data],
|
||||
);
|
||||
const value = useCallback(
|
||||
(key: string) => {
|
||||
const typed = draft[key];
|
||||
if (typed !== undefined) return typed;
|
||||
const stored = entry(key);
|
||||
return stored?.secret ? "" : stored?.value ?? "";
|
||||
},
|
||||
[draft, entry],
|
||||
);
|
||||
const edit = useCallback(
|
||||
(key: string, next: string) => {
|
||||
// The mirror is the freshest copy of the draft, so two edits in one
|
||||
// tick cannot overwrite each other.
|
||||
applyDraft({ ...draftRef.current, [key]: next });
|
||||
setResult({ kind: "" });
|
||||
},
|
||||
[applyDraft],
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!env.data || saving) return;
|
||||
// The submission is the draft as it stands now, not as it stood when the
|
||||
// button was rendered.
|
||||
const pending = draftRef.current;
|
||||
// Only touched keys travel: an untouched key keeps its stored value, an
|
||||
// empty touched value is an explicit clear, and a blank secret keeps the
|
||||
// stored credential. The shared metadata owns those rules.
|
||||
const payload = envUpdates(pending, storedValues(env.data));
|
||||
setSaving(true);
|
||||
setResult({ kind: "" });
|
||||
try {
|
||||
const summary = parseEnvSummary(
|
||||
await envCall(rpc, "writeEnv", { workspaceDir, values: payload }),
|
||||
);
|
||||
if (!alive.current) return;
|
||||
setEnv({ phase: "ready", data: summary });
|
||||
// The save resolves the edits it carried; a value typed while it was in
|
||||
// flight stays in the draft.
|
||||
const remaining = remainingEdits(draftRef.current, pending);
|
||||
applyDraft(remaining);
|
||||
setResult({
|
||||
kind: "saved",
|
||||
pending: Object.keys(remaining).length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!alive.current) return;
|
||||
setResult({ kind: "failed", message: safeError(error) });
|
||||
} finally {
|
||||
if (alive.current) setSaving(false);
|
||||
}
|
||||
}, [applyDraft, env.data, rpc, saving, workspaceDir]);
|
||||
|
||||
const testLlm = useCallback(
|
||||
async (pythonPath: string) => {
|
||||
if (!pythonPath || llmTest.phase === "running") return;
|
||||
llmController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
llmController.current = controller;
|
||||
setLlmTest({ phase: "running" });
|
||||
try {
|
||||
const data = parseLlmResult(
|
||||
await envCall(
|
||||
rpc,
|
||||
"testLlm",
|
||||
{ workspaceDir, pythonPath },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setLlmTest({
|
||||
phase: "fail",
|
||||
result: {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
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 && alive.current)
|
||||
setLlmTest({ phase: "idle" });
|
||||
}
|
||||
},
|
||||
[llmTest.phase, rpc, workspaceDir],
|
||||
);
|
||||
|
||||
const saveWorkspace = useCallback(async () => {
|
||||
if (!writable) return;
|
||||
const next = workspaceInput ?? workspaceDir;
|
||||
setWorkspaceInput(undefined);
|
||||
const saved = await onWorkspaceDir(next.trim()).catch(() => false);
|
||||
if (!alive.current) return;
|
||||
// A rejected save is reported by the settings writer; do not reload the
|
||||
// old directory as if the new one had been accepted.
|
||||
if (saved === false) return;
|
||||
setTick((current) => current + 1);
|
||||
}, [onWorkspaceDir, workspaceDir, workspaceInput, writable]);
|
||||
|
||||
return {
|
||||
env,
|
||||
workspaceInput,
|
||||
workspaceDraft: workspaceInput ?? workspaceDir,
|
||||
editWorkspace: setWorkspaceInput,
|
||||
saveWorkspace,
|
||||
value,
|
||||
entry,
|
||||
edit,
|
||||
saving,
|
||||
result,
|
||||
save,
|
||||
llmTest,
|
||||
testLlm,
|
||||
};
|
||||
}
|
||||
|
||||
/** One `.env` control generated from the field table. */ function EnvControl({
|
||||
t,
|
||||
spec,
|
||||
workspace,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
spec: EnvFieldSpec;
|
||||
workspace: WorkspaceState;
|
||||
}): JSX.Element {
|
||||
const entry = workspace.entry(spec.key);
|
||||
const hint = spec.hint ? t(spec.hint) : undefined;
|
||||
if (spec.kind === "select")
|
||||
return (
|
||||
<EnvField label={t(spec.label)}>
|
||||
<select
|
||||
value={workspace.value(spec.key)}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{envChoices(spec.options ?? [], workspace.value(spec.key)).map(
|
||||
(name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</EnvField>
|
||||
);
|
||||
if (spec.kind === "secret")
|
||||
return (
|
||||
<EnvField
|
||||
label={t(spec.label)}
|
||||
hint={
|
||||
entry?.set
|
||||
? fill(t("secretSet"), { masked: entry.masked })
|
||||
: t("secretUnset")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={workspace.value(spec.key)}
|
||||
placeholder={entry?.masked ?? spec.disabledValue ?? ""}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
/>
|
||||
</EnvField>
|
||||
);
|
||||
return (
|
||||
<EnvField label={t(spec.label)} hint={hint}>
|
||||
<input
|
||||
value={workspace.value(spec.key)}
|
||||
placeholder={spec.placeholder}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
/>
|
||||
</EnvField>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep a value that is no longer offered as an option visible and selected. */
|
||||
function envChoices(list: readonly string[], current: string): string[] {
|
||||
return current && !list.includes(current) ? [...list, current] : [...list];
|
||||
}
|
||||
|
||||
export interface WorkspaceSectionProps {
|
||||
t: EnvTranslator;
|
||||
rpc: StatusRpc;
|
||||
/** The workspace whose `.env` this section reads; also its React key. */
|
||||
workspaceDir: string;
|
||||
pythonPath: string;
|
||||
writable: boolean;
|
||||
/** Persist a new workspace directory; resolves false when refused. */
|
||||
onWorkspaceDir(dir: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Step 3: the workspace `.env` ReMe reads when the service starts. */
|
||||
export function WorkspaceSection({
|
||||
t,
|
||||
rpc,
|
||||
workspaceDir,
|
||||
pythonPath,
|
||||
writable,
|
||||
onWorkspaceDir,
|
||||
}: WorkspaceSectionProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
// The section owns the workspace state, and the tab mounts it with
|
||||
// `key={workspaceDir}`, so switching workspaces disposes the previous
|
||||
// drafts, save message and LLM-test result instead of invalidating them
|
||||
// field by field.
|
||||
const workspace = useWorkspace(rpc, {
|
||||
workspaceDir,
|
||||
writable,
|
||||
onWorkspaceDir,
|
||||
});
|
||||
const { env, llmTest, result } = workspace;
|
||||
// The probe reads the saved .env, so a configuration that is only picked in
|
||||
// the form would be tested against the one still on disk. The button waits
|
||||
// instead of quietly testing the old configuration.
|
||||
const savedValue = (key: string): string =>
|
||||
env.data?.entries.find((entry) => entry.key === key)?.value ?? "";
|
||||
// The backend falls back to the default on both sides, so picking the
|
||||
// default in the form is not a change at all.
|
||||
const backendChanged =
|
||||
llmBackendOf(workspace.value("LLM_BACKEND")) !==
|
||||
llmBackendOf(savedValue("LLM_BACKEND"));
|
||||
// The model and the base URL travel as plain text, so they compare by
|
||||
// value. The key is write-only: a typed one is the only sign that it
|
||||
// differs from the file, which still holds the stored key.
|
||||
const configChanged =
|
||||
backendChanged ||
|
||||
workspace.value("LLM_MODEL_NAME").trim() !==
|
||||
savedValue("LLM_MODEL_NAME").trim() ||
|
||||
workspace.value("LLM_BASE_URL").trim() !==
|
||||
savedValue("LLM_BASE_URL").trim() ||
|
||||
workspace.value("LLM_API_KEY") !== "";
|
||||
|
||||
return (
|
||||
<section className={`reme-env-card${open ? "" : " collapsed"}`}>
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("configTitle")}</h4>
|
||||
<p className="reme-env-summary">
|
||||
{env.phase === "ready" && env.data
|
||||
? `${env.data.exists ? env.data.envPath : t("envMissing")}${
|
||||
workspace.value("LLM_MODEL_NAME").trim()
|
||||
? ` · ${workspace.value("LLM_MODEL_NAME").trim()}`
|
||||
: ""
|
||||
}`
|
||||
: t("envLoading")}
|
||||
</p>
|
||||
</div>
|
||||
<aside>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{t(open ? "collapse" : "expand")}
|
||||
</button>
|
||||
</aside>
|
||||
</header>
|
||||
{open ? (
|
||||
<div className="reme-env-card-body">
|
||||
<p className="reme-settings-muted">{t("configHint")}</p>
|
||||
<div className="reme-settings-grid">
|
||||
<EnvField label={t("workspaceDir")} hint={t("workspaceDirHint")}>
|
||||
<div className="reme-env-inline">
|
||||
<input
|
||||
value={workspace.workspaceDraft}
|
||||
placeholder="~/.reme"
|
||||
onChange={(event) =>
|
||||
workspace.editWorkspace(event.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!writable || workspace.saving}
|
||||
onClick={() => void workspace.saveWorkspace()}
|
||||
>
|
||||
{t("saveWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
</EnvField>
|
||||
</div>
|
||||
{env.phase === "loading" ? (
|
||||
<p className="reme-settings-muted">{t("envLoading")}</p>
|
||||
) : null}
|
||||
{env.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("saveEnvFailed")}
|
||||
{env.error ? `: ${env.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{env.phase === "ready" && env.data ? (
|
||||
<>
|
||||
<p className="reme-settings-muted">
|
||||
{env.data.exists
|
||||
? `${t("envPath")}: ${env.data.envPath}`
|
||||
: t("envMissing")}
|
||||
</p>
|
||||
<div className="reme-settings-grid reme-env-config">
|
||||
{ENV_GROUPS.map((group) => (
|
||||
<EnvGroupFields
|
||||
key={group.title}
|
||||
t={t}
|
||||
group={group}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="reme-env-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={workspace.saving || !writable}
|
||||
onClick={() => void workspace.save()}
|
||||
>
|
||||
{t(workspace.saving ? "savingEnv" : "saveEnv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={
|
||||
!pythonPath || llmTest.phase === "running" || configChanged
|
||||
}
|
||||
onClick={() => void workspace.testLlm(pythonPath)}
|
||||
>
|
||||
{t(
|
||||
llmTest.phase === "running"
|
||||
? "testingLlm"
|
||||
: "testLlmButton",
|
||||
)}
|
||||
</button>
|
||||
{configChanged ? (
|
||||
<span className="reme-settings-muted">
|
||||
{t("testLlmSaveFirst")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="reme-settings-result" role="status">
|
||||
{result.kind === "saved" ? (
|
||||
<span className="reme-settings-success">
|
||||
{fill(
|
||||
// A save that left newer edits behind must not read
|
||||
// like a clean form.
|
||||
result.pending ? t("savedEnvPending") : t("savedEnv"),
|
||||
{ path: env.data.envPath },
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{result.kind === "failed" ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("saveEnvFailed")}${
|
||||
result.message ? `: ${result.message}` : ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "ok" && llmTest.result ? (
|
||||
<span className="reme-settings-success">
|
||||
{fill(t("llmOk"), { ms: llmTest.result.latencyMs })}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "fail" && llmTest.result ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("llmFailed")}${
|
||||
llmTest.result.error ? `: ${llmTest.result.error}` : ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One titled group of `.env` controls. */
|
||||
function EnvGroupFields({
|
||||
t,
|
||||
group,
|
||||
workspace,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
group: EnvGroup;
|
||||
workspace: WorkspaceState;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<h5 className="reme-env-group">{t(group.title)}</h5>
|
||||
{group.fields.map((spec) => (
|
||||
<EnvControl key={spec.key} t={t} spec={spec} workspace={workspace} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
integrations/dsh/src/environment/dependency.ts
Normal file
36
integrations/dsh/src/environment/dependency.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* What one interpreter can and cannot run.
|
||||
*
|
||||
* The dependency card, the hero and the service card all have to answer the
|
||||
* same question about the selected interpreter, and the Host answers it again
|
||||
* when a start is requested. The answers live here so the four cannot drift
|
||||
* apart: a button that is offered for an interpreter the Host will refuse
|
||||
* only produces an error, and a button that is hidden for one the Host would
|
||||
* accept takes a capability away.
|
||||
*
|
||||
* This module stays browser-safe: the client imports it too.
|
||||
*/
|
||||
import type { RemeCheck } from "./types.js";
|
||||
|
||||
/**
|
||||
* Whether the interpreter can host the ReMe service.
|
||||
*
|
||||
* This is the Host's own start gate: `reme-ai` must be installed and satisfy
|
||||
* the minimum version, because the Host refuses a start otherwise with
|
||||
* `not-installed` or `version-too-old`.
|
||||
*/
|
||||
export function canHostService(check: RemeCheck | undefined): boolean {
|
||||
return check?.installed === true && check.versionOk === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the interpreter offers the whole feature set.
|
||||
*
|
||||
* The `[core]` extras provide automatic memory and automatic consolidation, so
|
||||
* an interpreter without them runs the service but not those two jobs. The
|
||||
* hero reports this state; a start is still offered, because the Host accepts
|
||||
* one and search still works.
|
||||
*/
|
||||
export function hasCoreFeatures(check: RemeCheck | undefined): boolean {
|
||||
return canHostService(check) && check?.hasCore === true;
|
||||
}
|
||||
52
integrations/dsh/src/environment/endpoint-key.ts
Normal file
52
integrations/dsh/src/environment/endpoint-key.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The origin and root-normalized path of an endpoint, when it is parseable.
|
||||
*
|
||||
* Loopback aliases and default ports are already folded here, so both keys
|
||||
* below compare the same spelling of the same endpoint.
|
||||
*/
|
||||
function endpointParts(
|
||||
endpoint: string,
|
||||
): { origin: string; path: string } | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint.trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const host = hostname === "localhost" ? "127.0.0.1" : hostname;
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
return {
|
||||
origin: `${url.protocol}//${host}:${port}`,
|
||||
path: url.pathname.replace(/\/+$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function serviceEndpointKey(endpoint: string): string {
|
||||
const parts = endpointParts(endpoint);
|
||||
return parts ? `${parts.origin}${parts.path}` : endpoint.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical identity of an endpoint's origin, ignoring its path.
|
||||
*
|
||||
* The service launch configures only host and port, so two endpoints on the
|
||||
* same origin name the same local process even when their paths differ. A
|
||||
* record left by a version that still launched a prefixed endpoint (for
|
||||
* example `/reme`) therefore still describes the service a start on the
|
||||
* origin root has to replace.
|
||||
*/
|
||||
export function serviceOriginKey(endpoint: string): string {
|
||||
const parts = endpointParts(endpoint);
|
||||
return parts ? parts.origin : endpoint.trim();
|
||||
}
|
||||
73
integrations/dsh/src/environment/endpoint-rules.ts
Normal file
73
integrations/dsh/src/environment/endpoint-rules.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* The endpoint rules the Host and the tab must agree on.
|
||||
*
|
||||
* The plugin launches `reme start` with `service.host=127.0.0.1` and a plain
|
||||
* HTTP service, so only a loopback HTTP endpoint can ever become a managed
|
||||
* local service. The tab decides whether to show the local steps at all and
|
||||
* the Host decides whether it may act; both read the same rules here, so the
|
||||
* browser cannot offer a local service the Host would refuse.
|
||||
*
|
||||
* This module stays browser-safe: the client imports it too.
|
||||
*/
|
||||
|
||||
/** The host and port of a manageable local endpoint. */
|
||||
export interface LocalEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host and port of a manageable local endpoint.
|
||||
*
|
||||
* Throws when the endpoint is not one, so the caller can report why. An
|
||||
* https loopback URL is refused: the service this plugin launches is plain
|
||||
* HTTP, so an https URL could never become healthy. A loopback URL with a path
|
||||
* prefix is refused too: the launch only sets host and port, so the service
|
||||
* answers at the origin root and the prefixed health check could never pass.
|
||||
*/
|
||||
export function parseLocalEndpoint(endpoint: string): LocalEndpoint {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint.trim());
|
||||
} catch {
|
||||
throw new TypeError("Invalid ReMe endpoint");
|
||||
}
|
||||
if (url.protocol !== "http:")
|
||||
throw new TypeError(
|
||||
"Local service management requires an http:// endpoint",
|
||||
);
|
||||
// The service is launched with `service.host=127.0.0.1`; accepting an IPv6
|
||||
// loopback would spawn a process that can never answer that endpoint.
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname))
|
||||
throw new TypeError(
|
||||
"Local service management requires an IPv4 loopback endpoint",
|
||||
);
|
||||
const port = Number(url.port || "80");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
throw new TypeError("Invalid ReMe endpoint port");
|
||||
// The launch configures `service.host` and `service.port` only, so the
|
||||
// service always answers at the origin root. A path prefix (a gateway that
|
||||
// mounts ReMe under, say, `/reme`) would make the startup health check at
|
||||
// `/reme/health_check` 404, so such an endpoint is not launchable here. It
|
||||
// stays a remote endpoint and is health-checked at its own path.
|
||||
if (url.pathname !== "/" && url.pathname !== "")
|
||||
throw new TypeError(
|
||||
"Local service management requires a root-path endpoint",
|
||||
);
|
||||
return { host: "127.0.0.1", port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the endpoint names a service this plugin can manage locally.
|
||||
*
|
||||
* A remote endpoint is still health-checked, but its local steps are hidden:
|
||||
* the browser and the Host may be different machines.
|
||||
*/
|
||||
export function isLocalEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
parseLocalEndpoint(endpoint);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
168
integrations/dsh/src/environment/env-fields.ts
Normal file
168
integrations/dsh/src/environment/env-fields.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* The `.env` keys the Environment tab manages, and the rules for updating them.
|
||||
*
|
||||
* This module is the single source of truth for the managed keys and for which
|
||||
* of them are write-only secrets. An empty secret draft means "leave blank to
|
||||
* keep" and an empty non-secret draft is an explicit clear; the rules are
|
||||
* derived from the `secret` flag alone, so a key cannot carry a rule that
|
||||
* contradicts its own kind. A key that names a default (`defaultValue`) is the
|
||||
* one exception: its empty draft means "use the default", and the default is
|
||||
* written out, because the service only falls back to it when the variable is
|
||||
* absent. The Host and the browser both derive their behaviour from it, so the
|
||||
* two can never drift apart about the same file.
|
||||
*
|
||||
* It stays browser-safe: no node built-in is imported here, and the browser
|
||||
* bundle must stay loadable without one.
|
||||
*/
|
||||
|
||||
import { DEFAULT_LLM_BACKEND } from "./llm-backend.js";
|
||||
|
||||
export interface EnvFieldMeta {
|
||||
/** The `.env` key. */
|
||||
key: string;
|
||||
/**
|
||||
* True for write-only credentials; the stored value never rides a response.
|
||||
* An empty draft for such a key writes nothing.
|
||||
*/
|
||||
secret: boolean;
|
||||
/**
|
||||
* Value written when the key is unset and must not stay empty.
|
||||
*
|
||||
* ReMe 0.4.0.x instantiates the embedding component unconditionally, so
|
||||
* `EMBEDDING_API_KEY` has to hold a non-empty value even when vector search
|
||||
* is off; `EMPTY` is the documented placeholder for "no embedding key".
|
||||
*/
|
||||
disabledValue?: string;
|
||||
/**
|
||||
* The value an empty selection is written as.
|
||||
*
|
||||
* An empty selection means "use the default". The default is written
|
||||
* explicitly, because ReMe's configuration only falls back to it when the
|
||||
* variable is absent: an empty value stays empty and names no backend, and
|
||||
* the service then refuses to start.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed keys, in the order the two form groups show them.
|
||||
*
|
||||
* The order is the order the summary reports them in, so the Host projection
|
||||
* and the browser form list the same keys in the same order.
|
||||
*/
|
||||
export const ENV_FIELDS: readonly EnvFieldMeta[] = [
|
||||
{
|
||||
key: "LLM_BACKEND",
|
||||
secret: false,
|
||||
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is
|
||||
// absent, so a cleared selection must write the default out rather than
|
||||
// an empty value.
|
||||
defaultValue: DEFAULT_LLM_BACKEND,
|
||||
},
|
||||
{ key: "LLM_MODEL_NAME", secret: false },
|
||||
{ key: "LLM_API_KEY", secret: true },
|
||||
{ key: "LLM_BASE_URL", secret: false },
|
||||
{
|
||||
key: "EMBEDDING_BACKEND",
|
||||
secret: false,
|
||||
// ReMe 0.4.0.x declares `as_embedding` with
|
||||
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so the
|
||||
// same rule holds here: the default is written out, because an empty
|
||||
// value names no backend and the service then refuses to start.
|
||||
defaultValue: "openai",
|
||||
},
|
||||
{ key: "EMBEDDING_MODEL_NAME", secret: false },
|
||||
{
|
||||
key: "EMBEDDING_API_KEY",
|
||||
secret: true,
|
||||
disabledValue: "EMPTY",
|
||||
},
|
||||
{ key: "EMBEDDING_BASE_URL", secret: false },
|
||||
];
|
||||
|
||||
/** Every key the Environment tab manages. */
|
||||
export const MANAGED_KEYS: readonly string[] = ENV_FIELDS.map(
|
||||
(field) => field.key,
|
||||
);
|
||||
|
||||
/** The keys whose stored value is never returned to the browser. */
|
||||
export const SECRET_KEYS: ReadonlySet<string> = new Set(
|
||||
ENV_FIELDS.filter((field) => field.secret).map((field) => field.key),
|
||||
);
|
||||
|
||||
/** The metadata of one managed key. */
|
||||
export function envFieldMeta(key: string): EnvFieldMeta | undefined {
|
||||
return ENV_FIELDS.find((field) => field.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file already holds a non-empty value for each managed key.
|
||||
*
|
||||
* The Host builds it from the parsed file; the tab builds it from the masked
|
||||
* projection it received, which never carries a secret's value.
|
||||
*/
|
||||
export function storedState(
|
||||
values: Readonly<Record<string, string>>,
|
||||
): Record<string, boolean> {
|
||||
const state: Record<string, boolean> = {};
|
||||
for (const field of ENV_FIELDS)
|
||||
state[field.key] = (values[field.key] ?? "") !== "";
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The update a draft of the managed keys produces.
|
||||
*
|
||||
* Only touched keys travel: an untouched key keeps its stored value. An empty
|
||||
* secret draft is dropped rather than written, so "leave blank to keep" holds
|
||||
* even after the user typed a replacement and cleared it again. An empty
|
||||
* non-secret draft is an explicit clear and keeps that meaning — unless the
|
||||
* key names a default, in which case the empty selection means "use the
|
||||
* default" and the default is written out.
|
||||
*
|
||||
* `stored` reports, per key, whether the file already holds a non-empty value.
|
||||
* It is what decides whether a key that must not stay empty gets its
|
||||
* `disabledValue` marker.
|
||||
*/
|
||||
export function envUpdates(
|
||||
draft: Readonly<Record<string, string>>,
|
||||
stored: Readonly<Record<string, boolean>>,
|
||||
): Record<string, string> {
|
||||
const updates: Record<string, string> = {};
|
||||
// A key the tab does not manage travels unchanged: the Host must not
|
||||
// silently drop a value it was asked to write.
|
||||
for (const [key, value] of Object.entries(draft))
|
||||
if (!envFieldMeta(key)) updates[key] = value;
|
||||
for (const field of ENV_FIELDS) {
|
||||
const typed = draft[field.key];
|
||||
const isSet = stored[field.key] === true;
|
||||
if (typed === undefined) {
|
||||
// Untouched: the stored value survives. The one exception is a key that
|
||||
// must not stay empty, which gets its documented placeholder.
|
||||
if (field.disabledValue && !isSet)
|
||||
updates[field.key] = field.disabledValue;
|
||||
continue;
|
||||
}
|
||||
if (typed === "") {
|
||||
// "Leave blank to keep": a secret is write-only, so an empty draft
|
||||
// cannot mean "delete my key"; it writes nothing and the stored
|
||||
// credential survives.
|
||||
if (field.secret) {
|
||||
if (field.disabledValue && !isSet)
|
||||
updates[field.key] = field.disabledValue;
|
||||
continue;
|
||||
}
|
||||
// "Use the default": the default is written out, because the service
|
||||
// only falls back to it when the variable is absent. An empty value
|
||||
// would name no backend at all.
|
||||
if (field.defaultValue) {
|
||||
updates[field.key] = field.defaultValue;
|
||||
continue;
|
||||
}
|
||||
updates[field.key] = "";
|
||||
continue;
|
||||
}
|
||||
updates[field.key] = typed;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
593
integrations/dsh/src/environment/host/detect.ts
Normal file
593
integrations/dsh/src/environment/host/detect.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
import { win32 } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { pathOf } from "./paths.js";
|
||||
import type { RunCommand, RunResult } from "./runner.js";
|
||||
import { probeReme, remeVersionAtLeast } from "./reme.js";
|
||||
import type { PythonDetection, PythonEnv, PythonOrigin } from "../types.js";
|
||||
|
||||
/** ReMe requires Python 3.11 or newer. */
|
||||
export const MINIMUM_PYTHON = "3.11";
|
||||
|
||||
/**
|
||||
* Ask one interpreter for its real executable, version and base prefix.
|
||||
* Kept as a single self-contained -c snippet so no helper files are needed.
|
||||
*/
|
||||
const VERIFY_SNIPPET =
|
||||
'import sys,json;print(json.dumps({"exe":sys.executable,"ver":"%d.%d.%d"%sys.version_info[:3],"base":sys.base_prefix}))';
|
||||
|
||||
export interface DetectIO {
|
||||
run: RunCommand;
|
||||
platform: NodeJS.Platform;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homedir: string;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface VerifyInfo {
|
||||
exe: string;
|
||||
version: string;
|
||||
basePrefix: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pure parsers (unit-tested against real tool output fixtures). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Compare "major.minor[.patch]" strings numerically. */
|
||||
export function versionAtLeast(
|
||||
version: string,
|
||||
minimum: string = MINIMUM_PYTHON,
|
||||
): boolean {
|
||||
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (actual.length < 2 || actual.some((part) => !Number.isFinite(part)))
|
||||
return false;
|
||||
const wanted = minimum.split(".").map((part) => Number.parseInt(part, 10));
|
||||
for (let index = 0; index < wanted.length; index += 1) {
|
||||
const left = actual[index] ?? 0;
|
||||
const right = wanted[index] ?? 0;
|
||||
if (left !== right) return left > right;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Parse the VERIFY_SNIPPET line out of mixed stdout (banners, warnings). */
|
||||
export function parseVerifyOutput(text: string): VerifyInfo | undefined {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const exe = typeof value.exe === "string" ? value.exe : "";
|
||||
const version = typeof value.ver === "string" ? value.ver : "";
|
||||
const base = typeof value.base === "string" ? value.base : "";
|
||||
if (exe && version) return { exe, version, basePrefix: base };
|
||||
} catch {
|
||||
// Not JSON; try the next line.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface RegPython {
|
||||
exe: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `reg query <hive> /s /ve` output for PEP 514 InstallPath defaults.
|
||||
* The default-value label is localized and arrives mojibaked when reg.exe
|
||||
* writes GBK into a UTF-8 pipe, so match the ASCII "REG_SZ" marker instead.
|
||||
*/
|
||||
export function parseRegQuery(text: string): RegPython[] {
|
||||
const found: RegPython[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const key = (lines[index] ?? "").trim();
|
||||
if (!key.startsWith("HKEY_") || !key.endsWith("\\InstallPath")) continue;
|
||||
for (
|
||||
let offset = 1;
|
||||
offset <= 3 && index + offset < lines.length;
|
||||
offset += 1
|
||||
) {
|
||||
const line = (lines[index + offset] ?? "").trim();
|
||||
if (line.startsWith("HKEY_")) break;
|
||||
const match = /REG_SZ\s+(.+)$/i.exec(line);
|
||||
if (!match) continue;
|
||||
const dir = (match[1] ?? "").trim();
|
||||
if (!dir || /^\(.*\)$/.test(dir)) break;
|
||||
const exe = /python[^\\/]*\.exe$/i.test(dir)
|
||||
? dir
|
||||
: `${dir.replace(/[\\/]+$/, "")}\\python.exe`;
|
||||
found.push({ exe, source: `registry ${key}` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Parse `py -0p` lines: "-V:3.12 * D:\...\python.exe". */
|
||||
export function parsePyListPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = /-V:\d+(?:\.\d+)?\s+\*?\s*(\S.*)$/.exec(line);
|
||||
const exe = (match?.[1] ?? "").trim();
|
||||
if (exe && /python[^\\/]*\.exe$/i.test(exe)) paths.push(exe);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Parse `where`/`which -a` output into trimmed, non-empty lines. */
|
||||
export function parsePathList(text: string): string[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Store installs real interpreters under
|
||||
* WindowsApps\PythonSoftwareFoundation.*, while the alias stubs sit directly
|
||||
* in WindowsApps and only open the Store. Only the stubs must be dropped.
|
||||
*/
|
||||
export function isWindowsAppsAlias(exe: string): boolean {
|
||||
const normalized = exe.replaceAll("/", "\\").toLowerCase();
|
||||
const cut = normalized.lastIndexOf("\\");
|
||||
if (cut < 0) return false;
|
||||
const parent = normalized.slice(0, cut);
|
||||
const parentName = parent.slice(parent.lastIndexOf("\\") + 1);
|
||||
return parentName === "windowsapps";
|
||||
}
|
||||
|
||||
function isWindowsStorePackage(exe: string): boolean {
|
||||
return /\\windowsapps\\pythonsoftwarefoundation\./i.test(
|
||||
exe.replaceAll("/", "\\"),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCondaEnvs(text: string): string[] {
|
||||
try {
|
||||
const value = JSON.parse(text) as { envs?: unknown };
|
||||
if (!Array.isArray(value.envs)) return [];
|
||||
return value.envs.filter(
|
||||
(item): item is string => typeof item === "string" && item.length > 0,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCondaRoot(text: string): string {
|
||||
try {
|
||||
const value = JSON.parse(text) as { root_prefix?: unknown };
|
||||
return typeof value.root_prefix === "string" ? value.root_prefix : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Conda command runner: prefer the real conda.exe, fall back to .bat. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type CondaRunner = (
|
||||
args: string[],
|
||||
timeoutMs?: number,
|
||||
) => Promise<RunResult>;
|
||||
|
||||
/** Derive the conda installation root from any conda executable path. */
|
||||
export function condaRootFromPath(condaPath: string): string {
|
||||
// The path may be written for another platform than the host, so its flavor
|
||||
// decides the semantics: dirname must not depend on where tests run.
|
||||
const path = pathOf(condaPath);
|
||||
const dir = path.dirname(condaPath);
|
||||
const lower = dir.toLowerCase();
|
||||
if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin"))
|
||||
return path.dirname(path.dirname(dir));
|
||||
if (lower.endsWith("\\scripts") || lower.endsWith("/scripts"))
|
||||
return path.dirname(dir);
|
||||
if (lower.endsWith("\\condabin") || lower.endsWith("/condabin"))
|
||||
return path.dirname(dir);
|
||||
if (lower.endsWith("\\bin") || lower.endsWith("/bin"))
|
||||
return path.dirname(dir);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows ships conda as .bat files that Node cannot spawn without a shell;
|
||||
* run the real Scripts\conda.exe when present, otherwise wrap the .bat in
|
||||
* cmd.exe with verbatim argv. The path comes from discovery, never user input.
|
||||
*/
|
||||
export function makeCondaRunner(
|
||||
run: RunCommand,
|
||||
condaPath: string,
|
||||
windows: boolean,
|
||||
exists: (path: string) => boolean,
|
||||
): CondaRunner {
|
||||
const root = condaRootFromPath(condaPath);
|
||||
const exeCandidates: string[] = [];
|
||||
if (root && windows)
|
||||
exeCandidates.push(win32.join(root, "Scripts", "conda.exe"));
|
||||
if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath);
|
||||
return async (args, timeoutMs = 15_000) => {
|
||||
for (const candidate of exeCandidates) {
|
||||
if (!exists(candidate)) continue;
|
||||
const result = await run(candidate, args, { timeoutMs });
|
||||
if (!result.error) return result;
|
||||
}
|
||||
if (windows)
|
||||
return run(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", `"${condaPath}" ${args.join(" ")}`],
|
||||
{ timeoutMs, windowsVerbatimArguments: true },
|
||||
);
|
||||
return run(condaPath, args, { timeoutMs });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix interpreters that a `which -a python3` can miss when the shell has a
|
||||
* trimmed PATH: the system install, Homebrew on both Apple Silicon and Intel,
|
||||
* and pyenv/local shims. Windows has `where` and the registry for this.
|
||||
*/
|
||||
function commonPythonPaths(io: DetectIO, windows: boolean): string[] {
|
||||
if (windows) return [];
|
||||
const path = pathOf(io.homedir);
|
||||
return [
|
||||
"/usr/bin/python3",
|
||||
"/usr/bin/python",
|
||||
"/usr/local/bin/python3",
|
||||
"/opt/homebrew/bin/python3",
|
||||
path.join(io.homedir, ".pyenv", "shims", "python3"),
|
||||
path.join(io.homedir, ".local", "bin", "python3"),
|
||||
];
|
||||
}
|
||||
|
||||
function commonCondaPaths(io: DetectIO, windows: boolean): string[] {
|
||||
const programData = io.env["ProgramData"] ?? "";
|
||||
const path = windows ? win32 : pathOf(io.homedir);
|
||||
const candidates = windows
|
||||
? [
|
||||
path.join(io.homedir, "miniconda3", "Scripts", "conda.exe"),
|
||||
path.join(io.homedir, "anaconda3", "Scripts", "conda.exe"),
|
||||
path.join(programData, "miniconda3", "Scripts", "conda.exe"),
|
||||
path.join(programData, "anaconda3", "Scripts", "conda.exe"),
|
||||
]
|
||||
: [
|
||||
path.join(io.homedir, "miniconda3", "bin", "conda"),
|
||||
path.join(io.homedir, "anaconda3", "bin", "conda"),
|
||||
path.join(io.homedir, "miniforge3", "bin", "conda"),
|
||||
"/opt/miniconda3/bin/conda",
|
||||
"/opt/anaconda3/bin/conda",
|
||||
"/opt/homebrew/Caskroom/miniconda/base/bin/conda",
|
||||
];
|
||||
return candidates.filter((candidate) => path.isAbsolute(candidate));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Orchestration. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface Candidate {
|
||||
exe: string;
|
||||
sources: Set<string>;
|
||||
}
|
||||
|
||||
function normalizeExe(exe: string, windows: boolean): string {
|
||||
const trimmed = exe.trim();
|
||||
if (!trimmed) return "";
|
||||
// Unify separators for the simulated platform: resolve() follows the host,
|
||||
// but detection must behave identically on every host.
|
||||
const unified = windows
|
||||
? trimmed.replaceAll("/", "\\")
|
||||
: trimmed.replaceAll("\\", "/");
|
||||
return windows ? unified.toLowerCase() : unified;
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
candidates: Map<string, Candidate>,
|
||||
exe: string,
|
||||
source: string,
|
||||
windows: boolean,
|
||||
): void {
|
||||
const key = normalizeExe(exe, windows);
|
||||
if (!key) return;
|
||||
const existing = candidates.get(key);
|
||||
if (existing) existing.sources.add(source);
|
||||
else candidates.set(key, { exe: exe.trim(), sources: new Set([source]) });
|
||||
}
|
||||
|
||||
function pathContains(dir: string, exe: string, windows: boolean): boolean {
|
||||
const path = windows ? win32 : pathOf(exe);
|
||||
const base = normalizeExe(path.resolve(dir), windows);
|
||||
const full = normalizeExe(path.resolve(exe), windows);
|
||||
return full.startsWith(`${base}${windows ? "\\" : "/"}`);
|
||||
}
|
||||
|
||||
/** prefix of an interpreter install from its executable location. */
|
||||
function pythonPrefix(exe: string, windows: boolean): string {
|
||||
const path = windows ? win32 : pathOf(exe);
|
||||
const parent = path.dirname(exe);
|
||||
return windows || path.basename(parent) !== "bin"
|
||||
? parent
|
||||
: path.dirname(parent);
|
||||
}
|
||||
|
||||
function isVenv(info: VerifyInfo, windows: boolean): boolean {
|
||||
if (!info.basePrefix) return false;
|
||||
return (
|
||||
normalizeExe(pythonPrefix(info.exe, windows), windows) !==
|
||||
normalizeExe(info.basePrefix, windows)
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_RANK: Record<PythonOrigin["kind"], number> = {
|
||||
conda: 0,
|
||||
"python.org": 1,
|
||||
"windows-store": 2,
|
||||
venv: 3,
|
||||
system: 4,
|
||||
unknown: 5,
|
||||
};
|
||||
|
||||
function classifyOrigin(
|
||||
info: VerifyInfo,
|
||||
candidate: Candidate,
|
||||
conda: { root: string; envs: string[] },
|
||||
windows: boolean,
|
||||
): PythonOrigin {
|
||||
// Named envs live under the root too, so they must be matched first.
|
||||
for (const envPath of conda.envs) {
|
||||
if (envPath === conda.root) continue;
|
||||
if (pathContains(envPath, info.exe, windows))
|
||||
return {
|
||||
kind: "conda",
|
||||
envName: (windows ? win32 : pathOf(envPath)).basename(envPath),
|
||||
root: envPath,
|
||||
};
|
||||
}
|
||||
if (conda.root && pathContains(conda.root, info.exe, windows))
|
||||
return { kind: "conda", envName: "base", root: conda.root };
|
||||
if (isWindowsStorePackage(info.exe)) return { kind: "windows-store" };
|
||||
if (isVenv(info, windows))
|
||||
return {
|
||||
kind: "venv",
|
||||
baseExe: windows
|
||||
? win32.join(info.basePrefix, "python.exe")
|
||||
: pathOf(info.basePrefix).join(info.basePrefix, "bin", "python3"),
|
||||
};
|
||||
if ([...candidate.sources].some((source) => source.startsWith("registry")))
|
||||
return { kind: "python.org" };
|
||||
if (
|
||||
candidate.sources.has("PATH") ||
|
||||
candidate.sources.has("which") ||
|
||||
candidate.sources.has("well-known")
|
||||
)
|
||||
return { kind: "system" };
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
function compareEnvs(a: PythonEnv, b: PythonEnv): number {
|
||||
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
||||
const rank = KIND_RANK[a.origin.kind] - KIND_RANK[b.origin.kind];
|
||||
if (rank !== 0) return rank;
|
||||
const nameA = a.origin.kind === "conda" ? a.origin.envName : "";
|
||||
const nameB = b.origin.kind === "conda" ? b.origin.envName : "";
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB);
|
||||
return a.exe.localeCompare(b.exe);
|
||||
}
|
||||
|
||||
/** Prefer a named conda env with a usable reme, then any reme env. */
|
||||
export function suggestInterpreter(envs: PythonEnv[]): string {
|
||||
const usable = envs.filter((env) => env.ok);
|
||||
// An installed-but-outdated reme does not make an interpreter ready.
|
||||
const withReme = usable.filter(
|
||||
(env) =>
|
||||
env.reme?.installed === true && remeVersionAtLeast(env.reme.version),
|
||||
);
|
||||
const named = (list: PythonEnv[]): PythonEnv | undefined =>
|
||||
list.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
|
||||
);
|
||||
return (
|
||||
(named(withReme) ?? withReme[0] ?? named(usable) ?? usable[0])?.exe ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
/** Probe reme-ai in every interpreter so the list shows setup state. */
|
||||
async function enrichWithReme(envs: PythonEnv[], io: DetectIO): Promise<void> {
|
||||
const queue = [...envs];
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const env = queue.shift();
|
||||
if (!env) return;
|
||||
env.reme = await probeReme(io, env.exe);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(6, envs.length) }, () => worker()),
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyCandidates(
|
||||
candidates: Map<string, Candidate>,
|
||||
io: DetectIO,
|
||||
): Promise<Map<string, VerifyInfo>> {
|
||||
const verified = new Map<string, VerifyInfo>();
|
||||
const queue = [...candidates.entries()];
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
const [key, candidate] = entry;
|
||||
let result: RunResult;
|
||||
try {
|
||||
result = await io.run(candidate.exe, ["-c", VERIFY_SNIPPET], {
|
||||
timeoutMs: 8_000,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (result.error || result.code !== 0) continue;
|
||||
const info = parseVerifyOutput(result.stdout);
|
||||
if (!info) continue;
|
||||
// Prefer the interpreter's own sys.executable (resolves symlinks/shims).
|
||||
verified.set(key, { ...info, exe: info.exe || candidate.exe });
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(6, queue.length) }, () => worker()),
|
||||
);
|
||||
return verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local Python interpreters, verify each one by running it, and
|
||||
* classify origins. Discovery failures are collected; they never reject.
|
||||
*/
|
||||
export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
|
||||
const windows = io.platform === "win32";
|
||||
const errors: string[] = [];
|
||||
const candidates = new Map<string, Candidate>();
|
||||
let condaRoot = "";
|
||||
let condaEnvs: string[] = [];
|
||||
|
||||
const steps: Promise<void>[] = [
|
||||
// PEP 514 registry (Windows; Miniconda registers itself here too).
|
||||
(async () => {
|
||||
if (!windows) return;
|
||||
const hives = [
|
||||
"HKCU\\Software\\Python",
|
||||
"HKLM\\Software\\Python",
|
||||
"HKLM\\Software\\WOW6432Node\\Python",
|
||||
];
|
||||
for (const hive of hives) {
|
||||
const result = await io.run("reg.exe", ["query", hive, "/s", "/ve"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (result.error) continue;
|
||||
for (const { exe, source } of parseRegQuery(result.stdout))
|
||||
addCandidate(candidates, exe, source, windows);
|
||||
}
|
||||
})(),
|
||||
// py launcher (Windows only; usually absent without a python.org install).
|
||||
(async () => {
|
||||
if (!windows) return;
|
||||
const result = await io.run("py.exe", ["-0p"], { timeoutMs: 5_000 });
|
||||
if (result.error) return;
|
||||
for (const exe of parsePyListPaths(result.stdout))
|
||||
addCandidate(candidates, exe, "py launcher", windows);
|
||||
})(),
|
||||
// PATH / which.
|
||||
(async () => {
|
||||
const first = windows
|
||||
? await io.run("where.exe", ["python"], { timeoutMs: 5_000 })
|
||||
: await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 });
|
||||
// A non-zero exit just means "no match"; a spawn/timeout error is a real
|
||||
// diagnostic and must not disappear from the report.
|
||||
if (first.error) errors.push(`python: ${safeError(first.error)}`);
|
||||
let stdout = first.stdout;
|
||||
if (!windows) {
|
||||
const extra = await io.run("which", ["-a", "python"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (extra.error) errors.push(`python: ${safeError(extra.error)}`);
|
||||
stdout = `${stdout}\n${extra.stdout}`;
|
||||
}
|
||||
for (const exe of parsePathList(stdout)) {
|
||||
if (isWindowsAppsAlias(exe)) continue;
|
||||
addCandidate(candidates, exe, windows ? "PATH" : "which", windows);
|
||||
}
|
||||
// Well-known Unix install locations, independent of the Host PATH.
|
||||
for (const exe of commonPythonPaths(io, windows))
|
||||
if (io.exists(exe))
|
||||
addCandidate(candidates, exe, "well-known", windows);
|
||||
})(),
|
||||
// conda: PATH first, then well-known install roots.
|
||||
(async () => {
|
||||
const located = windows
|
||||
? parsePathList(
|
||||
(await io.run("where.exe", ["conda"], { timeoutMs: 5_000 })).stdout,
|
||||
)
|
||||
: parsePathList(
|
||||
(await io.run("which", ["-a", "conda"], { timeoutMs: 5_000 }))
|
||||
.stdout,
|
||||
);
|
||||
for (const path of commonCondaPaths(io, windows))
|
||||
if (io.exists(path)) located.push(path);
|
||||
if (!located.length) return;
|
||||
const runConda = makeCondaRunner(
|
||||
io.run,
|
||||
located[0] ?? "",
|
||||
windows,
|
||||
io.exists,
|
||||
);
|
||||
const info = await runConda(["info", "--json"]);
|
||||
if (info.error)
|
||||
errors.push(
|
||||
`conda: discovered but not runnable (${safeError(info.error)})`,
|
||||
);
|
||||
condaRoot = info.error ? "" : parseCondaRoot(info.stdout);
|
||||
const listing = await runConda(["env", "list", "--json"]);
|
||||
condaEnvs = listing.error ? [] : parseCondaEnvs(listing.stdout);
|
||||
if (!condaRoot && condaEnvs.length) condaRoot = condaEnvs[0] ?? "";
|
||||
for (const envPath of condaEnvs) {
|
||||
addCandidate(
|
||||
candidates,
|
||||
windows
|
||||
? win32.join(envPath, "python.exe")
|
||||
: pathOf(envPath).join(envPath, "bin", "python3"),
|
||||
"conda env list",
|
||||
windows,
|
||||
);
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
errors.push(`conda: ${safeError(error)}`);
|
||||
}),
|
||||
];
|
||||
|
||||
await Promise.allSettled(steps);
|
||||
|
||||
const verified = await verifyCandidates(candidates, io);
|
||||
// Candidates can resolve to the same real executable (aliases, shims);
|
||||
// merge their discovery sources so the list stays duplicate-free.
|
||||
const merged = new Map<string, PythonEnv>();
|
||||
for (const [key, candidate] of candidates) {
|
||||
const info = verified.get(key);
|
||||
if (!info) continue;
|
||||
const exe = info.exe || candidate.exe;
|
||||
const dedupeKey = normalizeExe(exe, windows);
|
||||
const existing = merged.get(dedupeKey);
|
||||
if (existing) {
|
||||
existing.source = [
|
||||
...new Set([...existing.source.split("; "), ...candidate.sources]),
|
||||
]
|
||||
.sort()
|
||||
.join("; ");
|
||||
continue;
|
||||
}
|
||||
merged.set(dedupeKey, {
|
||||
exe,
|
||||
version: info.version,
|
||||
ok: versionAtLeast(info.version),
|
||||
origin: classifyOrigin(
|
||||
info,
|
||||
candidate,
|
||||
{ root: condaRoot, envs: condaEnvs },
|
||||
windows,
|
||||
),
|
||||
source: [...candidate.sources].sort().join("; "),
|
||||
});
|
||||
}
|
||||
const envs = [...merged.values()];
|
||||
await enrichWithReme(envs, io);
|
||||
envs.sort(compareEnvs);
|
||||
return {
|
||||
platform: io.platform,
|
||||
scannedAt: new Date().toISOString(),
|
||||
envs,
|
||||
suggested: suggestInterpreter(envs),
|
||||
condaRoot,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
212
integrations/dsh/src/environment/host/gateway.ts
Normal file
212
integrations/dsh/src/environment/host/gateway.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
||||
|
||||
import type {
|
||||
ConnectionHealth,
|
||||
EnvSummary,
|
||||
InstallStatus,
|
||||
LlmTestResult,
|
||||
PythonDetection,
|
||||
RemeCheck,
|
||||
ServiceForgetResult,
|
||||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
import type { ReMeEnvManager } from "./manager.js";
|
||||
|
||||
declare module "@deepseek-ai/cordis" {
|
||||
interface Context {
|
||||
/** Local environment manager mounted by the DSH adapter. */
|
||||
remeEnv: ReMeEnvManager;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host projection for the environment management tab.
|
||||
*
|
||||
* DSH exposes these methods in SRC mode (no generated typert artifact), which
|
||||
* parses the method source: every method takes exactly one plain identifier
|
||||
* parameter named `input`, with no destructuring, defaults, or rest, and the
|
||||
* client sends `{ args: { input: ... } }`.
|
||||
*/
|
||||
export class ReMeEnvGateway extends TypertRemoteService {
|
||||
static inject = ["remeEnv"];
|
||||
|
||||
constructor(ctx: Context) {
|
||||
// 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. */
|
||||
@Remote("python")
|
||||
async python(
|
||||
input: { refresh?: boolean } | undefined,
|
||||
): Promise<PythonDetection> {
|
||||
return this.ctx.remeEnv.detectPython(input ?? {});
|
||||
}
|
||||
|
||||
/** Inspect reme-ai, the [core] extras and the CLI for one interpreter. */
|
||||
@Remote("checkReme")
|
||||
async checkReme(
|
||||
input: { pythonPath?: string } | undefined,
|
||||
): Promise<RemeCheck> {
|
||||
return this.ctx.remeEnv.checkReme(input?.pythonPath ?? "");
|
||||
}
|
||||
|
||||
/** Start the one-click pip install and return the running job. */
|
||||
@Remote("install")
|
||||
async install(
|
||||
input:
|
||||
| {
|
||||
pythonPath?: string;
|
||||
upgrade?: boolean;
|
||||
indexUrl?: string;
|
||||
forceReinstall?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): Promise<InstallStatus> {
|
||||
return this.ctx.remeEnv.install({
|
||||
pythonPath: input?.pythonPath ?? "",
|
||||
upgrade: input?.upgrade === true,
|
||||
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
|
||||
forceReinstall: input?.forceReinstall === true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest (or named) install job progress, including the log tail. */
|
||||
@Remote("installStatus")
|
||||
async installStatus(
|
||||
input: { jobId?: string; pythonPath?: string } | undefined,
|
||||
): Promise<InstallStatus | null> {
|
||||
return this.ctx.remeEnv.installStatus(
|
||||
input?.jobId ?? "",
|
||||
input?.pythonPath ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Ask a running install to stop; polling observes the cancelled phase. */
|
||||
@Remote("installCancel")
|
||||
async installCancel(input: { jobId?: string } | undefined): Promise<boolean> {
|
||||
return this.ctx.remeEnv.installCancel(input?.jobId ?? "");
|
||||
}
|
||||
|
||||
/** Masked .env values for the workspace; secrets are never returned. */
|
||||
@Remote("readEnv")
|
||||
async readEnv(
|
||||
input: { workspaceDir?: string } | undefined,
|
||||
): Promise<EnvSummary> {
|
||||
return this.ctx.remeEnv.readEnv(input?.workspaceDir ?? "");
|
||||
}
|
||||
|
||||
/** Merge edited values into the workspace .env and return fresh state. */
|
||||
@Remote("writeEnv")
|
||||
async writeEnv(
|
||||
input:
|
||||
| { workspaceDir?: string; values?: Record<string, string> }
|
||||
| undefined,
|
||||
): Promise<EnvSummary> {
|
||||
return this.ctx.remeEnv.writeEnv(
|
||||
input?.workspaceDir ?? "",
|
||||
input?.values ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Probe the configured LLM endpoint with the selected interpreter. */
|
||||
@Remote("testLlm")
|
||||
async testLlm(
|
||||
input: { workspaceDir?: string; pythonPath?: string } | undefined,
|
||||
): Promise<LlmTestResult> {
|
||||
return this.ctx.remeEnv.testLlm(
|
||||
input?.workspaceDir ?? "",
|
||||
input?.pythonPath ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the configured endpoint from this Host. The Environment hero and the
|
||||
* service card both read Host results, so a browser on another machine cannot
|
||||
* disagree with the Host about "local".
|
||||
*/
|
||||
@Remote("health")
|
||||
async health(
|
||||
input: { endpoint?: string; authToken?: string } | undefined,
|
||||
): Promise<ConnectionHealth> {
|
||||
return this.ctx.remeEnv.health(
|
||||
input?.endpoint ?? "",
|
||||
input?.authToken ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Phase of the local ReMe service: running, conflict or stopped. */
|
||||
@Remote("serviceStatus")
|
||||
async serviceStatus(
|
||||
input: { endpoint?: string; authToken?: string } | undefined,
|
||||
): Promise<ServiceState> {
|
||||
return this.ctx.remeEnv.serviceStatus(
|
||||
input?.endpoint ?? "",
|
||||
input?.authToken ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Spawn a detached `reme start`; poll serviceStatus until healthy. */
|
||||
@Remote("serviceStart")
|
||||
async serviceStart(
|
||||
input:
|
||||
| {
|
||||
endpoint?: string;
|
||||
pythonPath?: string;
|
||||
workspaceDir?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
| undefined,
|
||||
): Promise<ServiceStartResult> {
|
||||
return this.ctx.remeEnv.serviceStart(
|
||||
input?.endpoint ?? "",
|
||||
input?.pythonPath ?? "",
|
||||
input?.workspaceDir ?? "",
|
||||
input?.authToken ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop the plugin-managed service; foreign instances are untouched. */
|
||||
@Remote("serviceStop")
|
||||
async serviceStop(
|
||||
input: { endpoint?: string } | undefined,
|
||||
): Promise<ServiceStopResult> {
|
||||
return this.ctx.remeEnv.serviceStop(input?.endpoint ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the service record without signalling the process. This is the way
|
||||
* out of a live record whose identity cannot be verified, where Start and
|
||||
* Stop both refuse to act.
|
||||
*
|
||||
* `endpoint` is the record's own endpoint, which is not necessarily the one
|
||||
* on screen, and `expectPid` pins the record so a replaced one is not
|
||||
* dropped silently.
|
||||
*/
|
||||
@Remote("serviceForget")
|
||||
async serviceForget(
|
||||
input: { endpoint?: string; expectPid?: number } | undefined,
|
||||
): Promise<ServiceForgetResult> {
|
||||
return this.ctx.remeEnv.serviceForget(
|
||||
input?.endpoint ?? "",
|
||||
typeof input?.expectPid === "number" ? input.expectPid : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Tail of the workspace service log for start-failure diagnosis. */
|
||||
@Remote("serviceLog")
|
||||
async serviceLog(
|
||||
input: { workspaceDir?: string } | undefined,
|
||||
): Promise<ServiceLog> {
|
||||
return this.ctx.remeEnv.serviceLog(input?.workspaceDir ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
export default ReMeEnvGateway;
|
||||
561
integrations/dsh/src/environment/host/install.ts
Normal file
561
integrations/dsh/src/environment/host/install.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
|
||||
import { interpreterKey } from "../interpreter-key.js";
|
||||
import { INSTALL_LOG_KEEP, openAppendLog, pruneInstallLogs } from "./log.js";
|
||||
import { isAbsolutePath } from "./paths.js";
|
||||
|
||||
export interface InstallChild {
|
||||
pid: number | undefined;
|
||||
onExit(callback: (code: number | null) => void): void;
|
||||
/** `force` escalates to SIGKILL on POSIX; Windows always force-kills. */
|
||||
kill(force?: boolean): void;
|
||||
/**
|
||||
* Whether any process of the managed process group still exists.
|
||||
*
|
||||
* Only the leader's exit is observable through `onExit`, so a build child
|
||||
* that ignored the graceful signal can outlive it. POSIX starts the install
|
||||
* in its own group and can answer this; Windows kills the tree with
|
||||
* `taskkill /T`, so it reports false. Omitted when the group cannot be
|
||||
* inspected, which makes an observed exit settle the job.
|
||||
*/
|
||||
groupAlive?(): boolean;
|
||||
}
|
||||
|
||||
export interface InstallSpawner {
|
||||
start(options: {
|
||||
exe: string;
|
||||
args: string[];
|
||||
logPath: string;
|
||||
}): InstallChild;
|
||||
}
|
||||
|
||||
/** Compose the pip invocation for `reme-ai[core]`. */
|
||||
export function pipArgs(request: InstallRequest): string[] {
|
||||
const args = [
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
];
|
||||
if (request.upgrade) args.push("-U");
|
||||
// `pip install reme-ai[core]` reports "already satisfied" for a broken but
|
||||
// present install, so an explicit Reinstall must force it.
|
||||
if (request.forceReinstall) args.push("--force-reinstall");
|
||||
const indexUrl = (request.indexUrl ?? "").trim();
|
||||
if (indexUrl) {
|
||||
if (!/^https?:\/\/\S+$/i.test(indexUrl))
|
||||
throw new TypeError("Package index must be an http(s) URL");
|
||||
// Credentials embedded in the URL would be echoed in the job command, the
|
||||
// install log and the browser; pip's own config is the safe place for them.
|
||||
const parsed = new URL(indexUrl);
|
||||
if (parsed.username || parsed.password)
|
||||
throw new TypeError(
|
||||
"Package index must not embed credentials; configure them in pip.conf",
|
||||
);
|
||||
args.push("--index-url", indexUrl);
|
||||
}
|
||||
args.push("reme-ai[core]");
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last `maxLines` non-empty lines of a log file, normalized to "\n". Only the
|
||||
* tail is read: this runs on every 2 s poll and the log can grow for minutes.
|
||||
*/
|
||||
export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, "r");
|
||||
const size = fstatSync(fd).size;
|
||||
const length = Math.min(size, maxBytes);
|
||||
const start = size - length;
|
||||
const buffer = Buffer.alloc(length);
|
||||
readSync(fd, buffer, 0, length, start);
|
||||
let text = buffer.toString("utf8");
|
||||
if (start > 0) {
|
||||
// The cut may be mid-line and even mid-character; drop that partial line
|
||||
// so the tail never starts with a replacement character.
|
||||
const firstBreak = text.indexOf("\n");
|
||||
text =
|
||||
firstBreak >= 0
|
||||
? text.slice(firstBreak + 1)
|
||||
: text.replace(/^\uFFFD+/, "");
|
||||
}
|
||||
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.slice(-maxLines)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn pip with stdout/stderr appended to a per-job log file. */
|
||||
export function createInstallSpawner(
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): InstallSpawner {
|
||||
return {
|
||||
start({ exe, args, logPath }) {
|
||||
const fd = openAppendLog(logPath);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
// POSIX: lead a process group so a cancel can stop pip's build
|
||||
// backends too, not only pip itself.
|
||||
detached: process.platform !== "win32",
|
||||
env: {
|
||||
...base,
|
||||
PYTHONIOENCODING: "utf-8",
|
||||
PYTHONUTF8: "1",
|
||||
PYTHONUNBUFFERED: "1",
|
||||
},
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
// The child owns its inherited handle; the parent copy is no longer needed.
|
||||
closeSync(fd);
|
||||
}
|
||||
return {
|
||||
pid: child.pid,
|
||||
onExit(callback) {
|
||||
let settled = false;
|
||||
const finish = (code: number | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
callback(code);
|
||||
};
|
||||
child.on("close", finish);
|
||||
child.on("error", () => finish(null));
|
||||
},
|
||||
kill(force = false) {
|
||||
if (child.pid !== undefined && process.platform === "win32") {
|
||||
// Tree-kill: pip's build backends are child processes.
|
||||
const killer = spawn(
|
||||
"taskkill",
|
||||
["/PID", String(child.pid), "/T", "/F"],
|
||||
{ windowsHide: true, stdio: "ignore" },
|
||||
);
|
||||
// Without this listener an async spawn failure would exit the Host.
|
||||
killer.on("error", () => undefined);
|
||||
killer.unref();
|
||||
return;
|
||||
}
|
||||
const signal = force ? "SIGKILL" : "SIGTERM";
|
||||
// The install process leads its own group on POSIX, so pip's build
|
||||
// backends receive the signal as well.
|
||||
try {
|
||||
if (child.pid !== undefined) process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Not a group leader (e.g. spawn failed); signal the process only.
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
},
|
||||
groupAlive() {
|
||||
// Only POSIX puts the install in its own process group, and only
|
||||
// there can the leader exit while the group outlives it.
|
||||
if (process.platform === "win32" || child.pid === undefined)
|
||||
return false;
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// ESRCH means the group is empty; anything else cannot be proven
|
||||
// empty, so the job waits instead of settling early.
|
||||
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two interpreter paths name the same interpreter.
|
||||
*
|
||||
* The path flavor decides the comparison: Windows paths fold case and
|
||||
* separators, POSIX paths do not, because they are case-sensitive there. The
|
||||
* tab compares the same way, so a job it found is never dropped again.
|
||||
*/
|
||||
function sameInterpreter(left: string, right: string): boolean {
|
||||
return interpreterKey(left) === interpreterKey(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace period between the graceful termination signal and the
|
||||
* force-kill of a cancelled job. pip gets a moment to clean up its build
|
||||
* children before it is killed outright.
|
||||
*/
|
||||
const DEFAULT_KILL_GRACE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Default grace period for a disposed job.
|
||||
*
|
||||
* Zero: the plugin is unloading, so the force-kill follows the graceful signal
|
||||
* immediately and a child can never outlive the Host.
|
||||
*/
|
||||
const DEFAULT_DISPOSE_GRACE_MS = 0;
|
||||
|
||||
/**
|
||||
* How often a cancelled job whose group outlived its leader is re-checked.
|
||||
* Small enough to settle promptly, large enough not to busy-wait.
|
||||
*/
|
||||
const DEFAULT_GROUP_POLL_MS = 100;
|
||||
|
||||
/**
|
||||
* How long a cancelled job waits for a process group it already force-killed
|
||||
* to empty. A group still populated after this stays unconfirmed (and locked)
|
||||
* rather than being reported as a cancellation that did not happen.
|
||||
*/
|
||||
const DEFAULT_GROUP_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
phase: InstallPhase;
|
||||
pythonPath: string;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
logPath: string;
|
||||
child: InstallChild;
|
||||
/** Set between a termination request and the pip process actually exiting. */
|
||||
cancelling: boolean;
|
||||
/**
|
||||
* Escalation timer that force-kills the child after the grace period.
|
||||
*
|
||||
* It never settles the job: only an observed exit does.
|
||||
*/
|
||||
terminateTimer?: ReturnType<typeof setTimeout>;
|
||||
/**
|
||||
* Poll that waits for the whole process group of a cancelled job to empty
|
||||
* after its leader already exited. It settles the job once the group is
|
||||
* gone; until then the interpreter stays locked.
|
||||
*/
|
||||
groupTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface InstallJobsOptions {
|
||||
spawner: InstallSpawner;
|
||||
logDir: string;
|
||||
/**
|
||||
* Grace period between the graceful termination signal and the force-kill
|
||||
* of a cancelled job. Defaults to 5 s, so pip can clean up before it is
|
||||
* force-killed.
|
||||
*/
|
||||
killGraceMs?: number;
|
||||
/**
|
||||
* Grace period between the graceful termination signal and the force-kill
|
||||
* of a disposed job. Defaults to 0: the plugin is unloading, so the
|
||||
* force-kill follows immediately and a child can never outlive the Host.
|
||||
*/
|
||||
disposeGraceMs?: number;
|
||||
/**
|
||||
* How often a cancelled job re-checks a process group whose leader already
|
||||
* exited. Defaults to 100 ms.
|
||||
*/
|
||||
groupPollMs?: number;
|
||||
/**
|
||||
* How long a cancelled job waits for that group to empty after the
|
||||
* force-kill. Defaults to 5 s; a group still populated afterwards keeps the
|
||||
* job unconfirmed instead of reporting a completed cancellation.
|
||||
*/
|
||||
groupTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-running pip install at a time per interpreter. Jobs live in Host
|
||||
* memory and write a log file, so closing the browser keeps them running and
|
||||
* a running job can be restored by polling the latest status.
|
||||
*/
|
||||
export class InstallJobs {
|
||||
private readonly jobs = new Map<string, Job>();
|
||||
private latest: string | undefined;
|
||||
private sequence = 0;
|
||||
private readonly killGraceMs: number;
|
||||
private readonly disposeGraceMs: number;
|
||||
private readonly groupPollMs: number;
|
||||
private readonly groupTimeoutMs: number;
|
||||
|
||||
constructor(private readonly options: InstallJobsOptions) {
|
||||
this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
||||
this.disposeGraceMs = options.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS;
|
||||
this.groupPollMs = options.groupPollMs ?? DEFAULT_GROUP_POLL_MS;
|
||||
this.groupTimeoutMs = options.groupTimeoutMs ?? DEFAULT_GROUP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
start(request: InstallRequest): InstallStatus {
|
||||
const pythonPath = (request.pythonPath ?? "").trim();
|
||||
// The interpreter may be written for another platform than the host, so its
|
||||
// own flavor decides whether the path is absolute.
|
||||
if (!pythonPath || !isAbsolutePath(pythonPath))
|
||||
throw new TypeError(
|
||||
"Install requires an absolute Python interpreter path",
|
||||
);
|
||||
// The same lookup the status view uses: a job that is still running for
|
||||
// this interpreter owns it, even when its termination is unconfirmed.
|
||||
const running = this.runningFor(pythonPath);
|
||||
if (running) return this.snapshot(running);
|
||||
const args = pipArgs(request);
|
||||
const id = `install-${Date.now().toString(
|
||||
36,
|
||||
)}-${(this.sequence += 1).toString(36)}`;
|
||||
mkdirSync(this.options.logDir, { recursive: true });
|
||||
const logPath = join(this.options.logDir, `${id}.log`);
|
||||
const child = this.options.spawner.start({
|
||||
exe: pythonPath,
|
||||
args,
|
||||
logPath,
|
||||
});
|
||||
const job: Job = {
|
||||
id,
|
||||
phase: "running",
|
||||
pythonPath,
|
||||
command: [pythonPath, ...args].join(" "),
|
||||
startedAt: new Date().toISOString(),
|
||||
logPath,
|
||||
child,
|
||||
cancelling: false,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of one job, of the newest running job for an interpreter, or of the
|
||||
* newest job overall.
|
||||
*
|
||||
* The interpreter lookup is what lets a tab restore the job it lost: two
|
||||
* interpreters may install at the same time, so the newest job overall is
|
||||
* not necessarily the one on screen.
|
||||
*/
|
||||
status(jobId?: string, pythonPath?: string): InstallStatus | null {
|
||||
const trimmed = (jobId ?? "").trim();
|
||||
if (trimmed) {
|
||||
const job = this.jobs.get(trimmed);
|
||||
return job ? this.snapshot(job) : null;
|
||||
}
|
||||
const wanted = (pythonPath ?? "").trim();
|
||||
if (wanted) {
|
||||
// Only a running job is restored: a finished one belongs to the tab that
|
||||
// started it, which shows its own result for it.
|
||||
const job = this.runningFor(wanted);
|
||||
return job ? this.snapshot(job) : null;
|
||||
}
|
||||
const job = this.jobs.get(this.key(jobId));
|
||||
return job ? this.snapshot(job) : null;
|
||||
}
|
||||
|
||||
/** The newest running job for one interpreter, if there is one. */
|
||||
private runningFor(pythonPath: string): Job | undefined {
|
||||
let found: Job | undefined;
|
||||
// The map keeps insertion order, so the last match is the newest job.
|
||||
for (const job of this.jobs.values()) {
|
||||
if (job.phase !== "running") continue;
|
||||
if (!sameInterpreter(job.pythonPath, pythonPath)) continue;
|
||||
found = job;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask pip to stop. The job keeps the interpreter locked (phase stays
|
||||
* `running`, `cancelling` set) until the process has actually exited, so a
|
||||
* second installation cannot start while the first is still dying.
|
||||
*
|
||||
* A process that ignores the graceful signal is force-killed after the
|
||||
* grace period. The escalation never settles the job: only an observed
|
||||
* exit does, so a failed termination stays visible instead of being
|
||||
* reported as a completed cancellation.
|
||||
*/
|
||||
cancel(jobId?: string): boolean {
|
||||
const job = this.jobs.get(this.key(jobId));
|
||||
if (!job || job.phase !== "running" || job.cancelling) return false;
|
||||
this.terminate(job, this.killGraceMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop every running installation; called when the plugin unloads.
|
||||
*
|
||||
* The jobs are not settled here: only an observed exit settles them. The
|
||||
* default grace period is zero, so the force-kill follows the graceful
|
||||
* signal immediately and a child that ignores it cannot outlive the Host.
|
||||
*/
|
||||
dispose(): void {
|
||||
for (const job of this.jobs.values()) {
|
||||
if (job.phase !== "running") continue;
|
||||
this.terminate(job, this.disposeGraceMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one termination path, shared by cancellation and disposal.
|
||||
*
|
||||
* It sends the graceful signal first and escalates to the force-kill after
|
||||
* `graceMs`. It never settles the job: only an observed exit does — and, for
|
||||
* a cancelled job whose leader exited first, an empty managed group — so the
|
||||
* interpreter stays locked while any install process is still alive and a
|
||||
* failed termination stays visible.
|
||||
*/
|
||||
private terminate(job: Job, graceMs: number): void {
|
||||
this.clearTerminateTimer(job);
|
||||
job.cancelling = true;
|
||||
job.child.kill();
|
||||
if (graceMs <= 0) {
|
||||
// No grace period: the force-kill follows immediately, and nothing is
|
||||
// left pending that could fire later or keep the Host alive.
|
||||
job.child.kill(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
job.terminateTimer = undefined;
|
||||
job.child.kill(true);
|
||||
}, graceMs);
|
||||
// A pending escalation must never keep the Host process alive.
|
||||
timer.unref();
|
||||
job.terminateTimer = timer;
|
||||
}
|
||||
|
||||
/** Drop the pending escalation timer of a job. */
|
||||
private clearTerminateTimer(job: Job): void {
|
||||
if (!job.terminateTimer) return;
|
||||
clearTimeout(job.terminateTimer);
|
||||
job.terminateTimer = undefined;
|
||||
}
|
||||
|
||||
private key(jobId?: string): string {
|
||||
const trimmed = (jobId ?? "").trim();
|
||||
return trimmed || this.latest || "";
|
||||
}
|
||||
|
||||
/** Settle a running job exactly once, from an observed exit. */
|
||||
private finish(job: Job, code: number | null): void {
|
||||
if (job.phase !== "running") return;
|
||||
this.clearTerminateTimer(job);
|
||||
// A cancelled leader can exit while a build child that ignored the
|
||||
// graceful signal keeps running. Only an empty group settles the job, so
|
||||
// the interpreter stays locked until nothing of the install is left.
|
||||
if (job.cancelling && this.groupAlive(job)) {
|
||||
this.awaitGroupExit(job, code);
|
||||
return;
|
||||
}
|
||||
this.settle(job, code);
|
||||
}
|
||||
|
||||
/** Write the final phase of an already stopped job. */
|
||||
private settle(job: Job, code: number | null): void {
|
||||
this.clearTerminateTimer(job);
|
||||
this.clearGroupTimer(job);
|
||||
job.phase = job.cancelling
|
||||
? "cancelled"
|
||||
: code === 0
|
||||
? "succeeded"
|
||||
: "failed";
|
||||
job.exitCode = code;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
job.cancelling = false;
|
||||
}
|
||||
|
||||
/** Whether the managed process group still holds a live process. */
|
||||
private groupAlive(job: Job): boolean {
|
||||
return job.child.groupAlive?.() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill the group that outlived its leader, then settle only once it is
|
||||
* empty.
|
||||
*
|
||||
* The job stays `running` (and the interpreter locked) for the whole wait,
|
||||
* so a second installation cannot start while a build child still runs. A
|
||||
* group that does not empty within the timeout keeps the job unconfirmed
|
||||
* instead of reporting a cancellation that did not happen.
|
||||
*
|
||||
* The group id is the leader's pid, and the force-kill is sent in the short
|
||||
* window right after its exit was observed; a pathological pid reuse inside
|
||||
* that window is inherent to signalling a process group. The liveness probe
|
||||
* uses signal 0, which also counts an unreaped zombie as present: the group
|
||||
* keeps reading as non-empty until the zombie is reaped, so a dead group can
|
||||
* take more than one poll to be seen as empty.
|
||||
*/
|
||||
private awaitGroupExit(job: Job, code: number | null): void {
|
||||
this.clearGroupTimer(job);
|
||||
job.child.kill(true);
|
||||
const deadline = Date.now() + this.groupTimeoutMs;
|
||||
const poll = (): void => {
|
||||
job.groupTimer = undefined;
|
||||
if (job.phase !== "running") return;
|
||||
if (!this.groupAlive(job)) {
|
||||
this.settle(job, code);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) return;
|
||||
job.groupTimer = this.after(this.groupPollMs, poll);
|
||||
};
|
||||
job.groupTimer = this.after(this.groupPollMs, poll);
|
||||
}
|
||||
|
||||
/** Drop the pending group poll of a job. */
|
||||
private clearGroupTimer(job: Job): void {
|
||||
if (!job.groupTimer) return;
|
||||
clearTimeout(job.groupTimer);
|
||||
job.groupTimer = undefined;
|
||||
}
|
||||
|
||||
/** Never let a pending wait keep the Host process alive. */
|
||||
private after(
|
||||
delayMs: number,
|
||||
callback: () => void,
|
||||
): ReturnType<typeof setTimeout> {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
timer.unref();
|
||||
return timer;
|
||||
}
|
||||
|
||||
/** Serialize only JSON-safe values; optional fields are omitted when unset. */
|
||||
private snapshot(job: Job): InstallStatus {
|
||||
return {
|
||||
id: job.id,
|
||||
phase: job.phase,
|
||||
pythonPath: job.pythonPath,
|
||||
command: job.command,
|
||||
startedAt: job.startedAt,
|
||||
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
|
||||
...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
|
||||
...(job.cancelling ? { cancelling: true } : {}),
|
||||
logPath: job.logPath,
|
||||
logTail: tailLog(job.logPath),
|
||||
};
|
||||
}
|
||||
}
|
||||
74
integrations/dsh/src/environment/host/log.ts
Normal file
74
integrations/dsh/src/environment/host/log.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
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;
|
||||
/** 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.
|
||||
* Install and service logs otherwise grow without bound because both are
|
||||
* append-only and outlive many sessions.
|
||||
*/
|
||||
export function openAppendLog(
|
||||
path: string,
|
||||
maxBytes = LOG_ROTATE_BYTES,
|
||||
): number {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
try {
|
||||
if (statSync(path).size > maxBytes) renameSync(path, `${path}.1`);
|
||||
} catch {
|
||||
// A missing log has nothing to rotate; a locked one (Windows, while the
|
||||
// service still writes it) is left alone rather than failing the launch.
|
||||
}
|
||||
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.
|
||||
*
|
||||
* `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"),
|
||||
);
|
||||
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)) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
774
integrations/dsh/src/environment/host/manager.ts
Normal file
774
integrations/dsh/src/environment/host/manager.ts
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { detectPythonEnvs } from "./detect.js";
|
||||
import { envUpdates, storedState } from "../env-fields.js";
|
||||
import { canHostService } from "../dependency.js";
|
||||
import { isLocalEndpoint } from "../endpoint-rules.js";
|
||||
import { createInstallSpawner, InstallJobs, tailLog } from "./install.js";
|
||||
import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js";
|
||||
import { createNodeRunner } from "./runner.js";
|
||||
import {
|
||||
createServiceIO,
|
||||
forgetService,
|
||||
processAlive,
|
||||
readServiceState,
|
||||
removeServiceState,
|
||||
serviceEndpointKey,
|
||||
serviceOriginKey,
|
||||
serviceOwnership,
|
||||
serviceRecordRef,
|
||||
serviceStatus,
|
||||
startService,
|
||||
stopService,
|
||||
writeServiceState,
|
||||
} from "./service.js";
|
||||
import type { PersistedService, ServiceIO } from "./service.js";
|
||||
import type { ServiceOwnership } from "./service.js";
|
||||
import {
|
||||
envSummary,
|
||||
mergeEnvFile,
|
||||
parseEnvFile,
|
||||
resolveWorkspaceDir,
|
||||
testLlm,
|
||||
validateEnvUpdate,
|
||||
writeEnvText,
|
||||
} from "./workspace.js";
|
||||
import type {
|
||||
ConnectionHealth,
|
||||
EnvSummary,
|
||||
InstallRequest,
|
||||
InstallStatus,
|
||||
LlmTestResult,
|
||||
PythonDetection,
|
||||
RemeCheck,
|
||||
ServiceForgetResult,
|
||||
ServiceLog,
|
||||
ServiceRecordRef,
|
||||
ServiceStartErrorCode,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
|
||||
const DEFAULT_ENDPOINT = "http://127.0.0.1:2333";
|
||||
const CACHE_MS = 60_000;
|
||||
/** How long a spawn is treated as "already starting" while it comes up. */
|
||||
const START_GRACE_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Host-side environment manager: the one place that runs local operations and
|
||||
* hands the results to the gateway.
|
||||
*
|
||||
* Everything here runs on the machine DSH runs on, so the tab and the Host can
|
||||
* never disagree about which Python, which `.env` or which service "local"
|
||||
* means. Lifetimes are explicit:
|
||||
*
|
||||
* - Python probes and reme-ai checks are bounded one-shot commands (`timeoutMs`).
|
||||
* - A pip installation is a job in this manager's registry; {@link dispose}
|
||||
* stops the ones still running when the plugin unloads, and its log survives
|
||||
* in `$DSH_HOME/reme-memory`.
|
||||
* - The ReMe service is detached on purpose: it must outlive DSH, so it is not
|
||||
* handed to DSH's subprocess provider, which kills its children on Host exit.
|
||||
* It is stopped only through the ownership-checked adapter in `service.ts`.
|
||||
*/
|
||||
export class ReMeEnvManager {
|
||||
private cache?: { at: number; detection: PythonDetection };
|
||||
private readonly installs: InstallJobs;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly serviceIO: ServiceIO;
|
||||
private readonly serviceStatePath: string;
|
||||
/** Serializes starts so two requests cannot spawn two services. */
|
||||
private startQueue: Promise<unknown> = Promise.resolve();
|
||||
private starting?: { endpoint: string; at: number };
|
||||
|
||||
constructor(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
serviceIO: ServiceIO = createServiceIO(),
|
||||
) {
|
||||
this.env = env;
|
||||
this.serviceIO = serviceIO;
|
||||
const home = env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
||||
this.installs = new InstallJobs({
|
||||
spawner: createInstallSpawner(env),
|
||||
logDir: join(home, "reme-memory"),
|
||||
});
|
||||
this.serviceStatePath = join(home, "reme-memory", "service.json");
|
||||
}
|
||||
|
||||
async detectPython(
|
||||
options: { refresh?: boolean } = {},
|
||||
): Promise<PythonDetection> {
|
||||
if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS)
|
||||
return this.cache.detection;
|
||||
const detection = await detectPythonEnvs({
|
||||
run: createNodeRunner(this.env),
|
||||
platform: process.platform,
|
||||
env: this.env,
|
||||
homedir: homedir(),
|
||||
exists: existsSync,
|
||||
});
|
||||
this.cache = { at: Date.now(), detection };
|
||||
return detection;
|
||||
}
|
||||
|
||||
async checkReme(pythonPath: string): Promise<RemeCheck> {
|
||||
return checkReme(
|
||||
{
|
||||
run: createNodeRunner(this.env),
|
||||
platform: process.platform,
|
||||
exists: existsSync,
|
||||
},
|
||||
pythonPath,
|
||||
);
|
||||
}
|
||||
|
||||
install(request: InstallRequest): InstallStatus {
|
||||
return this.installs.start(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of one job, of the newest running job for an interpreter, or of the
|
||||
* newest job overall. The interpreter lookup is what lets a tab restore the
|
||||
* job it lost: two interpreters may install at the same time, so "the latest
|
||||
* job" is not necessarily the one on screen.
|
||||
*/
|
||||
installStatus(jobId?: string, pythonPath?: string): InstallStatus | null {
|
||||
return this.installs.status(jobId, pythonPath);
|
||||
}
|
||||
|
||||
installCancel(jobId?: string): boolean {
|
||||
return this.installs.cancel(jobId);
|
||||
}
|
||||
|
||||
/** Stop the installations this manager owns, on plugin disposal. */
|
||||
dispose(): void {
|
||||
this.installs.dispose();
|
||||
}
|
||||
|
||||
/** Masked .env projection for the selected workspace. */
|
||||
readEnv(workspaceDir: string): EnvSummary {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
return envSummary(join(dir, ".env"), dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge edited values into the workspace .env. The whole merged file is
|
||||
* validated, so an untouched real key still satisfies the requirement and a
|
||||
* cleared one fails here rather than at the next service start.
|
||||
*/
|
||||
writeEnv(workspaceDir: string, values: Record<string, string>): EnvSummary {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const path = join(dir, ".env");
|
||||
let existing = "";
|
||||
try {
|
||||
existing = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
// First write creates the file.
|
||||
}
|
||||
const stored = parseEnvFile(existing);
|
||||
// The update rules are applied here as well as in the tab: a client that
|
||||
// predates them must not be able to clear a stored credential by sending
|
||||
// an empty value for it.
|
||||
const merged = mergeEnvFile(
|
||||
existing,
|
||||
envUpdates(values, storedState(stored)),
|
||||
);
|
||||
validateEnvUpdate(parseEnvFile(merged));
|
||||
writeEnvText(path, merged);
|
||||
return envSummary(path, dir);
|
||||
}
|
||||
|
||||
/** One-off LLM connectivity probe reading the workspace .env. */
|
||||
async testLlm(
|
||||
workspaceDir: string,
|
||||
pythonPath: string,
|
||||
): Promise<LlmTestResult> {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
return testLlm(
|
||||
{ run: createNodeRunner(this.env) },
|
||||
{ workspaceDir: dir, pythonPath },
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the configured ReMe endpoint answers, probed from this Host. */
|
||||
async health(endpoint: string, authToken = ""): Promise<ConnectionHealth> {
|
||||
const endpointValue = this.endpoint(endpoint);
|
||||
const health = await this.serviceIO.fetchHealth(endpointValue, authToken);
|
||||
return {
|
||||
endpoint: endpointValue,
|
||||
ok: health.ok,
|
||||
version: health.version,
|
||||
error: health.error,
|
||||
...(health.code === undefined ? {} : { code: health.code }),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Current service phase (running / conflict / stopped) and ownership. */
|
||||
async serviceStatus(endpoint: string, authToken = ""): Promise<ServiceState> {
|
||||
const endpointValue = this.endpoint(endpoint);
|
||||
// A record left under a path prefix of the same origin describes the
|
||||
// service this endpoint talks to. It is re-keyed to the endpoint on screen
|
||||
// first, so the tab can manage (stop) it and the start path can replace
|
||||
// it instead of treating our own service as a foreign process.
|
||||
await this.adoptLeftover(endpointValue);
|
||||
const persisted = readServiceState(this.serviceStatePath);
|
||||
// A malformed record must not turn the log path into a relative one.
|
||||
let logDir = resolveWorkspaceDir("", homedir());
|
||||
const recordedDir = persisted?.workspaceDir?.trim() ?? "";
|
||||
if (recordedDir) {
|
||||
try {
|
||||
logDir = resolveWorkspaceDir(recordedDir, homedir());
|
||||
} catch {
|
||||
// Keep the default log directory.
|
||||
}
|
||||
}
|
||||
const status = await serviceStatus(this.serviceIO, {
|
||||
endpoint: endpointValue,
|
||||
statePath: this.serviceStatePath,
|
||||
authToken,
|
||||
logPath: join(logDir, "service.log"),
|
||||
});
|
||||
if (this.starting?.endpoint === endpointValue) {
|
||||
// Running and conflict both mean the port answered: the launch is no
|
||||
// longer "in flight". Stopped without a live pid means it died before it
|
||||
// ever listened, so a retry must not be blocked either. A stopped status
|
||||
// *with* a pid is a service still booting.
|
||||
if (
|
||||
status.phase === "running" ||
|
||||
status.phase === "conflict" ||
|
||||
(status.phase === "stopped" && status.pid === undefined)
|
||||
)
|
||||
this.starting = undefined;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `reme start` detached; the caller polls serviceStatus. Starts are
|
||||
* serialized and re-check the phase inside the lock, so two requests cannot
|
||||
* spawn two services and overwrite the one recorded pid.
|
||||
*/
|
||||
serviceStart(
|
||||
endpoint: string,
|
||||
pythonPath: string,
|
||||
workspaceDir: string,
|
||||
authToken = "",
|
||||
): Promise<ServiceStartResult> {
|
||||
return this.enqueue(() =>
|
||||
this.startLocked(endpoint, pythonPath, workspaceDir, authToken),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the plugin-managed service; a foreign service stays untouched. Stop
|
||||
* joins the start queue, so a request that arrives while a launch is still
|
||||
* being recorded waits for it and then stops that launch instead of
|
||||
* reporting "nothing started".
|
||||
*/
|
||||
serviceStop(endpoint: string): Promise<ServiceStopResult> {
|
||||
const endpointValue = this.endpoint(endpoint);
|
||||
return this.enqueue(() => this.stopLocked(endpointValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a service record without touching the process. It is the escape hatch
|
||||
* for a live process whose identity cannot be verified (Stop and Start both
|
||||
* refuse to act on it). Joins the start queue so a launch that is still being
|
||||
* recorded completes first.
|
||||
*
|
||||
* `expectPid` pins the record the caller saw: a record that was replaced in
|
||||
* the meantime is refused instead of being dropped silently.
|
||||
*/
|
||||
serviceForget(
|
||||
endpoint: string,
|
||||
expectPid?: number,
|
||||
): Promise<ServiceForgetResult> {
|
||||
const endpointValue = this.endpoint(endpoint);
|
||||
return this.enqueue(async () => {
|
||||
const result = forgetService({
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: endpointValue,
|
||||
...(expectPid === undefined ? {} : { expectPid }),
|
||||
});
|
||||
if (result.forgotten && this.starting?.endpoint === endpointValue)
|
||||
this.starting = undefined;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Tail of the workspace service log. */
|
||||
serviceLog(workspaceDir: string): ServiceLog {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const currentPath = join(dir, "service.log");
|
||||
const current = tailLog(currentPath, 40);
|
||||
if (current) return { logPath: currentPath, lines: current };
|
||||
// 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. The
|
||||
// returned path is the file actually read, so the UI labels it correctly.
|
||||
const rotatedPath = `${currentPath}.1`;
|
||||
const rotated = tailLog(rotatedPath, 40);
|
||||
return rotated
|
||||
? { logPath: rotatedPath, lines: rotated }
|
||||
: { logPath: currentPath, lines: "" };
|
||||
}
|
||||
|
||||
/** DSH-startup best effort: start a stopped local service when configured. */
|
||||
async autoStart(options: {
|
||||
endpoint: string;
|
||||
pythonPath: string;
|
||||
workspaceDir: string;
|
||||
authToken?: string;
|
||||
}): Promise<void> {
|
||||
// Only a root-path loopback endpoint can become a local service. A
|
||||
// configured gateway prefix (or a remote URL) is probed elsewhere, and a
|
||||
// start could at best fail after stopping a same-origin service: skip it
|
||||
// instead of reporting a failure on every DSH start.
|
||||
let launchable = false;
|
||||
try {
|
||||
launchable = isLocalEndpoint(this.endpoint(options.endpoint));
|
||||
} catch {
|
||||
launchable = false;
|
||||
}
|
||||
if (!launchable) return;
|
||||
const status = await this.serviceStatus(
|
||||
options.endpoint,
|
||||
options.authToken ?? "",
|
||||
);
|
||||
if (status.phase === "running") return;
|
||||
let pythonPath = options.pythonPath.trim();
|
||||
if (!pythonPath) {
|
||||
const detection = await this.detectPython();
|
||||
pythonPath = detection.suggested;
|
||||
}
|
||||
if (!pythonPath) return;
|
||||
await this.serviceStart(
|
||||
options.endpoint,
|
||||
pythonPath,
|
||||
options.workspaceDir,
|
||||
options.authToken ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize service operations: a start, a stop and a forget must not
|
||||
* interleave, or two of them could act on the same record at once. A failed
|
||||
* task must not wedge the queue, so it is swallowed here.
|
||||
*/
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.startQueue.then(task);
|
||||
this.startQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused start. Every reason produces the same shape, so the client reads
|
||||
* one result contract instead of guessing from the message.
|
||||
*/
|
||||
private refused(
|
||||
logPath: string,
|
||||
startedAt: string,
|
||||
code: ServiceStartErrorCode,
|
||||
error: string,
|
||||
extra?: { pid?: number; record?: ServiceRecordRef },
|
||||
): ServiceStartResult {
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath,
|
||||
startedAt,
|
||||
error,
|
||||
code,
|
||||
...(extra?.pid === undefined ? {} : { pid: extra.pid }),
|
||||
...(extra?.record === undefined ? {} : { record: extra.record }),
|
||||
};
|
||||
}
|
||||
|
||||
private endpoint(raw: string): string {
|
||||
const value = raw.trim() || DEFAULT_ENDPOINT;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new TypeError("Invalid ReMe endpoint");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
throw new TypeError("ReMe endpoint must be an http(s) URL");
|
||||
if (url.username || url.password)
|
||||
throw new TypeError("ReMe endpoint must not contain credentials");
|
||||
if (url.search || url.hash)
|
||||
throw new TypeError(
|
||||
"ReMe endpoint must not contain a query string or fragment",
|
||||
);
|
||||
// The client appends job names to the endpoint, so it never keeps a
|
||||
// trailing slash; normalising here is what makes records comparable.
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
|
||||
}
|
||||
|
||||
private async stopLocked(endpointValue: string): Promise<ServiceStopResult> {
|
||||
// A leftover record under a path prefix of the same origin is this
|
||||
// service: it is re-keyed first, so a stop that arrives before any
|
||||
// status read still acts on the right record instead of refusing it as
|
||||
// foreign.
|
||||
await this.adoptLeftover(endpointValue);
|
||||
const result = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: endpointValue,
|
||||
});
|
||||
if (result.stopped && this.starting?.endpoint === endpointValue)
|
||||
this.starting = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded service and what can be proven about it.
|
||||
*
|
||||
* The record is read in one place and its ownership decided in one place,
|
||||
* because the verdict drives three decisions: whether a start may replace
|
||||
* the process, whether the record blocks it, and which record the tab may
|
||||
* forget.
|
||||
*
|
||||
* Ownership is decided against the record's *own* endpoint: the launch
|
||||
* configures only host and port, so a record filed under a path prefix
|
||||
* still describes the service the endpoint on screen talks to.
|
||||
*/
|
||||
private async recordedService(): Promise<{
|
||||
record?: PersistedService;
|
||||
ownership: ServiceOwnership;
|
||||
alive: boolean;
|
||||
}> {
|
||||
const record = readServiceState(this.serviceStatePath);
|
||||
if (!record || record.pid === undefined)
|
||||
return { ownership: "foreign", alive: false };
|
||||
if (!processAlive(this.serviceIO, record.pid))
|
||||
return { record, ownership: "foreign", alive: false };
|
||||
return {
|
||||
record,
|
||||
alive: true,
|
||||
ownership: await serviceOwnership(
|
||||
this.serviceIO,
|
||||
record,
|
||||
record.endpoint,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two endpoints name the same local service (host and port). */
|
||||
private sameOrigin(recorded: string, endpoint: string): boolean {
|
||||
return serviceOriginKey(recorded) === serviceOriginKey(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorded service is the one the endpoint on screen talks to:
|
||||
* the same endpoint, or the same local service under another path.
|
||||
*/
|
||||
private relatedRecord(recorded: string, endpoint: string): boolean {
|
||||
return (
|
||||
serviceEndpointKey(recorded) === serviceEndpointKey(endpoint) ||
|
||||
this.sameOrigin(recorded, endpoint)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key a leftover same-origin record to the endpoint on screen.
|
||||
*
|
||||
* Only the endpoint the record is filed under changes: it stays the record
|
||||
* of the same process, so the tab can stop it and the start path can replace
|
||||
* it. A failed write is not fatal — the start path can still replace the
|
||||
* record by its own endpoint.
|
||||
*
|
||||
* Only a record that is provably ours and describes the same local service
|
||||
* is re-keyed: the same origin, a live pid, a Python image and a command
|
||||
* line that carries the recorded `workspace_dir` and `service.port`. A
|
||||
* request that cannot launch anything never qualifies: a prefixed request
|
||||
* is refused by `buildStartCommand`, so stopping a healthy same-origin
|
||||
* service first would leave nothing running (autoStart would do that on
|
||||
* every DSH start).
|
||||
*
|
||||
* The record is compared and written without yielding in between: the
|
||||
* ownership probe above takes long enough for a launch to record a new pid,
|
||||
* and overwriting that record would orphan the fresh service. The compare
|
||||
* and the write are synchronous, so nothing can slip in between them.
|
||||
*/
|
||||
private async adoptLeftover(endpointValue: string): Promise<void> {
|
||||
if (!isLocalEndpoint(endpointValue)) return;
|
||||
const { record, ownership } = await this.recordedService();
|
||||
if (!record || ownership !== "owned") return;
|
||||
// A record filed under the endpoint on screen is not a leftover.
|
||||
if (
|
||||
serviceEndpointKey(record.endpoint) === serviceEndpointKey(endpointValue)
|
||||
)
|
||||
return;
|
||||
if (!this.sameOrigin(record.endpoint, endpointValue)) return;
|
||||
const current = readServiceState(this.serviceStatePath);
|
||||
if (
|
||||
!current ||
|
||||
current.pid !== record.pid ||
|
||||
current.endpoint !== record.endpoint
|
||||
)
|
||||
return;
|
||||
try {
|
||||
writeServiceState(this.serviceStatePath, {
|
||||
...current,
|
||||
endpoint: endpointValue,
|
||||
});
|
||||
} catch {
|
||||
// The record keeps its old endpoint; the start path replaces it anyway.
|
||||
}
|
||||
}
|
||||
|
||||
private async startLocked(
|
||||
endpoint: string,
|
||||
pythonPath: string,
|
||||
workspaceDir: string,
|
||||
authToken: string,
|
||||
): Promise<ServiceStartResult> {
|
||||
const endpointValue = this.endpoint(endpoint);
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const logPath = join(dir, "service.log");
|
||||
const now = new Date().toISOString();
|
||||
const starting = this.starting;
|
||||
if (starting && Date.now() - starting.at < START_GRACE_MS) {
|
||||
if (starting.endpoint === endpointValue)
|
||||
// A genuine launch is in flight. Observing it is not an error: the
|
||||
// client keeps polling just as the first request does.
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: true,
|
||||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
};
|
||||
// The service record is per install, not per endpoint; spawning a second
|
||||
// endpoint's service now would overwrite the first one's record and
|
||||
// orphan it.
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"other-endpoint-starting",
|
||||
`A service start for ${starting.endpoint} is still in progress`,
|
||||
);
|
||||
}
|
||||
this.starting = undefined;
|
||||
const current = await this.serviceStatus(endpointValue, authToken);
|
||||
if (current.phase === "running")
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: true,
|
||||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
...(current.version ? { version: current.version } : {}),
|
||||
};
|
||||
if (current.phase === "conflict") {
|
||||
// The port is answered by something that is not a healthy service. The
|
||||
// record may name that process: one of ours (still booting or rejecting
|
||||
// our token) is stopped and replaced; one whose identity cannot be read
|
||||
// is named, so the tab can forget it; anything else is left alone.
|
||||
const { record, ownership } = await this.recordedService();
|
||||
if (
|
||||
record &&
|
||||
ownership === "owned" &&
|
||||
isLocalEndpoint(endpointValue) &&
|
||||
this.sameOrigin(record.endpoint, endpointValue)
|
||||
) {
|
||||
const previous = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: record.endpoint,
|
||||
ownership: "owned",
|
||||
});
|
||||
if (!previous.stopped)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"previous-not-stopped",
|
||||
`A previous launch could not be stopped: ${
|
||||
previous.reason ?? "unknown reason"
|
||||
}`,
|
||||
{ record: previous.record },
|
||||
);
|
||||
} else if (
|
||||
record &&
|
||||
ownership === "unknown" &&
|
||||
this.relatedRecord(record.endpoint, endpointValue)
|
||||
) {
|
||||
// The process may be ours, but its identity cannot be read: it must
|
||||
// never be signalled. The record has to be named anyway, because it is
|
||||
// the only way out of a port the tab cannot clear.
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"unverified-record",
|
||||
`The recorded service for ${record.endpoint} (pid ${record.pid}) could not be verified; leaving it untouched`,
|
||||
{ pid: record.pid, record: serviceRecordRef(record) },
|
||||
);
|
||||
} else {
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"conflict",
|
||||
"Another process is already listening on this endpoint",
|
||||
);
|
||||
}
|
||||
}
|
||||
// A record for a *different* endpoint that is still alive would be
|
||||
// overwritten by the new launch, leaving that endpoint unable to stop it.
|
||||
// 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 other = await this.recordedService();
|
||||
const recorded = other.record;
|
||||
if (
|
||||
recorded &&
|
||||
other.alive &&
|
||||
serviceEndpointKey(recorded.endpoint) !==
|
||||
serviceEndpointKey(endpointValue)
|
||||
) {
|
||||
if (other.ownership === "owned") {
|
||||
// The launch configures only host and port, so two endpoints on the
|
||||
// same origin name the same local service even when their paths
|
||||
// differ. A record left by a version that still launched a prefixed
|
||||
// endpoint (for example `/reme`) is that service, and the endpoint on
|
||||
// screen cannot reach it to stop it: replace it here instead of
|
||||
// refusing with an instruction the tab cannot carry out.
|
||||
//
|
||||
// Only a launchable request may replace it: a prefixed request is
|
||||
// refused by `buildStartCommand` below, so stopping a healthy
|
||||
// same-origin service first would leave nothing running (autoStart
|
||||
// would do that on every DSH start).
|
||||
if (
|
||||
isLocalEndpoint(endpointValue) &&
|
||||
this.sameOrigin(recorded.endpoint, endpointValue)
|
||||
) {
|
||||
const previous = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: recorded.endpoint,
|
||||
ownership: "owned",
|
||||
});
|
||||
if (!previous.stopped)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"previous-not-stopped",
|
||||
`A previous launch could not be stopped: ${
|
||||
previous.reason ?? "unknown reason"
|
||||
}`,
|
||||
{ record: previous.record },
|
||||
);
|
||||
} else
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"other-endpoint-running",
|
||||
`A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
|
||||
);
|
||||
}
|
||||
// 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. The record is
|
||||
// named, so the client can offer to forget exactly this one.
|
||||
if (other.ownership === "unknown")
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"unverified-record",
|
||||
`The recorded service for ${recorded.endpoint} (pid ${recorded.pid}) could not be verified; leaving it untouched`,
|
||||
{ pid: recorded.pid, record: serviceRecordRef(recorded) },
|
||||
);
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
// A previous launch that never became healthy may still be alive. Stop it
|
||||
// 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) {
|
||||
if (other.ownership === "owned") {
|
||||
const previous = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: endpointValue,
|
||||
ownership: "owned",
|
||||
});
|
||||
if (!previous.stopped)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"previous-not-stopped",
|
||||
`A previous launch could not be stopped: ${
|
||||
previous.reason ?? "unknown reason"
|
||||
}`,
|
||||
{ record: previous.record },
|
||||
);
|
||||
} else if (other.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.
|
||||
// The message and the record name the same pid: it is the one the
|
||||
// confirmed Forget is pinned to.
|
||||
const pid = other.record?.pid ?? current.pid;
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"unverified-record",
|
||||
`The recorded service for ${endpointValue} (pid ${pid}) could not be verified; leaving it untouched`,
|
||||
{ pid, record: serviceRecordRef(other.record) },
|
||||
);
|
||||
} else {
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
}
|
||||
const path = pythonPath.trim();
|
||||
if (!path)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"no-python",
|
||||
"Select a Python interpreter first",
|
||||
);
|
||||
const check = await this.checkReme(path);
|
||||
// The same rule the tab uses to offer a start decides it here, so a
|
||||
// button is never offered for an interpreter this refuses.
|
||||
if (!canHostService(check))
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
check.installed ? "version-too-old" : "not-installed",
|
||||
check.installed
|
||||
? `reme-ai ${
|
||||
check.version || "(unknown version)"
|
||||
} is older than ${MINIMUM_REME}; upgrade it first`
|
||||
: "reme-ai is not installed in the selected interpreter",
|
||||
);
|
||||
// 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
|
||||
// cleared again when the spawn fails, so a retry is possible immediately.
|
||||
this.starting = { endpoint: endpointValue, at: Date.now() };
|
||||
try {
|
||||
const result = await startService(this.serviceIO, {
|
||||
endpoint: endpointValue,
|
||||
workspaceDir: dir,
|
||||
pythonPath: path,
|
||||
cliPath: check.cliPath,
|
||||
// 0.4.0.x has no jobs.<name>.enabled; only newer releases accept it.
|
||||
disableDreamCron: remeVersionAtLeast(check.version, "0.4.1"),
|
||||
statePath: this.serviceStatePath,
|
||||
logPath,
|
||||
env: this.env,
|
||||
});
|
||||
if (!result.started) this.starting = undefined;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.starting = undefined;
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"start-failed",
|
||||
`Could not start the ReMe service: ${safeError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
integrations/dsh/src/environment/host/paths.ts
Normal file
30
integrations/dsh/src/environment/host/paths.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { posix, win32, type PlatformPath } from "node:path";
|
||||
|
||||
import { isWindowsPath } from "../interpreter-key.js";
|
||||
|
||||
export { isWindowsPath };
|
||||
|
||||
/**
|
||||
* The path flavor a value is written in.
|
||||
*
|
||||
* Interpreter discovery and the probes around it see paths that may belong to
|
||||
* the simulated platform, so they must not fall back to the host's
|
||||
* `node:path`: the same fixtures then resolve identically on Windows, macOS and
|
||||
* Linux instead of only on the machine that wrote them.
|
||||
*/
|
||||
export function pathOf(value: string): PlatformPath {
|
||||
return isWindowsPath(value) ? win32 : posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a path is absolute in its own platform's semantics.
|
||||
*
|
||||
* `win32.isAbsolute("\\python.exe")` is true, but such a path is relative to
|
||||
* the current drive, which is not what the interpreter and install checks must
|
||||
* accept. Windows therefore needs a drive letter or a UNC prefix.
|
||||
*/
|
||||
export function isAbsolutePath(value: string): boolean {
|
||||
if (isWindowsPath(value))
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
|
||||
return value.startsWith("/");
|
||||
}
|
||||
144
integrations/dsh/src/environment/host/reme.ts
Normal file
144
integrations/dsh/src/environment/host/reme.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { win32 } from "node:path";
|
||||
|
||||
import type { RemeCheck, RemeProbe } from "../types.js";
|
||||
import { isAbsolutePath, pathOf } from "./paths.js";
|
||||
import type { RunCommand } from "./runner.js";
|
||||
|
||||
/** Minimum reme-ai version this DSH integration is tested against. */
|
||||
export const MINIMUM_REME = "0.4.0";
|
||||
|
||||
const REME_VERSION_SNIPPET =
|
||||
"import importlib.metadata as m;print(m.version('reme-ai'))";
|
||||
|
||||
export interface RemeIO {
|
||||
run: RunCommand;
|
||||
platform: string;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
/** Numeric dotted-version comparison; returns -1, 0 or 1. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const right = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (left[index] ?? 0) - (right[index] ?? 0);
|
||||
if (diff !== 0) return Math.sign(diff);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Strict numeric version gate; empty or prefixed versions never pass. */
|
||||
export function remeVersionAtLeast(
|
||||
version: string,
|
||||
minimum: string = MINIMUM_REME,
|
||||
): boolean {
|
||||
const normalized = version.trim();
|
||||
if (!/^\d+(?:\.\d+)+$/.test(normalized)) return false;
|
||||
return compareVersions(normalized, minimum) >= 0;
|
||||
}
|
||||
|
||||
/** Read a bare version line out of pip/import noise. */
|
||||
export function parseRemeVersion(text: string): string {
|
||||
const match = /^\s*(\d+(?:\.\d+)+)\s*$/m.exec(text);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
/** Ask one interpreter for its installed reme-ai metadata version. */
|
||||
export async function probeReme(
|
||||
io: RemeIO,
|
||||
pythonPath: string,
|
||||
): Promise<RemeProbe> {
|
||||
try {
|
||||
const result = await io.run(pythonPath, ["-c", REME_VERSION_SNIPPET], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (result.error || result.code !== 0)
|
||||
return {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
};
|
||||
const version = parseRemeVersion(result.stdout);
|
||||
return {
|
||||
installed: version !== "",
|
||||
version,
|
||||
versionOk: remeVersionAtLeast(version),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Locate the console script installed beside the interpreter. */
|
||||
export function remeCliPath(io: RemeIO, pythonPath: string): string {
|
||||
// The interpreter path may be written for another platform than the host, so
|
||||
// its flavor supplies the separators and joins.
|
||||
const path = io.platform === "win32" ? win32 : pathOf(pythonPath);
|
||||
const directory = path.dirname(pythonPath);
|
||||
// venv interpreters live inside the scripts/bin directory; conda ones do not.
|
||||
const scriptsDir =
|
||||
io.platform === "win32"
|
||||
? /[\\/]scripts$/i.test(directory)
|
||||
? directory
|
||||
: path.join(directory, "Scripts")
|
||||
: directory;
|
||||
const candidate = path.join(
|
||||
scriptsDir,
|
||||
io.platform === "win32" ? "reme.exe" : "reme",
|
||||
);
|
||||
return io.exists(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full dependency check for one interpreter: reme-ai presence/version, the
|
||||
* `[core]` AgentScope import, and the CLI entry used by service management.
|
||||
*/
|
||||
export async function checkReme(
|
||||
io: RemeIO,
|
||||
pythonPath: string,
|
||||
): Promise<RemeCheck> {
|
||||
const path = pythonPath.trim();
|
||||
if (!path || !isAbsolutePath(path)) {
|
||||
return {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: "Python interpreter path must be absolute",
|
||||
};
|
||||
}
|
||||
const probe = await probeReme(io, path);
|
||||
if (!probe.installed) {
|
||||
return {
|
||||
...probe,
|
||||
versionOk: false,
|
||||
hasCore: false,
|
||||
cliPath: "",
|
||||
error: probe.error ?? "",
|
||||
};
|
||||
}
|
||||
let hasCore = false;
|
||||
try {
|
||||
const core = await io.run(path, ["-c", "import agentscope"], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
hasCore = !core.error && core.code === 0;
|
||||
} catch {
|
||||
hasCore = false;
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
versionOk: remeVersionAtLeast(probe.version),
|
||||
hasCore,
|
||||
cliPath: remeCliPath(io, path),
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
112
integrations/dsh/src/environment/host/runner.ts
Normal file
112
integrations/dsh/src/environment/host/runner.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface RunResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
/** Spawn or timeout failure description; absent on a normal exit. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
timeoutMs?: number;
|
||||
cwd?: string;
|
||||
/** Join argv verbatim (cmd.exe /s /c wrappers); never combine with user input. */
|
||||
windowsVerbatimArguments?: boolean;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
exe: string,
|
||||
args: string[],
|
||||
options?: RunOptions,
|
||||
) => Promise<RunResult>;
|
||||
|
||||
/** UTF-8 guards so probe output survives GBK console codepages on Windows. */
|
||||
export function probeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...base,
|
||||
PYTHONIOENCODING: "utf-8",
|
||||
PYTHONUTF8: "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Array-argv, shell-free runner with a hard timeout and no thrown rejections. */
|
||||
export function createNodeRunner(
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): RunCommand {
|
||||
return (exe, args, options = {}) =>
|
||||
new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
...(options.windowsVerbatimArguments
|
||||
? { windowsVerbatimArguments: true }
|
||||
: {}),
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
env: probeEnv(base),
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({ code: null, stdout: "", stderr: "", error: String(error) });
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const settle = (result: RunResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
const timer = setTimeout(() => {
|
||||
// The caller must see an explicit error: an exit caused by our own
|
||||
// timeout is not "the interpreter has no reme-ai".
|
||||
timedOut = true;
|
||||
child.kill();
|
||||
// A child that ignores SIGTERM must not make the probe hang forever:
|
||||
// force it and settle, rather than waiting for a close that may never
|
||||
// arrive.
|
||||
const grace = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
settle({
|
||||
code: null,
|
||||
stdout,
|
||||
stderr,
|
||||
error: `Command timed out after ${timeoutMs} ms`,
|
||||
});
|
||||
}, 1_000);
|
||||
grace.unref();
|
||||
timers.push(grace);
|
||||
}, timeoutMs);
|
||||
timer.unref();
|
||||
timers.push(timer);
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
settle({ code: null, stdout, stderr, error: String(error) });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
settle({
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
...(timedOut
|
||||
? { error: `Command timed out after ${timeoutMs} ms` }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
926
integrations/dsh/src/environment/host/service.ts
Normal file
926
integrations/dsh/src/environment/host/service.ts
Normal file
|
|
@ -0,0 +1,926 @@
|
|||
import { execFile, spawn } from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createConnection } from "node:net";
|
||||
import { basename, dirname } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { ReMeClient } from "../../reme/client.js";
|
||||
import type { ReMeHealthResult } from "../../reme/types.js";
|
||||
import { serviceEndpointKey, serviceOriginKey } from "../endpoint-key.js";
|
||||
import { parseLocalEndpoint } from "../endpoint-rules.js";
|
||||
import type {
|
||||
HealthErrorCode,
|
||||
ServiceForgetResult,
|
||||
ServiceRecordRef,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
ServiceStopResult,
|
||||
} from "../types.js";
|
||||
import { openAppendLog } from "./log.js";
|
||||
import { isWindowsPath } from "./paths.js";
|
||||
|
||||
export { serviceEndpointKey, serviceOriginKey } from "../endpoint-key.js";
|
||||
export { parseLocalEndpoint } from "../endpoint-rules.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
|
||||
* subprocess provider (that provider terminates its children on Host exit).
|
||||
* This module is the small service adapter that owns that lifetime instead:
|
||||
* it spawns the process, records the pid in a state file, verifies ownership
|
||||
* before reporting or stopping it, and tree-kills it on request.
|
||||
*/
|
||||
|
||||
export interface PersistedService {
|
||||
pid?: number;
|
||||
endpoint: string;
|
||||
workspaceDir: string;
|
||||
pythonPath: string;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceHealth {
|
||||
ok: boolean;
|
||||
version: string;
|
||||
/** Non-empty when the endpoint answered but not as a ReMe service. */
|
||||
error: string;
|
||||
/** Stable reason the client localizes instead of showing `error`. */
|
||||
code?: HealthErrorCode;
|
||||
}
|
||||
|
||||
export interface ServiceIO {
|
||||
fetchHealth(endpoint: string, authToken: string): Promise<ServiceHealth>;
|
||||
portOpen(host: string, port: number): Promise<boolean>;
|
||||
spawnService(options: {
|
||||
exe: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
logPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ pid: number | undefined }>;
|
||||
/**
|
||||
* Ask the process, and its process group on POSIX, to stop. `force`
|
||||
* escalates to `SIGKILL` (always `/F` on Windows).
|
||||
*/
|
||||
killTree(pid: number, force?: boolean): void;
|
||||
probePid(pid: number): void;
|
||||
/** OS image name of a pid, or "" when it cannot be read. */
|
||||
processImage(pid: number): Promise<string>;
|
||||
/** Full command line of a pid, or "" when it cannot be read. */
|
||||
processCommandLine?(pid: number): Promise<string>;
|
||||
exists(path: string): boolean;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
/** A pid we are willing to signal: > 1, so kill(0)/kill(-1) is impossible. */
|
||||
export function isValidPid(pid: unknown): pid is number {
|
||||
return typeof pid === "number" && Number.isInteger(pid) && pid > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the `reme start` invocation. The working directory must be the
|
||||
* workspace so ReMe's `.env` discovery (cwd plus five parents) finds the file
|
||||
* the Environment tab writes.
|
||||
*/
|
||||
export function buildStartCommand(options: {
|
||||
cliPath: string;
|
||||
pythonPath: string;
|
||||
workspaceDir: string;
|
||||
endpoint: string;
|
||||
disableDreamCron: boolean;
|
||||
exists(path: string): boolean;
|
||||
}): { exe: string; args: string[]; display: string } {
|
||||
const { port } = parseLocalEndpoint(options.endpoint);
|
||||
const args = [
|
||||
"start",
|
||||
`workspace_dir=${options.workspaceDir}`,
|
||||
"service.host=127.0.0.1",
|
||||
`service.port=${port}`,
|
||||
// Keeps the rich logo from crashing GBK consoles in the background.
|
||||
"enable_logo=false",
|
||||
...(options.disableDreamCron ? ["jobs.dream_cron.enabled=false"] : []),
|
||||
];
|
||||
if (options.cliPath && options.exists(options.cliPath))
|
||||
return {
|
||||
exe: options.cliPath,
|
||||
args,
|
||||
display: `${options.cliPath} ${args.join(" ")}`,
|
||||
};
|
||||
// The console script is missing (e.g. a bare venv without Scripts entry):
|
||||
// run the same CLI entry through the interpreter itself.
|
||||
const wrapper = `import sys;sys.argv=${JSON.stringify([
|
||||
"reme",
|
||||
...args,
|
||||
])};from reme.reme import main;sys.exit(main())`;
|
||||
return {
|
||||
exe: options.pythonPath,
|
||||
args: ["-c", wrapper],
|
||||
display: `${options.pythonPath} -c <reme wrapper>`,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Read a record defensively: a malformed file is "no record", not a crash. */
|
||||
export function readServiceState(path: string): PersistedService | undefined {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const endpoint = typeof value.endpoint === "string" ? value.endpoint : "";
|
||||
if (!endpoint.trim()) return undefined;
|
||||
return {
|
||||
...(isValidPid(value.pid) ? { pid: value.pid } : {}),
|
||||
endpoint,
|
||||
workspaceDir:
|
||||
typeof value.workspaceDir === "string" ? value.workspaceDir : "",
|
||||
pythonPath: typeof value.pythonPath === "string" ? value.pythonPath : "",
|
||||
command: typeof value.command === "string" ? value.command : "",
|
||||
startedAt: typeof value.startedAt === "string" ? value.startedAt : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function writeServiceState(path: string, state: PersistedService): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify(state, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
renameSync(temporary, path);
|
||||
} catch (error) {
|
||||
rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the service record without touching the recorded process. */
|
||||
export function removeServiceState(path: string): void {
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// A stale state file is retried by the next status read.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of a persisted record, for a result that names the record it could
|
||||
* not act on. The recovery action targets this record, which is not
|
||||
* necessarily the endpoint the request was made for.
|
||||
*/
|
||||
export function serviceRecordRef(
|
||||
persisted: PersistedService | undefined,
|
||||
): ServiceRecordRef | undefined {
|
||||
if (!persisted) return undefined;
|
||||
return {
|
||||
endpoint: persisted.endpoint,
|
||||
...(persisted.pid === undefined ? {} : { pid: persisted.pid }),
|
||||
};
|
||||
}
|
||||
|
||||
const clearServiceState = removeServiceState;
|
||||
|
||||
function alive(io: ServiceIO, pid?: number): boolean {
|
||||
if (!isValidPid(pid)) return false;
|
||||
try {
|
||||
io.probePid(pid);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Only ESRCH proves the process is gone. EPERM means it exists but this
|
||||
// user may not signal it (for example a service DSH started elevated);
|
||||
// treating that as dead would drop the record and orphan the process.
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a pid is alive, rejecting pids this plugin never records. */
|
||||
export function processAlive(io: ServiceIO, pid?: number): boolean {
|
||||
return alive(io, pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an OS image name is a process that could be running the ReMe
|
||||
* service: an exact Python-family name or `reme`, never a prefix match (which
|
||||
* would accept e.g. `pycharm64.exe`).
|
||||
*/
|
||||
export function isServiceImage(image: string): boolean {
|
||||
const name = basename(image.trim().replaceAll("\\", "/")).toLowerCase();
|
||||
return /^(python|pythonw|reme)([0-9.]*)?(\.exe)?$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership of the recorded process, as far as this plugin can prove it.
|
||||
*
|
||||
* - `owned`: endpoint, pid, image and command line all match the launch this
|
||||
* plugin recorded.
|
||||
* - `foreign`: at least one piece of evidence contradicts the record.
|
||||
* - `unknown`: the process exists but its image or command line could not be
|
||||
* read (localized `tasklist`, `ps` failure, missing permissions).
|
||||
*
|
||||
* Stop is destructive, so it only accepts `owned`; starting can discard an
|
||||
* `unknown` record (without killing it) instead of being blocked forever.
|
||||
*/
|
||||
export type ServiceOwnership = "owned" | "foreign" | "unknown";
|
||||
|
||||
/**
|
||||
* Compare paths and ports with separators normalized. Windows command lines
|
||||
* escape quotes as `\"`, so that sequence is folded back before matching; case
|
||||
* is only folded for Windows paths, because POSIX paths are case-sensitive.
|
||||
*/
|
||||
function normalizeCommandText(value: string, foldCase: boolean): string {
|
||||
const text = value
|
||||
.replaceAll('\\"', '"')
|
||||
.replaceAll("\\\\", "\\")
|
||||
.replaceAll("/", "\\");
|
||||
return foldCase ? text.toLowerCase() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local port a recorded endpoint names, without the launch rules.
|
||||
*
|
||||
* `commandMatches` only needs the port to find `service.port=<port>` in the
|
||||
* command line, and it must stay as permissive as the record is old: a record
|
||||
* written before the root-path rule may name a prefixed loopback URL, and that
|
||||
* process still has to be recognizable as the one this plugin started so it
|
||||
* can be stopped. Reusing the launch rule here would turn such a live record
|
||||
* into a `foreign` process that nothing owns.
|
||||
*/
|
||||
function recordedPort(endpoint: string): number | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint.trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const port = Number(url.port || (url.protocol === "https:" ? "443" : "80"));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded launch always passes `workspace_dir=<dir>` and
|
||||
* `service.port=<port>` as argv entries (also inside the Python wrapper's
|
||||
* `-c` script), so their presence is strong evidence that this pid is the
|
||||
* process the record describes — not merely another Python that reused the pid.
|
||||
*/
|
||||
function commandMatches(command: string, persisted: PersistedService): boolean {
|
||||
const port = recordedPort(persisted.endpoint);
|
||||
if (port === undefined) return false;
|
||||
const workspace = persisted.workspaceDir.trim();
|
||||
if (!workspace) return false;
|
||||
const foldCase = isWindowsPath(workspace);
|
||||
const text = normalizeCommandText(command, foldCase);
|
||||
const marker = `workspace_dir=${normalizeCommandText(workspace, foldCase)}`;
|
||||
const index = text.indexOf(marker);
|
||||
if (index < 0) return false;
|
||||
// Require 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;
|
||||
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
|
||||
}
|
||||
|
||||
export async function serviceOwnership(
|
||||
io: ServiceIO,
|
||||
persisted: PersistedService | undefined,
|
||||
endpoint: string,
|
||||
): Promise<ServiceOwnership> {
|
||||
if (!persisted || !isValidPid(persisted.pid)) return "foreign";
|
||||
if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint))
|
||||
return "foreign";
|
||||
if (!alive(io, persisted.pid)) return "foreign";
|
||||
let image = "";
|
||||
try {
|
||||
image = await io.processImage(persisted.pid);
|
||||
} catch {
|
||||
image = "";
|
||||
}
|
||||
if (!image.trim()) return "unknown";
|
||||
if (!isServiceImage(image)) return "foreign";
|
||||
let command = "";
|
||||
try {
|
||||
command = (await io.processCommandLine?.(persisted.pid)) ?? "";
|
||||
} catch {
|
||||
command = "";
|
||||
}
|
||||
if (!command.trim()) return "unknown";
|
||||
return commandMatches(command, persisted) ? "owned" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorded process is provably the service this plugin started
|
||||
* for the endpoint on screen.
|
||||
*
|
||||
* This guards a destructive operation, so it fails *closed*: unreadable
|
||||
* evidence is not proof of ownership.
|
||||
*/
|
||||
export async function ownsService(
|
||||
io: ServiceIO,
|
||||
persisted: PersistedService | undefined,
|
||||
endpoint: string,
|
||||
): Promise<boolean> {
|
||||
return (await serviceOwnership(io, persisted, endpoint)) === "owned";
|
||||
}
|
||||
|
||||
/** Running / conflict / stopped, with ownership of the running process. */
|
||||
export async function serviceStatus(
|
||||
io: ServiceIO,
|
||||
options: {
|
||||
endpoint: string;
|
||||
statePath: string;
|
||||
logPath: string;
|
||||
authToken?: string;
|
||||
},
|
||||
): Promise<ServiceState> {
|
||||
const health = await io.fetchHealth(
|
||||
options.endpoint,
|
||||
options.authToken ?? "",
|
||||
);
|
||||
const persisted = readServiceState(options.statePath);
|
||||
const recordForEndpoint =
|
||||
persisted !== undefined &&
|
||||
serviceEndpointKey(persisted.endpoint) ===
|
||||
serviceEndpointKey(options.endpoint);
|
||||
const recordAlive = alive(io, persisted?.pid);
|
||||
// A record whose process is gone must not be reported, or a later start
|
||||
// would keep seeing a stale pid. Remove it while nobody can be relying on it.
|
||||
if (persisted && !recordAlive) clearServiceState(options.statePath);
|
||||
// Ownership is expensive (image plus command line). During boot the service
|
||||
// is polled every second while `stopped`, where it is not needed, so it is
|
||||
// computed lazily only for the phases that report it.
|
||||
const ownership = async (): Promise<ServiceOwnership> =>
|
||||
recordForEndpoint && recordAlive
|
||||
? serviceOwnership(io, persisted, options.endpoint)
|
||||
: "foreign";
|
||||
/**
|
||||
* The record behind the status, when the Host cannot prove that the process
|
||||
* is the one the record describes.
|
||||
*
|
||||
* An unreadable identity is no proof, so the process is never signalled —
|
||||
* but the record is what the tab has to drop to get the port back, and it
|
||||
* has to travel with the status: the conflict card cannot offer a recovery
|
||||
* that only a refused start would name.
|
||||
*
|
||||
* A record for another path on the same origin describes the same local
|
||||
* service, so it is related too.
|
||||
*/
|
||||
const unverified = async (
|
||||
owner: ServiceOwnership,
|
||||
): Promise<ServiceRecordRef | undefined> => {
|
||||
if (!persisted || !recordAlive) return undefined;
|
||||
if (owner === "unknown") return serviceRecordRef(persisted);
|
||||
if (recordForEndpoint) return undefined;
|
||||
if (
|
||||
serviceOriginKey(persisted.endpoint) !==
|
||||
serviceOriginKey(options.endpoint)
|
||||
)
|
||||
return undefined;
|
||||
const other = await serviceOwnership(io, persisted, persisted.endpoint);
|
||||
return other === "unknown" ? serviceRecordRef(persisted) : undefined;
|
||||
};
|
||||
if (health.ok) {
|
||||
const owner = await ownership();
|
||||
const managed = owner === "owned";
|
||||
const record = await unverified(owner);
|
||||
return {
|
||||
phase: "running",
|
||||
managed,
|
||||
endpoint: options.endpoint,
|
||||
...(managed && persisted?.pid !== undefined
|
||||
? { pid: persisted.pid }
|
||||
: {}),
|
||||
...(health.version ? { version: health.version } : {}),
|
||||
...(managed && persisted ? { startedAt: persisted.startedAt } : {}),
|
||||
...(record ? { record } : {}),
|
||||
logPath: options.logPath,
|
||||
};
|
||||
}
|
||||
let portOpen = false;
|
||||
try {
|
||||
const { host, port } = parseLocalEndpoint(options.endpoint);
|
||||
portOpen = await io.portOpen(host, port);
|
||||
} catch {
|
||||
// A remote or https endpoint is health-checked only; it has no local port.
|
||||
}
|
||||
if (portOpen) {
|
||||
// Our own service can occupy the port without being healthy (still
|
||||
// booting, or a rejected token); identify it so the tab can stop it.
|
||||
const owner = await ownership();
|
||||
const managed = owner === "owned";
|
||||
const record = await unverified(owner);
|
||||
return {
|
||||
phase: "conflict",
|
||||
managed,
|
||||
endpoint: options.endpoint,
|
||||
...(managed && persisted?.pid !== undefined
|
||||
? { pid: persisted.pid }
|
||||
: {}),
|
||||
...(record ? { record } : {}),
|
||||
logPath: options.logPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: options.endpoint,
|
||||
// A live record for this endpoint means a launch that never became
|
||||
// healthy; the manager stops or discards it before spawning a replacement.
|
||||
...(recordForEndpoint && recordAlive && persisted?.pid !== undefined
|
||||
? { pid: persisted.pid }
|
||||
: {}),
|
||||
logPath: options.logPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached service; the caller polls status until it is healthy. The
|
||||
* launch is recorded only once the OS acknowledged the spawn, so a missing
|
||||
* interpreter leaves no state file behind for a later tab to adopt.
|
||||
*/
|
||||
export async function startService(
|
||||
io: ServiceIO,
|
||||
options: {
|
||||
endpoint: string;
|
||||
workspaceDir: string;
|
||||
pythonPath: string;
|
||||
cliPath: string;
|
||||
disableDreamCron: boolean;
|
||||
statePath: string;
|
||||
logPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Promise<ServiceStartResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
let command: { exe: string; args: string[]; display: string };
|
||||
try {
|
||||
mkdirSync(options.workspaceDir, { recursive: true });
|
||||
command = buildStartCommand({
|
||||
cliPath: options.cliPath,
|
||||
pythonPath: options.pythonPath,
|
||||
workspaceDir: options.workspaceDir,
|
||||
endpoint: options.endpoint,
|
||||
disableDreamCron: options.disableDreamCron,
|
||||
exists: io.exists,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
let child: { pid: number | undefined };
|
||||
try {
|
||||
child = await io.spawnService({
|
||||
exe: command.exe,
|
||||
args: command.args,
|
||||
cwd: options.workspaceDir,
|
||||
logPath: options.logPath,
|
||||
env: {
|
||||
...options.env,
|
||||
PYTHONUTF8: "1",
|
||||
PYTHONIOENCODING: "utf-8",
|
||||
NO_PROXY: "localhost,127.0.0.1,::1",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: command.display,
|
||||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not start the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
try {
|
||||
writeServiceState(options.statePath, {
|
||||
pid: child.pid,
|
||||
endpoint: options.endpoint,
|
||||
workspaceDir: options.workspaceDir,
|
||||
pythonPath: options.pythonPath,
|
||||
command: command.display,
|
||||
startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// The process exists but could not be recorded; leaving it running would
|
||||
// make it unmanageable, so stop it before reporting the failure.
|
||||
try {
|
||||
if (child.pid !== undefined) io.killTree(child.pid, true);
|
||||
} catch {
|
||||
// Best effort: the error below already explains the launch failed.
|
||||
}
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: command.display,
|
||||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not record the ReMe service: ${safeError(error)}`,
|
||||
code: "start-failed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
...(child.pid === undefined ? {} : { pid: child.pid }),
|
||||
command: command.display,
|
||||
logPath: options.logPath,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const STOP_FORCE_TIMEOUT_MS = 3_000;
|
||||
const STOP_POLL_MS = 100;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll until the pid disappears; true when it exited within `timeoutMs`. */
|
||||
async function waitForExit(
|
||||
io: ServiceIO,
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
pollMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (!alive(io, pid)) return true;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return false;
|
||||
await delay(Math.max(1, Math.min(pollMs, remaining)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the service this plugin started for the endpoint, and only that one: a
|
||||
* service on another port, or a foreign process, is left running.
|
||||
*
|
||||
* The record is removed only after the process is confirmed gone. Otherwise a
|
||||
* SIGTERM the service ignored would delete the pid, leaving a running process
|
||||
* that the tab can never stop again.
|
||||
*/
|
||||
export async function stopService(
|
||||
io: ServiceIO,
|
||||
options: {
|
||||
statePath: string;
|
||||
endpoint: string;
|
||||
/** Grace period before escalating to SIGKILL (tests shorten it). */
|
||||
timeoutMs?: number;
|
||||
forceTimeoutMs?: number;
|
||||
pollMs?: number;
|
||||
/** Precomputed ownership from the caller; avoids a second process probe. */
|
||||
ownership?: ServiceOwnership;
|
||||
},
|
||||
): Promise<ServiceStopResult> {
|
||||
const persisted = readServiceState(options.statePath);
|
||||
if (!persisted || !isValidPid(persisted.pid)) {
|
||||
if (persisted) clearServiceState(options.statePath);
|
||||
return {
|
||||
stopped: false,
|
||||
reason: "No service started by this plugin",
|
||||
code: "no-record",
|
||||
};
|
||||
}
|
||||
const ownership =
|
||||
options.ownership ??
|
||||
(await serviceOwnership(io, persisted, options.endpoint));
|
||||
if (ownership === "unknown")
|
||||
return {
|
||||
stopped: false,
|
||||
reason:
|
||||
"The recorded process identity could not be verified; refusing to stop it",
|
||||
code: "unverified",
|
||||
record: serviceRecordRef(persisted),
|
||||
};
|
||||
if (ownership !== "owned")
|
||||
return {
|
||||
stopped: false,
|
||||
reason: "The recorded service does not belong to this endpoint",
|
||||
code: "foreign",
|
||||
};
|
||||
const pid = persisted.pid;
|
||||
const pollMs = options.pollMs ?? STOP_POLL_MS;
|
||||
try {
|
||||
io.killTree(pid);
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
if (
|
||||
await waitForExit(io, pid, options.timeoutMs ?? STOP_TIMEOUT_MS, pollMs)
|
||||
) {
|
||||
clearServiceState(options.statePath);
|
||||
return { stopped: true, pid };
|
||||
}
|
||||
// The service ignored (or slowly handled) SIGTERM: escalate, then only drop
|
||||
// the record once the process is really gone.
|
||||
try {
|
||||
io.killTree(pid, true);
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
if (
|
||||
await waitForExit(
|
||||
io,
|
||||
pid,
|
||||
options.forceTimeoutMs ?? STOP_FORCE_TIMEOUT_MS,
|
||||
pollMs,
|
||||
)
|
||||
) {
|
||||
clearServiceState(options.statePath);
|
||||
return { stopped: true, pid };
|
||||
}
|
||||
return {
|
||||
stopped: false,
|
||||
pid,
|
||||
reason: "The service did not exit after the force-kill",
|
||||
code: "not-exited",
|
||||
record: serviceRecordRef(persisted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the service record without signalling any process.
|
||||
*
|
||||
* Stop and Start both fail closed when ownership cannot be proven. With a live
|
||||
* but unverifiable process (for example one started by an elevated DSH) that
|
||||
* leaves the tab with no working action at all, so forgetting the record is the
|
||||
* explicit way out: the plugin stops managing the process and the user confirms
|
||||
* what to do with it themselves.
|
||||
*
|
||||
* `expectPid` pins the record the caller saw. Without it a record that was
|
||||
* replaced in the meantime would be dropped silently; with it the request is
|
||||
* refused instead, because the process it was meant to describe is a different
|
||||
* one now.
|
||||
*/
|
||||
export function forgetService(options: {
|
||||
statePath: string;
|
||||
endpoint: string;
|
||||
/** Pid the caller saw; the record must still describe it. */
|
||||
expectPid?: number;
|
||||
}): ServiceForgetResult {
|
||||
const persisted = readServiceState(options.statePath);
|
||||
if (!persisted || !isValidPid(persisted.pid)) {
|
||||
if (persisted) clearServiceState(options.statePath);
|
||||
return {
|
||||
forgotten: false,
|
||||
reason: "No service started by this plugin",
|
||||
code: "no-record",
|
||||
};
|
||||
}
|
||||
if (
|
||||
serviceEndpointKey(persisted.endpoint) !==
|
||||
serviceEndpointKey(options.endpoint)
|
||||
)
|
||||
return {
|
||||
forgotten: false,
|
||||
reason: "The recorded service does not belong to this endpoint",
|
||||
code: "foreign",
|
||||
};
|
||||
if (options.expectPid !== undefined && persisted.pid !== options.expectPid)
|
||||
return {
|
||||
forgotten: false,
|
||||
pid: persisted.pid,
|
||||
reason: "The recorded service changed since it was inspected",
|
||||
code: "record-changed",
|
||||
record: serviceRecordRef(persisted),
|
||||
};
|
||||
clearServiceState(options.statePath);
|
||||
return { forgotten: true, pid: persisted.pid };
|
||||
}
|
||||
|
||||
/** How long the Host waits for a health answer before giving up. */
|
||||
const HEALTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The Host's health verdict, taken from the shared ReMe client.
|
||||
*
|
||||
* The client already knows how to reach a ReMe service, how to authenticate
|
||||
* against it and how to tell one from a generic HTTP server, so the Host
|
||||
* reuses it instead of repeating that request logic. The stable codes stay:
|
||||
* the tab localizes them instead of showing an English sentence.
|
||||
*/
|
||||
function healthFromResult(result: ReMeHealthResult): ServiceHealth {
|
||||
const status = result.status ?? 0;
|
||||
if (status === 0)
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: result.error || "The endpoint could not be reached",
|
||||
code: "unreachable",
|
||||
};
|
||||
if (!result.ok) {
|
||||
// The client reports an HTTP failure and a ReMe-reported error the same
|
||||
// way; only the status tells them apart.
|
||||
const code = status >= 200 && status < 300 ? "reme-error" : "http-status";
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error:
|
||||
code === "http-status" ? `HTTP ${status}` : "ReMe reported an error",
|
||||
code,
|
||||
};
|
||||
}
|
||||
// A 200 response is not a healthy ReMe: the health report has to parse.
|
||||
if (!result.health)
|
||||
return {
|
||||
ok: false,
|
||||
version: "",
|
||||
error: "The endpoint did not answer with a ReMe health report",
|
||||
code: "not-reme",
|
||||
};
|
||||
return { ok: true, version: result.health.version, error: "" };
|
||||
}
|
||||
|
||||
/** Node-backed IO: fetch health, probe the port, spawn detached, tree-kill. */
|
||||
export function createServiceIO(): ServiceIO {
|
||||
return {
|
||||
async fetchHealth(endpoint, authToken) {
|
||||
const client = new ReMeClient({
|
||||
endpoint,
|
||||
requestTimeoutMs: HEALTH_TIMEOUT_MS,
|
||||
backgroundTimeoutMs: HEALTH_TIMEOUT_MS,
|
||||
...(authToken ? { authToken } : {}),
|
||||
});
|
||||
return healthFromResult(await client.healthCheck());
|
||||
},
|
||||
portOpen(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection({ host, port });
|
||||
const finish = (value: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
socket.setTimeout(800);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
},
|
||||
async spawnService({ exe, args, cwd, logPath, env }) {
|
||||
const fd = openAppendLog(logPath);
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
cwd,
|
||||
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 {
|
||||
started.kill();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
reject(new Error("spawn did not acknowledge within 10 s"));
|
||||
}, 10_000);
|
||||
timer.unref();
|
||||
started.once("spawn", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
started.once("error", (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
// The child owns its inherited handle; the parent copy is no longer
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
return { pid: child?.pid };
|
||||
},
|
||||
killTree(pid, force = false) {
|
||||
if (!isValidPid(pid)) return;
|
||||
if (process.platform === "win32") {
|
||||
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
// A spawn failure is an async event; without a listener it would exit
|
||||
// the Host.
|
||||
child.on("error", () => undefined);
|
||||
child.unref();
|
||||
return;
|
||||
}
|
||||
// The service was spawned detached, so it leads its own process group;
|
||||
// signalling the group stops pip's children too.
|
||||
const signal = force ? "SIGKILL" : "SIGTERM";
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Not a group leader (e.g. a record from an older launch).
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
},
|
||||
probePid(pid) {
|
||||
if (!isValidPid(pid))
|
||||
throw new RangeError("Refusing to probe an invalid pid");
|
||||
process.kill(pid, 0);
|
||||
},
|
||||
processImage(pid) {
|
||||
return new Promise((resolve) => {
|
||||
if (!isValidPid(pid)) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
execFile(
|
||||
"tasklist",
|
||||
["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
|
||||
{ windowsHide: true, timeout: 3000 },
|
||||
(error, stdout) => {
|
||||
const match = /^"([^"]+)"/.exec(String(stdout ?? "").trim());
|
||||
resolve(error ? "" : match?.[1] ?? "");
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
execFile(
|
||||
"ps",
|
||||
["-p", String(pid), "-o", "comm="],
|
||||
{ timeout: 3000 },
|
||||
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
|
||||
);
|
||||
});
|
||||
},
|
||||
processCommandLine(pid) {
|
||||
return new Promise((resolve) => {
|
||||
if (!isValidPid(pid)) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
// wmic is absent from recent Windows builds, so ask CIM. The encoding
|
||||
// prefix keeps non-ASCII workspace paths intact through the pipe.
|
||||
execFile(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`[Console]::OutputEncoding=[Text.Encoding]::UTF8; (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`,
|
||||
],
|
||||
{ windowsHide: true, timeout: 5000 },
|
||||
(error, stdout) =>
|
||||
resolve(error ? "" : String(stdout ?? "").trim()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
execFile(
|
||||
"ps",
|
||||
["-p", String(pid), "-o", "args="],
|
||||
{ timeout: 3000 },
|
||||
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
|
||||
);
|
||||
});
|
||||
},
|
||||
exists: existsSync,
|
||||
platform: process.platform,
|
||||
};
|
||||
}
|
||||
498
integrations/dsh/src/environment/host/workspace.ts
Normal file
498
integrations/dsh/src/environment/host/workspace.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { MANAGED_KEYS, SECRET_KEYS } from "../env-fields.js";
|
||||
import { DEFAULT_LLM_BACKEND, llmBackendOf } from "../llm-backend.js";
|
||||
import type { EnvSummary, LlmTestResult } from "../types.js";
|
||||
import type { RunCommand } from "./runner.js";
|
||||
|
||||
/** Empty means "~/.reme"; "~" expands against home; relative paths are rejected. */
|
||||
export function resolveWorkspaceDir(raw: string, home: string): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
const expanded = trimmed.startsWith("~")
|
||||
? join(home, trimmed.slice(1).replace(/^[\\/]+/, ""))
|
||||
: trimmed;
|
||||
const value = expanded || join(home, ".reme");
|
||||
if (!isAbsolute(value))
|
||||
throw new TypeError("ReMe workspace directory must be an absolute path");
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
/** Parse KEY=VALUE lines; comments, blanks, quotes and unknown keys are tolerated. */
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const equals = trimmed.indexOf("=");
|
||||
if (equals <= 0) continue;
|
||||
const key = trimmed.slice(0, equals).trim();
|
||||
let value = trimmed.slice(equals + 1).trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
)
|
||||
value = value.slice(1, -1);
|
||||
entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite only the lines whose keys appear in updates; every other line
|
||||
* (comments, unknown variables, ordering) is preserved byte-for-byte, and the
|
||||
* file keeps its own line ending.
|
||||
*
|
||||
* A key that appears more than once is rewritten in every position. Parsing
|
||||
* keeps the last occurrence, so rewriting only the first would leave an older
|
||||
* value in force: after this function every duplicate agrees.
|
||||
*/
|
||||
export function mergeEnvFile(
|
||||
existingText: string,
|
||||
updates: Record<string, string>,
|
||||
): string {
|
||||
const pending = new Map(Object.entries(updates));
|
||||
const eol = /\r\n/.test(existingText) ? "\r\n" : "\n";
|
||||
const lines = existingText.length ? existingText.split(/\r?\n/) : [];
|
||||
const output: string[] = [];
|
||||
const written = new Set<string>();
|
||||
for (const line of lines) {
|
||||
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
||||
const key = match?.[1] ?? "";
|
||||
if (key && pending.has(key)) {
|
||||
// Every occurrence takes the new value: parsing keeps the last one, so
|
||||
// rewriting only the first would leave an older value in force.
|
||||
output.push(`${key}=${pending.get(key) ?? ""}`);
|
||||
written.add(key);
|
||||
continue;
|
||||
}
|
||||
output.push(line);
|
||||
}
|
||||
// Normalize the trailing newline before appending new keys.
|
||||
while (output.length && output[output.length - 1] === "") output.pop();
|
||||
for (const [key, value] of pending)
|
||||
if (!written.has(key)) output.push(`${key}=${value}`);
|
||||
return `${output.join(eol)}${eol}`;
|
||||
}
|
||||
|
||||
/** "••••" plus the last four characters; empty values stay empty. */
|
||||
export function maskSecret(value: string): string {
|
||||
if (!value) return "";
|
||||
return value.length > 4 ? `••••${value.slice(-4)}` : "••••";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection for the tab: secrets are never returned in clear.
|
||||
*
|
||||
* The managed keys and their order come from the shared metadata, so the Host
|
||||
* projection and the browser form list the same keys in the same order.
|
||||
*/
|
||||
export function envSummary(path: string, workspaceDir: string): EnvSummary {
|
||||
let text = "";
|
||||
let exists = false;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
exists = true;
|
||||
} catch {
|
||||
// A missing .env is the valid first-run state.
|
||||
}
|
||||
const parsed = parseEnvFile(text);
|
||||
const entries = MANAGED_KEYS.map((key) => {
|
||||
const value = parsed[key] ?? "";
|
||||
const secret = SECRET_KEYS.has(key);
|
||||
return {
|
||||
key,
|
||||
secret,
|
||||
set: value !== "",
|
||||
masked: secret ? maskSecret(value) : value,
|
||||
value: secret ? "" : value,
|
||||
};
|
||||
});
|
||||
return { workspaceDir, envPath: path, exists, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a .env that cannot start ReMe. Version 0.4.0.x instantiates the
|
||||
* embedding component unconditionally, so an empty key aborts startup.
|
||||
*/
|
||||
export function validateEnvUpdate(values: Record<string, string>): void {
|
||||
if (!(values["LLM_API_KEY"] ?? "").trim())
|
||||
throw new TypeError("LLM_API_KEY is required");
|
||||
if (!(values["EMBEDDING_API_KEY"] ?? "").trim())
|
||||
throw new TypeError(
|
||||
"EMBEDDING_API_KEY must be non-empty (use EMPTY when vector search is disabled)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fully merged `.env`. The caller merges and validates first, so the
|
||||
* file on disk is exactly what was validated.
|
||||
*
|
||||
* The write is atomic (same-directory temp file plus rename) and keeps the
|
||||
* previous file as `.env.bak`: a crash or a full disk must not take unrelated
|
||||
* keys such as `LLM_API_KEY` with it. New files are owner-only (0600) because
|
||||
* they hold credentials.
|
||||
*/
|
||||
export function writeEnvText(path: string, text: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, text, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
if (existsSync(path)) {
|
||||
const backup = `${path}.bak`;
|
||||
copyFileSync(path, backup);
|
||||
// The backup holds the same credentials, so it must not be more readable
|
||||
// than the file it preserves.
|
||||
try {
|
||||
chmodSync(backup, 0o600);
|
||||
} catch {
|
||||
// Windows has no POSIX mode bits; the copy is still user-owned.
|
||||
}
|
||||
}
|
||||
renameSync(temporary, path);
|
||||
} catch (error) {
|
||||
rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How one provider expects to be called.
|
||||
*
|
||||
* The tab offers native Anthropic, Gemini and Ollama backends next to the
|
||||
* OpenAI-compatible ones, so the probe has to speak each provider's own
|
||||
* protocol: an OpenAI-style request would report a working provider-native
|
||||
* configuration as broken.
|
||||
*/
|
||||
export interface LlmProtocol {
|
||||
/** Path appended to the base URL; `{model}` is the model name. */
|
||||
path: string;
|
||||
/** Header that carries the API key; empty when the provider needs none. */
|
||||
authHeader: string;
|
||||
/** Value prefix for the key header, e.g. `Bearer `. */
|
||||
authPrefix: string;
|
||||
/** Headers every request carries. */
|
||||
headers: Record<string, string>;
|
||||
/** JSON body template; `{model}` is the model name. */
|
||||
body: string;
|
||||
/** Whether the provider needs an API key at all. */
|
||||
keyRequired: boolean;
|
||||
/**
|
||||
* Where the base URL comes from.
|
||||
*
|
||||
* ReMe's credentials decide this. The OpenAI-compatible providers and
|
||||
* Anthropic read `LLM_BASE_URL` from the workspace `.env`, so the probe
|
||||
* reads it too. Gemini, Ollama and xAI ignore that variable — their
|
||||
* credentials carry their own endpoint — so the probe must use that one as
|
||||
* well: a stale value left in the `.env` would otherwise send the probe to
|
||||
* a host the service never uses, and report a working configuration as
|
||||
* broken.
|
||||
*/
|
||||
baseSource: "env" | "provider";
|
||||
/** The provider's own endpoint; used when `baseSource` is `provider`. */
|
||||
providerBase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenAI-compatible chat request, shared by the compatible providers.
|
||||
*
|
||||
* `{model}` is replaced with the JSON-quoted model name, so the placeholder
|
||||
* carries no quotes of its own.
|
||||
*/
|
||||
const OPENAI_CHAT_BODY =
|
||||
'{"model":{model},"messages":[{"role":"user","content":"ping"}],"max_tokens":1}';
|
||||
|
||||
/** The protocol ReMe itself falls back to when `.env` names no backend. */
|
||||
const OPENAI_PROTOCOL: LlmProtocol = {
|
||||
path: "/chat/completions",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
headers: {},
|
||||
body: OPENAI_CHAT_BODY,
|
||||
keyRequired: true,
|
||||
baseSource: "env",
|
||||
};
|
||||
|
||||
/** One entry per backend the tab offers; the key is the `.env` value. */
|
||||
export const LLM_PROTOCOLS: Record<string, LlmProtocol> = {
|
||||
openai: OPENAI_PROTOCOL,
|
||||
dashscope: OPENAI_PROTOCOL,
|
||||
deepseek: OPENAI_PROTOCOL,
|
||||
moonshot: OPENAI_PROTOCOL,
|
||||
anthropic: {
|
||||
path: "/v1/messages",
|
||||
authHeader: "x-api-key",
|
||||
authPrefix: "",
|
||||
headers: { "anthropic-version": "2023-06-01" },
|
||||
body: '{"model":{model},"max_tokens":1,"messages":[{"role":"user","content":"ping"}]}',
|
||||
keyRequired: true,
|
||||
baseSource: "env",
|
||||
},
|
||||
gemini: {
|
||||
path: "/v1beta/models/{model}:generateContent",
|
||||
authHeader: "x-goog-api-key",
|
||||
authPrefix: "",
|
||||
headers: {},
|
||||
body: '{"contents":[{"role":"user","parts":[{"text":"ping"}]}],"generationConfig":{"maxOutputTokens":1}}',
|
||||
keyRequired: true,
|
||||
baseSource: "provider",
|
||||
providerBase: "https://generativelanguage.googleapis.com",
|
||||
},
|
||||
ollama: {
|
||||
path: "/api/chat",
|
||||
authHeader: "",
|
||||
authPrefix: "",
|
||||
headers: {},
|
||||
body: '{"model":{model},"messages":[{"role":"user","content":"ping"}],"stream":false}',
|
||||
keyRequired: false,
|
||||
baseSource: "provider",
|
||||
providerBase: "http://localhost:11434",
|
||||
},
|
||||
xai: {
|
||||
// ReMe reaches xAI through its gRPC SDK, so this OpenAI-compatible
|
||||
// endpoint is a connectivity approximation for that backend. It is still
|
||||
// the documented REST API of the same provider and the same key, and it
|
||||
// keeps the probe from reporting a working configuration as broken.
|
||||
path: "/chat/completions",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
headers: {},
|
||||
body: OPENAI_CHAT_BODY,
|
||||
keyRequired: true,
|
||||
baseSource: "provider",
|
||||
providerBase: "https://api.x.ai/v1",
|
||||
},
|
||||
};
|
||||
|
||||
/** The backend ReMe itself falls back to when `.env` names none. */
|
||||
export { DEFAULT_LLM_BACKEND };
|
||||
|
||||
/**
|
||||
* The protocol entry the table holds for `key`, if it holds one at all.
|
||||
*
|
||||
* Only an own entry counts: the table inherits from `Object.prototype`, so a
|
||||
* plain lookup would hand out `Object` for a backend named `constructor` and
|
||||
* build a malformed probe from it.
|
||||
*/
|
||||
function ownProtocol(key: string): LlmProtocol | undefined {
|
||||
return Object.hasOwn(LLM_PROTOCOLS, key) ? LLM_PROTOCOLS[key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The protocol a backend speaks; an unknown backend uses the OpenAI-style
|
||||
* request.
|
||||
*
|
||||
* The list of backends here is a snapshot of the ones the tab offers, and
|
||||
* ReMe may support more, so an unknown name is probed tolerantly rather than
|
||||
* refused.
|
||||
*/
|
||||
export function llmProtocolFor(backend: string): LlmProtocol {
|
||||
return ownProtocol(llmBackendOf(backend)) ?? OPENAI_PROTOCOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file names a backend in a spelling ReMe will not have.
|
||||
*
|
||||
* ReMe's backend registry is case-sensitive, so a name that differs from a
|
||||
* known backend only in case is a misspelling the service refuses
|
||||
* ("Unregistered backend"). A name the plugin has never heard of is left
|
||||
* alone: its list is a snapshot, and ReMe may know more than the plugin does.
|
||||
*/
|
||||
export function backendSpellingIsWrong(name: string): boolean {
|
||||
// Own entries only. An inherited member is not a known backend: `constructor`
|
||||
// and `__proto__` would otherwise be reported as known backends written in
|
||||
// the wrong case, and the message would name a spelling ReMe refuses too.
|
||||
return (
|
||||
ownProtocol(name) === undefined &&
|
||||
ownProtocol(name.toLowerCase()) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe program for one provider, run by the selected interpreter inside
|
||||
* the workspace so it reads the same `.env` the service would discover. The
|
||||
* provider's protocol is baked into the program, so the probe cannot fall back
|
||||
* to an OpenAI-style request for a provider that does not speak it.
|
||||
*/
|
||||
export function llmProbeSnippet(protocol: LlmProtocol): string {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...protocol.headers,
|
||||
};
|
||||
const auth = protocol.authHeader
|
||||
? `headers[${JSON.stringify(protocol.authHeader)}] = ${JSON.stringify(
|
||||
protocol.authPrefix,
|
||||
)} + key\n`
|
||||
: "";
|
||||
// A provider whose own credentials ignore LLM_BASE_URL is probed with the
|
||||
// provider's endpoint, exactly as the service would call it. Reading the
|
||||
// variable anyway would send the probe to a host the service never uses.
|
||||
const base =
|
||||
protocol.baseSource === "provider"
|
||||
? `base = ${JSON.stringify(protocol.providerBase ?? "")}\n`
|
||||
: 'base = env.get("LLM_BASE_URL", "").strip().rstrip("/")\n';
|
||||
const missingBase =
|
||||
protocol.baseSource === "provider"
|
||||
? ""
|
||||
: 'if not base:\n missing.append("LLM_BASE_URL")\n';
|
||||
return `import json, time, urllib.request, urllib.error, urllib.parse
|
||||
env = {}
|
||||
try:
|
||||
with open(".env", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
${base}key = env.get("LLM_API_KEY", "").strip()
|
||||
model = env.get("LLM_MODEL_NAME", "").strip()
|
||||
missing = []
|
||||
${missingBase}if not model:
|
||||
missing.append("LLM_MODEL_NAME")
|
||||
if ${protocol.keyRequired ? "True" : "False"} and not key:
|
||||
missing.append("LLM_API_KEY")
|
||||
if missing:
|
||||
print(json.dumps({"ok": False, "status": 0, "latencyMs": 0, "error": "LLM config incomplete (" + ", ".join(missing) + ")"}))
|
||||
raise SystemExit(0)
|
||||
url = base + ${JSON.stringify(
|
||||
protocol.path,
|
||||
)}.replace("{model}", urllib.parse.quote(model, safe=""))
|
||||
body = ${JSON.stringify(
|
||||
protocol.body,
|
||||
)}.replace("{model}", json.dumps(model)).encode()
|
||||
headers = ${JSON.stringify(headers)}
|
||||
${auth}request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
started = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
response.read()
|
||||
print(json.dumps({"ok": 200 <= response.status < 300, "status": response.status, "latencyMs": int((time.time() - started) * 1000), "error": ""}))
|
||||
except urllib.error.HTTPError as error:
|
||||
print(json.dumps({"ok": False, "status": error.code, "latencyMs": int((time.time() - started) * 1000), "error": "HTTP " + str(error.code)}))
|
||||
except Exception as error:
|
||||
print(json.dumps({"ok": False, "status": 0, "latencyMs": int((time.time() - started) * 1000), "error": str(error)[:300]}))
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The LLM backend the workspace `.env` selects.
|
||||
*
|
||||
* `undefined` means the file does not hold the key at all: the service then
|
||||
* falls back to its default. A present key is returned as its trimmed value,
|
||||
* which may be the empty string — that names no backend at all, and ReMe
|
||||
* refuses to start with it, so the caller has to know the difference.
|
||||
*/
|
||||
export function readLlmBackend(envPath: string): string | undefined {
|
||||
let text = "";
|
||||
try {
|
||||
text = readFileSync(envPath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const value = parseEnvFile(text)["LLM_BACKEND"];
|
||||
return value === undefined ? undefined : value.trim();
|
||||
}
|
||||
|
||||
/** Parse the JSON line the LLM test snippet prints. */
|
||||
export function parseLlmTest(text: string): LlmTestResult {
|
||||
for (const line of text.split(/\r?\n/).reverse()) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
return {
|
||||
ok: value.ok === true,
|
||||
status: typeof value.status === "number" ? value.status : 0,
|
||||
latencyMs: typeof value.latencyMs === "number" ? value.latencyMs : 0,
|
||||
error: typeof value.error === "string" ? value.error : "",
|
||||
};
|
||||
} catch {
|
||||
// Try an earlier line.
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM test produced no result",
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the LLM probe with the selected interpreter inside the workspace. */
|
||||
export async function testLlm(
|
||||
io: { run: RunCommand },
|
||||
options: { workspaceDir: string; pythonPath: string },
|
||||
): Promise<LlmTestResult> {
|
||||
// The backend in the workspace `.env` decides the protocol; the probe must
|
||||
// not fall back to an OpenAI-style request for a provider that speaks its
|
||||
// own, or it would report a working configuration as broken.
|
||||
const backend = readLlmBackend(join(options.workspaceDir, ".env"));
|
||||
// A present but empty backend names no backend. ReMe resolves its default
|
||||
// only when the variable is absent, so the service would start with no
|
||||
// backend at all and refuse to run: the honest answer is an incomplete
|
||||
// configuration, and the request is not sent.
|
||||
if (backend === "")
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM config incomplete (LLM_BACKEND)",
|
||||
};
|
||||
// A known backend written in the wrong case is refused by ReMe's
|
||||
// case-sensitive registry, so probing it would test a configuration the
|
||||
// service cannot use.
|
||||
if (backend !== undefined && backendSpellingIsWrong(backend))
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: `LLM config invalid (LLM_BACKEND must be written in lower case: ${backend.toLowerCase()})`,
|
||||
};
|
||||
const protocol = llmProtocolFor(backend ?? DEFAULT_LLM_BACKEND);
|
||||
try {
|
||||
const result = await io.run(
|
||||
options.pythonPath,
|
||||
["-c", llmProbeSnippet(protocol)],
|
||||
{
|
||||
cwd: options.workspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
);
|
||||
if (result.error)
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: safeError(result.error),
|
||||
};
|
||||
const parsed = parseLlmTest(result.stdout);
|
||||
if (!parsed.ok && parsed.error) {
|
||||
const hint =
|
||||
parsed.status === 401 || parsed.status === 403
|
||||
? "check LLM_API_KEY"
|
||||
: parsed.status === 404
|
||||
? "check LLM_MODEL_NAME / LLM_BASE_URL"
|
||||
: /refused|timed out|getaddrinfo|Name or service/i.test(parsed.error)
|
||||
? "check LLM_BASE_URL and network"
|
||||
: "";
|
||||
if (hint) parsed.error = `${parsed.error} (${hint})`;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, latencyMs: 0, error: safeError(error) };
|
||||
}
|
||||
}
|
||||
30
integrations/dsh/src/environment/interpreter-key.ts
Normal file
30
integrations/dsh/src/environment/interpreter-key.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Canonical identity of an interpreter path, shared by the Host (install jobs)
|
||||
* and the tab (matching a job to the interpreter on screen).
|
||||
*
|
||||
* Windows paths fold case and separators, because Windows does; POSIX paths
|
||||
* stay case-sensitive. This module stays browser-safe: no Node built-ins,
|
||||
* because the client imports it too.
|
||||
*/
|
||||
|
||||
/** True when a path is written the Windows way: drive letter, UNC or backslash. */
|
||||
export function isWindowsPath(value: string): boolean {
|
||||
return (
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith("\\\\") ||
|
||||
value.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key two interpreter paths are compared by.
|
||||
*
|
||||
* The path flavor decides the comparison: a Windows path is written with
|
||||
* forward slashes and lower case, a POSIX path keeps its own spelling,
|
||||
* because it is case-sensitive there.
|
||||
*/
|
||||
export function interpreterKey(path: string): string {
|
||||
const value = path.trim();
|
||||
if (!isWindowsPath(value)) return value;
|
||||
return value.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
23
integrations/dsh/src/environment/llm-backend.ts
Normal file
23
integrations/dsh/src/environment/llm-backend.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* The LLM backend the workspace `.env` selects.
|
||||
*
|
||||
* ReMe applies its default (`${LLM_BACKEND:-openai}`) only when the variable
|
||||
* is **absent**: an explicitly empty value stays empty and names no backend,
|
||||
* and the service then refuses to start. So the default has to be written out
|
||||
* rather than left blank — see `environment/env-fields.ts`, which owns that
|
||||
* rule.
|
||||
*
|
||||
* `llmBackendOf` is the *form's* reading, not the service's: an empty
|
||||
* selection there means "use the default", so it resolves to the default
|
||||
* backend. That is what the tab compares with, so picking the default in the
|
||||
* form is not a change to the configuration.
|
||||
*
|
||||
* This module stays browser-safe: no Node built-ins, because the client
|
||||
* imports it too.
|
||||
*/
|
||||
export const DEFAULT_LLM_BACKEND = "openai";
|
||||
|
||||
/** The backend a value means in the form, with the default the configuration applies. */
|
||||
export function llmBackendOf(value: string): string {
|
||||
return (value ?? "").trim().toLowerCase() || DEFAULT_LLM_BACKEND;
|
||||
}
|
||||
238
integrations/dsh/src/environment/types.ts
Normal file
238
integrations/dsh/src/environment/types.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Environment contract shared by the DSH Host and the Environment tab.
|
||||
*
|
||||
* Everything here crosses the `remeEnv` RPC boundary, so it stays browser-safe:
|
||||
* this module must not import a node built-in. Host-only shapes (process I/O,
|
||||
* spawners, persisted state) live beside the code that owns them.
|
||||
*/
|
||||
|
||||
/** A local Python interpreter discovered on the DSH Host. */
|
||||
export type PythonOrigin =
|
||||
| { kind: "conda"; envName: string; root: string }
|
||||
| { kind: "python.org" }
|
||||
| { kind: "windows-store" }
|
||||
| { kind: "venv"; baseExe: string }
|
||||
| { kind: "system" }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface PythonEnv {
|
||||
/** Verified interpreter executable (absolute, host-native separators). */
|
||||
exe: string;
|
||||
/** "major.minor.patch" reported by the interpreter; empty when unusable. */
|
||||
version: string;
|
||||
/** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */
|
||||
ok: boolean;
|
||||
origin: PythonOrigin;
|
||||
/** Human-readable discovery channels, e.g. "conda env list; PATH". */
|
||||
source: string;
|
||||
/** reme-ai installation facts, probed for the interpreter recommendation. */
|
||||
reme?: { installed: boolean; version: string; versionOk?: boolean };
|
||||
}
|
||||
|
||||
export interface PythonDetection {
|
||||
platform: string;
|
||||
scannedAt: string;
|
||||
envs: PythonEnv[];
|
||||
/** Preferred interpreter for a local service, or "" when none is usable. */
|
||||
suggested: string;
|
||||
/** Conda installation root, or "" when conda was not found. */
|
||||
condaRoot: string;
|
||||
/** Non-fatal probe failures worth showing for transparency. */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Installed `reme-ai` facts for one interpreter. */
|
||||
export interface RemeProbe {
|
||||
installed: boolean;
|
||||
version: string;
|
||||
/** True when the version satisfies the minimum this integration targets. */
|
||||
versionOk: boolean;
|
||||
/** Non-empty when the probe itself failed (spawn error, timeout). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemeCheck extends RemeProbe {
|
||||
/** True when the AgentScope `[core]` extras import (agentscope) works. */
|
||||
hasCore: boolean;
|
||||
/** Absolute reme CLI path, or "" when only the python wrapper is available. */
|
||||
cliPath: string;
|
||||
/** Non-empty when the interpreter itself could not be probed. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type InstallPhase = "running" | "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface InstallRequest {
|
||||
/** Absolute interpreter that receives the packages. */
|
||||
pythonPath: string;
|
||||
/** Upgrade an existing installation to the newest satisfying version. */
|
||||
upgrade?: boolean;
|
||||
/** Reinstall even when pip already considers the requirement satisfied. */
|
||||
forceReinstall?: boolean;
|
||||
/** Optional package index URL (mirror); empty keeps the user's pip config. */
|
||||
indexUrl?: string;
|
||||
}
|
||||
|
||||
export interface InstallStatus {
|
||||
id: string;
|
||||
phase: InstallPhase;
|
||||
pythonPath: string;
|
||||
/** Effective command line, for display and diagnostics. */
|
||||
command: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
logPath: string;
|
||||
/** Tail of the install log; safe to poll. */
|
||||
logTail: string;
|
||||
/** True between a cancel request and the pip process actually exiting. */
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvEntry {
|
||||
key: string;
|
||||
secret: boolean;
|
||||
/** True when the key has a non-empty value on disk. */
|
||||
set: boolean;
|
||||
/** Masked tail for secrets; the plain value for non-secret keys. */
|
||||
masked: string;
|
||||
/** Plain value for non-secret keys; empty for secrets. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EnvSummary {
|
||||
workspaceDir: string;
|
||||
envPath: string;
|
||||
exists: boolean;
|
||||
entries: EnvEntry[];
|
||||
}
|
||||
|
||||
export interface LlmTestResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
latencyMs: number;
|
||||
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;
|
||||
ok: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ServiceState {
|
||||
phase: "running" | "stopped" | "conflict";
|
||||
/** True when the running service was started by this plugin. */
|
||||
managed: boolean;
|
||||
endpoint: string;
|
||||
pid?: number;
|
||||
version?: string;
|
||||
startedAt?: string;
|
||||
/**
|
||||
* The record that describes the process behind this status, when the Host
|
||||
* could not prove that the process is the one the record describes.
|
||||
*
|
||||
* The process is then never signalled — an unreadable identity is no proof
|
||||
* — but the record is what the tab has to drop to get the port back, so it
|
||||
* travels with the status instead of waiting for a refusal that a disabled
|
||||
* button can never trigger.
|
||||
*/
|
||||
record?: ServiceRecordRef;
|
||||
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"
|
||||
| "unverified-record"
|
||||
| "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"
|
||||
| "record-changed";
|
||||
|
||||
/**
|
||||
* Identity of the service record an action was refused on behalf of.
|
||||
*
|
||||
* The record is not necessarily the endpoint on screen: a launch for another
|
||||
* port can block the start of this one. The Host names the record it could not
|
||||
* act on, so the recovery action can target that record instead of guessing.
|
||||
*/
|
||||
export interface ServiceRecordRef {
|
||||
/** Endpoint the record was written for. */
|
||||
endpoint: string;
|
||||
/** Recorded pid, when the record has one. */
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface ServiceStartResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
pid?: number;
|
||||
version?: string;
|
||||
command: string;
|
||||
logPath: string;
|
||||
startedAt: string;
|
||||
/** English diagnostic; shown only when no known `code` applies. */
|
||||
error?: string;
|
||||
code?: ServiceStartErrorCode;
|
||||
/** The record that blocked the start, when one did. */
|
||||
record?: ServiceRecordRef;
|
||||
}
|
||||
|
||||
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;
|
||||
/** The record that blocked the stop, when one did. */
|
||||
record?: ServiceRecordRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of dropping a service record without touching the process. This is
|
||||
* the escape hatch for a live process whose identity cannot be verified: Stop
|
||||
* and Start both refuse to act on it, so the record can only be forgotten.
|
||||
*/
|
||||
export interface ServiceForgetResult {
|
||||
forgotten: boolean;
|
||||
pid?: number;
|
||||
reason?: string;
|
||||
code?: ServiceStopReasonCode;
|
||||
/** The record that was refused, when one was. */
|
||||
record?: ServiceRecordRef;
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import type {} from "@deepseek-ai/dsh-session";
|
|||
import { ReMeClient } from "./reme/client.js";
|
||||
|
||||
import { resolveConfig } from "./config.js";
|
||||
import { ReMeEnvGateway } from "./environment/host/gateway.js";
|
||||
import { ReMeEnvManager } from "./environment/host/manager.js";
|
||||
import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js";
|
||||
import { ReMeRuntime } from "./runtime.js";
|
||||
import { ReMeStatusGateway } from "./status-gateway.js";
|
||||
|
|
@ -25,7 +27,31 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
|
|||
return () => runtime.disposeAll();
|
||||
}, "remeMemory.lifecycle()");
|
||||
ctx.provide("remeMemory", runtime);
|
||||
const envManager = new ReMeEnvManager();
|
||||
ctx.provide("remeEnv", envManager);
|
||||
// The manager owns the bounded operations it started (pip installs), so it
|
||||
// releases them when this plugin unloads. The detached ReMe service is not
|
||||
// part of that lifetime: it is meant to outlive DSH.
|
||||
ctx.effect(() => () => envManager.dispose(), "remeMemory.envLifetime()");
|
||||
void ctx.plugin(ReMeStatusGateway);
|
||||
void ctx.plugin(ReMeEnvGateway);
|
||||
ctx.effect(() => {
|
||||
const live = current();
|
||||
if (live.autoStart)
|
||||
void envManager
|
||||
.autoStart({
|
||||
endpoint: live.endpoint,
|
||||
pythonPath: live.pythonPath,
|
||||
workspaceDir: live.remeWorkspaceDir,
|
||||
authToken: live.authToken,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.logger.warn("remeMemory.envAutoStart failed", {
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
return () => {};
|
||||
}, "remeMemory.envAutoStart()");
|
||||
ctx.effect(
|
||||
() => registerReMeTools(ctx, client, current),
|
||||
"remeMemory.tools()",
|
||||
|
|
|
|||
|
|
@ -52,9 +52,15 @@ export class ReMeClient {
|
|||
sessionId: string,
|
||||
options: AutoMemoryOptions = {},
|
||||
): Promise<ReMeResult> {
|
||||
const config = {
|
||||
...this.configSource(),
|
||||
...(options.endpoint ? { endpoint: options.endpoint } : {}),
|
||||
const base = this.configSource();
|
||||
const endpoint = options.endpoint || base.endpoint;
|
||||
// A recovered batch keeps the endpoint it was queued for. The token is
|
||||
// scoped to the *configured* endpoint, so it must not ride along when the
|
||||
// batch goes somewhere else.
|
||||
const config: ReMeClientConfig = {
|
||||
...base,
|
||||
endpoint,
|
||||
...(endpoint === base.endpoint ? {} : { authToken: "" }),
|
||||
};
|
||||
return this.request(
|
||||
"auto_memory",
|
||||
|
|
@ -199,11 +205,16 @@ export class ReMeClient {
|
|||
throw new TypeError(
|
||||
"Invalid ReMe endpoint: credentials, query, and fragment are unsupported",
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// The token belongs to the endpoint it was configured for, so it is sent
|
||||
// here and not on any other request this plugin makes.
|
||||
if (config.authToken)
|
||||
headers.Authorization = `Bearer ${config.authToken}`;
|
||||
const response = await fetch(`${config.endpoint}/${job}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ export interface ReMeClientConfig {
|
|||
endpoint: string;
|
||||
requestTimeoutMs: number;
|
||||
backgroundTimeoutMs: number;
|
||||
/**
|
||||
* Shared secret sent as `Authorization: Bearer` to this endpoint only. Empty
|
||||
* or absent means the service runs without authentication.
|
||||
*/
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/** Normalized response returned by the ReMe HTTP client. */
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import type { ReMeClientConfig } from "./reme/types.js";
|
|||
|
||||
export interface ReMeConfigInput {
|
||||
endpoint?: string;
|
||||
authToken?: string;
|
||||
pythonPath?: string;
|
||||
remeWorkspaceDir?: string;
|
||||
autoStart?: boolean;
|
||||
requestTimeoutMs?: number;
|
||||
backgroundTimeoutMs?: number;
|
||||
shutdownTimeoutMs?: number;
|
||||
|
|
@ -22,6 +26,10 @@ export interface ReMeConfigInput {
|
|||
|
||||
export interface ReMeConfig extends ReMeClientConfig {
|
||||
shutdownTimeoutMs: number;
|
||||
authToken: string;
|
||||
pythonPath: string;
|
||||
remeWorkspaceDir: string;
|
||||
autoStart: boolean;
|
||||
autoMemoryEnabled: boolean;
|
||||
autoMemoryInterval: number;
|
||||
autoMemoryQueueDir: string;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ test("declares one installable DeepSeek Harness plugin", async () => {
|
|||
"0.2.1-alpha.2",
|
||||
"0.2.1-alpha.4",
|
||||
"0.2.1-rc.2",
|
||||
"0.2.2-alpha.2",
|
||||
]) {
|
||||
assert.ok(
|
||||
semver.satisfies(version, manifest.peerDependencies[dependency]),
|
||||
|
|
@ -51,8 +52,34 @@ test("declares one installable DeepSeek Harness plugin", async () => {
|
|||
assert.equal(manifest.peerDependenciesMeta[dependency]?.optional, true);
|
||||
}
|
||||
assert.equal(manifest.peerDependencies.openclaw, undefined);
|
||||
// The framework ranges track the same releases as the DSH packages, so a
|
||||
// supported DSH install cannot pull a framework the plugin rejects.
|
||||
for (const [dependency, supported, rejected] of [
|
||||
[
|
||||
"@deepseek-ai/cordis",
|
||||
["4.0.4", "4.0.5-alpha.1", "4.0.5", "4.0.6-alpha.1", "4.1.0"],
|
||||
["4.0.3", "5.0.0"],
|
||||
],
|
||||
[
|
||||
"@deepseek-ai/schemastery",
|
||||
["3.18.4", "3.18.5-alpha.1", "3.18.5", "3.18.6-alpha.1"],
|
||||
["3.18.3", "4.0.0"],
|
||||
],
|
||||
]) {
|
||||
for (const version of supported)
|
||||
assert.ok(
|
||||
semver.satisfies(version, manifest.peerDependencies[dependency]),
|
||||
`${dependency} ${version} should be supported`,
|
||||
);
|
||||
for (const version of rejected)
|
||||
assert.equal(
|
||||
semver.satisfies(version, manifest.peerDependencies[dependency]),
|
||||
false,
|
||||
`${dependency} ${version} should be rejected`,
|
||||
);
|
||||
}
|
||||
assert.match(patch, /remeMemory: true/);
|
||||
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/);
|
||||
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/);
|
||||
assert.doesNotMatch(patch, /id: reme-memory-runtime/);
|
||||
assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/);
|
||||
assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1);
|
||||
|
|
@ -75,6 +102,14 @@ test("builds a lazy DSH browser module for the ReMe settings card", async () =>
|
|||
assert.match(bundle, /Personal Knowledge Base/);
|
||||
assert.match(statusPage, /个人知识库/);
|
||||
assert.match(bundle, /health_check/);
|
||||
// The settings card stages its drafts with DSH's own form model, shipped by
|
||||
// the primitives package this bundle keeps external.
|
||||
assert.match(
|
||||
bundle,
|
||||
/require\("@deepseek-ai\/dsh-client-ui-primitives"\)/,
|
||||
"the client bundle resolves the primitives package at runtime",
|
||||
);
|
||||
assert.match(bundle, /SettingsFormModel/);
|
||||
});
|
||||
|
||||
test("registers configuration on the ReMe bundle row", async () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,78 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import React from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { resolveConfig } from "../dist/config.js";
|
||||
|
||||
/**
|
||||
* The snapshot store the form model binds to.
|
||||
*
|
||||
* `@deepseek-ai/dsh-client-store` is not installed in this package, so the
|
||||
* harness supplies the contract the real package offers: the store keeps the
|
||||
* value it was given and hands that very reference back from `getSnapshot()`,
|
||||
* and it notifies its listeners only when the value actually changed. The
|
||||
* browser loads the real package; this stand-in only has to behave like it for
|
||||
* the card's reads.
|
||||
*/
|
||||
function createSnapshotStore(init) {
|
||||
let value = init;
|
||||
const listeners = new Set();
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
set(next) {
|
||||
if (Object.is(next, value)) return;
|
||||
value = next;
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the SettingsFormModel this plugin links against, straight out of the
|
||||
* shipped primitives bundle.
|
||||
*
|
||||
* The package cannot be imported in Node (it loads CSS modules, icons and the
|
||||
* syntax highlighter), but the form model region is plain JavaScript. Running
|
||||
* that region means the card is exercised against DSH's real model instead of a
|
||||
* re-implementation of it; a changed bundle layout fails here loudly.
|
||||
*/
|
||||
function loadSettingsFormModel() {
|
||||
// Resolve the installed package rather than a fixed path: the packaged-plugin
|
||||
// check installs it hoisted above this package, while the repository keeps it
|
||||
// inside the package's own node_modules.
|
||||
const manifest = createRequire(import.meta.url).resolve(
|
||||
"@deepseek-ai/dsh-client-ui-primitives/package.json",
|
||||
);
|
||||
const source = readFileSync(
|
||||
join(dirname(manifest), "lib", "index.js"),
|
||||
"utf8",
|
||||
);
|
||||
const start = source.indexOf(
|
||||
"//#region lib/types/settings-form/form-model.js",
|
||||
);
|
||||
const end = source.indexOf("//#endregion", start + 1);
|
||||
assert.ok(
|
||||
start >= 0 && end > start,
|
||||
"unexpected @deepseek-ai/dsh-client-ui-primitives bundle layout",
|
||||
);
|
||||
// The model binds its projections through `createSnapshotStore`, which the
|
||||
// browser gets from `@deepseek-ai/dsh-client-store`; the harness passes the
|
||||
// stand-in above instead.
|
||||
return new Function(
|
||||
"createSnapshotStore",
|
||||
`${source.slice(start, end)}\nreturn SettingsFormModel;`,
|
||||
)(createSnapshotStore);
|
||||
}
|
||||
|
||||
async function editor(mutate) {
|
||||
let module;
|
||||
const bundle = await readFile(
|
||||
|
|
@ -15,6 +81,13 @@ async function editor(mutate) {
|
|||
);
|
||||
vm.runInNewContext(bundle, {
|
||||
URL,
|
||||
// The card disposes its form model on a deferred timer, so the new
|
||||
// context needs the timer globals: without them the cleanup throws
|
||||
// `ReferenceError: setTimeout is not defined` and React reports it.
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
document: {
|
||||
head: { appendChild() {} },
|
||||
createElement: () => ({ dataset: {}, remove() {} }),
|
||||
|
|
@ -29,13 +102,18 @@ async function editor(mutate) {
|
|||
});
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const client = module.factory((name) =>
|
||||
name === "@deepseek-ai/dsh-client-ui-primitives" ? {} : nodeRequire(name),
|
||||
name === "@deepseek-ai/dsh-client-ui-primitives"
|
||||
? { SettingsFormModel: loadSettingsFormModel() }
|
||||
: nodeRequire(name),
|
||||
);
|
||||
let Component;
|
||||
let snapshot = {
|
||||
status: "ready",
|
||||
value: resolveConfig({}, {}),
|
||||
base: resolveConfig({}, {}),
|
||||
// The Host reports the user layer it stored; every field here is an
|
||||
// override, which is what "reset to defaults" clears.
|
||||
user: resolveConfig({}, {}),
|
||||
revision: 7,
|
||||
writable: true,
|
||||
};
|
||||
|
|
@ -98,14 +176,22 @@ async function editor(mutate) {
|
|||
input.props.onChange({ target: { value } });
|
||||
});
|
||||
},
|
||||
input(index) {
|
||||
return renderer.root.findAllByType("input")[index].props;
|
||||
},
|
||||
button(label) {
|
||||
return renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button) => button.children.includes(label));
|
||||
},
|
||||
text: () => JSON.stringify(renderer.toJSON()),
|
||||
close() {
|
||||
async close() {
|
||||
act(() => renderer.unmount());
|
||||
// The card defers its disposal to the next tick, so the cleanup runs
|
||||
// inside the test instead of after it.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
for (const dispose of disposers) dispose?.();
|
||||
},
|
||||
};
|
||||
|
|
@ -131,7 +217,77 @@ test("saves all edited fields in one revision-fenced mutation", async () => {
|
|||
assert.match(ui.text(), /saved/);
|
||||
assert.equal(ui.button("save").props.disabled, true);
|
||||
} finally {
|
||||
ui.close();
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("repaints the controls and the unsaved marker from the model's publishes", async () => {
|
||||
const ui = await editor(async () => true);
|
||||
try {
|
||||
assert.doesNotMatch(ui.text(), /unsaved/);
|
||||
// A staged edit publishes, and the card renders what the model holds: the
|
||||
// control shows the draft and the card says the form is unsaved.
|
||||
ui.change(0, "http://memory.test");
|
||||
assert.equal(ui.input(0).value, "http://memory.test");
|
||||
assert.match(ui.text(), /unsaved/);
|
||||
// Dropping the drafts publishes as well, and the marker goes with them.
|
||||
act(() => ui.button("discard").props.onClick());
|
||||
assert.doesNotMatch(ui.text(), /unsaved/);
|
||||
assert.notEqual(ui.input(0).value, "http://memory.test");
|
||||
} finally {
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("follows the namespace status through the model's publishes", async () => {
|
||||
const ui = await editor(async () => true);
|
||||
try {
|
||||
assert.notEqual(ui.text(), "null");
|
||||
// The card reads the scope through the model, so a status change reaches
|
||||
// it as a publish rather than as a subscription of its own.
|
||||
act(() => ui.update({ status: "loading" }));
|
||||
assert.match(ui.text(), /loading/);
|
||||
act(() => ui.update({ status: "unavailable" }));
|
||||
assert.equal(ui.text(), "null");
|
||||
} finally {
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the save button follows the model's own saving state", async () => {
|
||||
let release = () => {};
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const ui = await editor(async () => {
|
||||
await gate;
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
ui.change(0, "http://memory.test");
|
||||
await act(async () => {
|
||||
ui.button("save").props.onClick();
|
||||
});
|
||||
// The write is in flight: the model's own state says so, and no second
|
||||
// flag in the card could disagree with it.
|
||||
const inFlight = ui.button("saving");
|
||||
assert.ok(inFlight);
|
||||
assert.equal(inFlight.props.disabled, true);
|
||||
assert.equal(ui.button("save"), undefined);
|
||||
assert.equal(ui.calls.length, 1);
|
||||
|
||||
await act(async () => {
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// The save landed: the button is idle again, disabled because the form
|
||||
// holds no drafts, and the result is reported.
|
||||
assert.equal(ui.button("saving"), undefined);
|
||||
assert.match(ui.text(), /"saved"/);
|
||||
assert.equal(ui.button("save").props.disabled, true);
|
||||
} finally {
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +310,7 @@ for (const outcome of ["refused", "transport failure"]) {
|
|||
act(() => ui.button("discard").props.onClick());
|
||||
assert.doesNotMatch(ui.text(), /http:\/\/memory.test/);
|
||||
} finally {
|
||||
ui.close();
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -172,7 +328,7 @@ test("resets overrides in one mutation only after Save", async () => {
|
|||
assert.ok(ui.calls[0].ops.every((op) => op.op === "unset"));
|
||||
assert.equal(ui.calls[0].revision, 7);
|
||||
} finally {
|
||||
ui.close();
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -188,7 +344,7 @@ test("read-only configuration cannot be saved", async () => {
|
|||
assert.equal(ui.button("save").props.disabled, true);
|
||||
assert.match(ui.text(), /readOnly/);
|
||||
} finally {
|
||||
ui.close();
|
||||
await ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -213,7 +369,7 @@ for (const endpoint of [
|
|||
});
|
||||
assert.equal(ui.calls.length, 0);
|
||||
} finally {
|
||||
ui.close();
|
||||
await ui.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -100,6 +100,32 @@ test("uses the persisted destination and request ID when sending a recovered bat
|
|||
}
|
||||
});
|
||||
|
||||
test("scopes the bearer token to the endpoint it was configured for", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
requests.push({ url, authorization: init.headers.Authorization });
|
||||
return Response.json({ success: true });
|
||||
};
|
||||
try {
|
||||
const client = new ReMeClient({
|
||||
endpoint: "http://new.test",
|
||||
authToken: "current-token",
|
||||
requestTimeoutMs: 1000,
|
||||
backgroundTimeoutMs: 1000,
|
||||
});
|
||||
// The current endpoint keeps the token.
|
||||
await client.search("fact");
|
||||
assert.equal(requests.at(-1).authorization, "Bearer current-token");
|
||||
// A recovered batch for another endpoint must not receive it.
|
||||
await client.autoMemory([], "session", { endpoint: "http://old.test" });
|
||||
assert.equal(requests.at(-1).url, "http://old.test/auto_memory");
|
||||
assert.equal(requests.at(-1).authorization, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("calls ReMe jobs with their native request and response envelopes", async () => {
|
||||
const calls = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
|
|
|||
59
integrations/dsh/tests/docs-render.test.mjs
Normal file
59
integrations/dsh/tests/docs-render.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const docs = join(dirname(fileURLToPath(import.meta.url)), "..", "docs");
|
||||
|
||||
/**
|
||||
* Whether the DSH Web GUI renders this code span as a clickable link.
|
||||
*
|
||||
* The GUI's markdown renderer has an explicit case for it: an `inlineCode`
|
||||
* node whose content is exactly an http(s) URL becomes
|
||||
* `<code><a href="...">URL</a></code>` — the code chip is a link.
|
||||
*
|
||||
* function uU(e){ if(e.trim()===e) try{ const t=new URL(e).protocol;
|
||||
* return t==="http:"||t==="https:"?e:void 0 }catch{return} }
|
||||
*
|
||||
* (from `@deepseek-ai/dsh-web-frontend`, the `inlineCode` case of the
|
||||
* markdown renderer.) A standard CommonMark renderer leaves a code span inert,
|
||||
* so this only shows up in the GUI — and an example endpoint is not something a
|
||||
* reader should be able to click.
|
||||
*/
|
||||
function guiLinkifies(code) {
|
||||
if (code.trim() !== code) return undefined;
|
||||
try {
|
||||
const protocol = new URL(code).protocol;
|
||||
return protocol === "http:" || protocol === "https:" ? code : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Code spans of a markdown document, fenced blocks left out. */
|
||||
function codeSpans(text) {
|
||||
const withoutFences = text.replace(/^```[\s\S]*?^```/gm, "");
|
||||
return [...withoutFences.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
test("the user docs hold no code span the GUI turns into a link", (t) => {
|
||||
for (const file of ["environment.md", "environment_ZH.md"]) {
|
||||
const path = join(docs, file);
|
||||
// The docs are part of the package (`files` includes `docs`), so the check
|
||||
// runs in the packed install too. This branch only guards a checkout that
|
||||
// does not have them.
|
||||
if (!existsSync(path)) {
|
||||
t.skip(`${file} is not part of this checkout`);
|
||||
continue;
|
||||
}
|
||||
const offenders = codeSpans(readFileSync(path, "utf8")).filter(
|
||||
(code) => guiLinkifies(code) !== undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`${file} holds a code span the GUI renders as a clickable link`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -39,7 +39,7 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a
|
|||
fiber: { state: 0 },
|
||||
logger: { debug() {}, warn() {}, log() {} },
|
||||
provide(name, value) {
|
||||
assert.equal(name, "remeMemory");
|
||||
assert.ok(name === "remeMemory" || name === "remeEnv");
|
||||
assert.ok(value);
|
||||
},
|
||||
plugin() {
|
||||
|
|
@ -261,3 +261,56 @@ function applyPlugin(t, ctx, input) {
|
|||
});
|
||||
return apply(ctx, { ...input, autoMemoryQueueDir: directory });
|
||||
}
|
||||
|
||||
test("disposes the environment manager when the plugin unloads", async (t) => {
|
||||
const effects = [];
|
||||
const provided = {};
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-dsh-dispose-"));
|
||||
t.after(async () => {
|
||||
// Stop the runtime before removing the queue directory it reads on shutdown.
|
||||
await provided.remeMemory?.disposeAll();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const ctx = {
|
||||
fiber: { state: 0 },
|
||||
logger: { debug() {}, warn() {}, log() {} },
|
||||
provide(name, value) {
|
||||
provided[name] = value;
|
||||
},
|
||||
plugin() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
inject() {},
|
||||
effect(execute, label) {
|
||||
effects.push({ label, cleanup: execute() });
|
||||
return () => {};
|
||||
},
|
||||
tools: {
|
||||
register() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
apply(ctx, {
|
||||
autoMemoryEnabled: false,
|
||||
autoDreamEnabled: false,
|
||||
autoMemoryQueueDir: directory,
|
||||
});
|
||||
|
||||
const manager = provided.remeEnv;
|
||||
assert.ok(manager, "the environment manager is provided to the context");
|
||||
let disposed = 0;
|
||||
manager.dispose = () => {
|
||||
disposed += 1;
|
||||
};
|
||||
|
||||
// The plugin owns the lifetime of the installs it started, so unloading it
|
||||
// must reach the manager rather than leaving pip running.
|
||||
const lifetime = effects.find(
|
||||
(entry) => entry.label === "remeMemory.envLifetime()",
|
||||
);
|
||||
assert.ok(lifetime?.cleanup, "the env lifetime effect registers a cleanup");
|
||||
lifetime.cleanup();
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
|
|
|||
669
integrations/dsh/tests/env-detect.test.mjs
Normal file
669
integrations/dsh/tests/env-detect.test.mjs
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
condaRootFromPath,
|
||||
detectPythonEnvs,
|
||||
isWindowsAppsAlias,
|
||||
makeCondaRunner,
|
||||
parseCondaEnvs,
|
||||
parseCondaRoot,
|
||||
parsePathList,
|
||||
parsePyListPaths,
|
||||
parseRegQuery,
|
||||
parseVerifyOutput,
|
||||
suggestInterpreter,
|
||||
versionAtLeast,
|
||||
} from "../dist/environment/host/detect.js";
|
||||
|
||||
function fakeRun(rules) {
|
||||
return async (exe, args) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.match(exe, args))
|
||||
return typeof rule.result === "function"
|
||||
? rule.result(args)
|
||||
: rule.result;
|
||||
}
|
||||
throw new Error(`unexpected command: ${exe} ${args.join(" ")}`);
|
||||
};
|
||||
}
|
||||
|
||||
const VERIFY_ARG0 = "-c";
|
||||
|
||||
function verifyRule(exe, version, base) {
|
||||
return {
|
||||
match: (actual, args) =>
|
||||
actual.toLowerCase() === exe.toLowerCase() &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("import sys"),
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: `note\n{"exe":"${exe.replaceAll(
|
||||
"\\",
|
||||
"\\\\",
|
||||
)}","ver":"${version}","base":"${base.replaceAll("\\", "\\\\")}"}\n`,
|
||||
stderr: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** reme-ai metadata probe; version null means "not installed". */
|
||||
function remeRule(exe, version) {
|
||||
return {
|
||||
match: (actual, args) =>
|
||||
actual.toLowerCase() === exe.toLowerCase() &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("importlib.metadata"),
|
||||
result: version
|
||||
? { code: 0, stdout: `${version}\n`, stderr: "" }
|
||||
: { code: 1, stdout: "", stderr: "PackageNotFoundError" },
|
||||
};
|
||||
}
|
||||
|
||||
function windowsFixture() {
|
||||
const miniconda = "D:\\ProgramFils\\Miniconda";
|
||||
const remeServe = `${miniconda}\\envs\\reme-serve`;
|
||||
const old38 = `${miniconda}\\envs\\old38`;
|
||||
const storePackage =
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0";
|
||||
const rules = [
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "reg.exe" &&
|
||||
args[0] === "query" &&
|
||||
args[1] === "HKCU\\Software\\Python",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: [
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12",
|
||||
" (默认) REG_SZ 3.12",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath`,
|
||||
` (默认) REG_SZ ${miniconda}\\`,
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12\\InstallPath`,
|
||||
" (Default) REG_SZ C:\\Python312\\",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonLegacy\\3.8\\InstallPath`,
|
||||
" (Default) REG_SZ C:\\Python38",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11\\InstallPath`,
|
||||
` (Default) REG_SZ ${storePackage}\\`,
|
||||
"",
|
||||
].join("\r\n"),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "reg.exe" && args[0] === "query",
|
||||
result: { code: 1, stdout: "", stderr: "key not found" },
|
||||
},
|
||||
{
|
||||
match: (exe) => exe === "py.exe",
|
||||
result: {
|
||||
code: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: "spawn py.exe ENOENT",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "where.exe" && args[0] === "python",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: [
|
||||
miniconda + "\\python.exe",
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "where.exe" && args[0] === "conda",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: `${miniconda}\\Library\\bin\\conda.bat\r\n`,
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === `${miniconda}\\Scripts\\conda.exe` &&
|
||||
args[0] === "info" &&
|
||||
args[1] === "--json",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ root_prefix: miniconda }),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === `${miniconda}\\Scripts\\conda.exe` &&
|
||||
args[0] === "env" &&
|
||||
args[1] === "list",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
envs: [miniconda, remeServe, old38],
|
||||
}),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule(`${miniconda}\\python.exe`, "3.12.3", miniconda),
|
||||
verifyRule(`${remeServe}\\python.exe`, "3.12.13", remeServe),
|
||||
verifyRule(`${old38}\\python.exe`, "3.8.10", old38),
|
||||
verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"),
|
||||
verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"),
|
||||
verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage),
|
||||
remeRule(`${miniconda}\\python.exe`, "0.4.0.9"),
|
||||
remeRule(`${remeServe}\\python.exe`, "0.4.0.9"),
|
||||
remeRule(`${old38}\\python.exe`, null),
|
||||
remeRule("C:\\Python312\\python.exe", "0.4.1.11"),
|
||||
remeRule("C:\\Python38\\python.exe", null),
|
||||
remeRule(`${storePackage}\\python.exe`, null),
|
||||
];
|
||||
return {
|
||||
rules,
|
||||
miniconda,
|
||||
remeServe,
|
||||
old38,
|
||||
storePackage,
|
||||
io: {
|
||||
run: fakeRun(rules),
|
||||
platform: "win32",
|
||||
env: { USERPROFILE: "C:\\Users\\u", ProgramData: "C:\\ProgramData" },
|
||||
homedir: "C:\\Users\\u",
|
||||
exists: (path) => path === `${miniconda}\\Scripts\\conda.exe`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("parses PEP 514 registry output across locales, mojibake and empty values", () => {
|
||||
const found = parseRegQuery(
|
||||
[
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
|
||||
" (\uFFFD\uFFFD) REG_SZ D:\\ProgramFils\\Miniconda\\",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.11\\InstallPath",
|
||||
" (Default) REG_SZ C:\\Python311",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\Localized\\3.10\\InstallPath",
|
||||
" (默认) REG_SZ C:\\Python310",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\Empty\\InstallPath",
|
||||
" (\uFFFD\uFFFD) REG_SZ (\uFFFD\uFFFD\uFFFD\uFFFD)",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
found.map((entry) => entry.exe),
|
||||
[
|
||||
"D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
"C:\\Python311\\python.exe",
|
||||
"C:\\Python310\\python.exe",
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
found[0].source,
|
||||
"registry HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses py launcher and path listings", () => {
|
||||
assert.deepEqual(
|
||||
parsePyListPaths(
|
||||
[
|
||||
" -V:3.12 * D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
" -V:3.11 C:\\Python311\\python.exe",
|
||||
" -V:2.7 C:\\Python27\\python.exe",
|
||||
].join("\n"),
|
||||
),
|
||||
[
|
||||
"D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
"C:\\Python311\\python.exe",
|
||||
"C:\\Python27\\python.exe",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(parsePathList("a\\python.exe\r\n\r\nb\\python.exe\n"), [
|
||||
"a\\python.exe",
|
||||
"b\\python.exe",
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops Store alias stubs but keeps real Store interpreters", () => {
|
||||
assert.equal(
|
||||
isWindowsAppsAlias(
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python3.exe",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWindowsAppsAlias(
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\\python.exe",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isWindowsAppsAlias("C:\\Python312\\python.exe"), false);
|
||||
});
|
||||
|
||||
test("compares interpreter versions numerically", () => {
|
||||
assert.equal(versionAtLeast("3.12.3"), true);
|
||||
assert.equal(versionAtLeast("3.11.0"), true);
|
||||
assert.equal(versionAtLeast("3.12"), true);
|
||||
assert.equal(versionAtLeast("3.10.8"), false);
|
||||
assert.equal(versionAtLeast("3.8"), false);
|
||||
assert.equal(versionAtLeast(""), false);
|
||||
assert.equal(versionAtLeast("abc"), false);
|
||||
});
|
||||
|
||||
test("parses interpreter verify output embedded in noisy stdout", () => {
|
||||
const info = parseVerifyOutput(
|
||||
'Warning: something\n{"exe":"D:\\\\x\\\\python.exe","ver":"3.12.3","base":"D:\\\\x"}\n',
|
||||
);
|
||||
assert.deepEqual(info, {
|
||||
exe: "D:\\x\\python.exe",
|
||||
version: "3.12.3",
|
||||
basePrefix: "D:\\x",
|
||||
});
|
||||
assert.equal(parseVerifyOutput("no json here"), undefined);
|
||||
});
|
||||
|
||||
test("parses conda json output defensively", () => {
|
||||
assert.deepEqual(parseCondaEnvs('{"envs":["D:\\\\a","D:\\\\b"]}'), [
|
||||
"D:\\a",
|
||||
"D:\\b",
|
||||
]);
|
||||
assert.deepEqual(parseCondaEnvs("not json"), []);
|
||||
assert.deepEqual(parseCondaEnvs('{"envs":42}'), []);
|
||||
assert.equal(parseCondaRoot('{"root_prefix":"D:\\\\a"}'), "D:\\a");
|
||||
assert.equal(parseCondaRoot("garbage"), "");
|
||||
});
|
||||
|
||||
test("resolves every fixture with its own platform's path semantics", () => {
|
||||
// The flavor comes from the path, not from the host running the test, so
|
||||
// these assertions hold on Windows, macOS and Linux alike.
|
||||
assert.equal(
|
||||
condaRootFromPath("D:\\ProgramFils\\Miniconda\\Scripts\\conda.exe"),
|
||||
"D:\\ProgramFils\\Miniconda",
|
||||
);
|
||||
assert.equal(
|
||||
condaRootFromPath("D:\\ProgramFils\\Miniconda\\condabin\\conda.bat"),
|
||||
"D:\\ProgramFils\\Miniconda",
|
||||
);
|
||||
assert.equal(
|
||||
condaRootFromPath("/opt/homebrew/Caskroom/miniconda/base/bin/conda"),
|
||||
"/opt/homebrew/Caskroom/miniconda/base",
|
||||
);
|
||||
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
|
||||
});
|
||||
|
||||
test("derives conda roots and prefers the real conda.exe over the .bat", async () => {
|
||||
assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc");
|
||||
assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc");
|
||||
assert.equal(
|
||||
condaRootFromPath("/home/u/miniconda3/bin/conda"),
|
||||
"/home/u/miniconda3",
|
||||
);
|
||||
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
|
||||
|
||||
const calls = [];
|
||||
const run = async (exe, args) => {
|
||||
calls.push([exe, args]);
|
||||
return { code: 0, stdout: "{}", stderr: "" };
|
||||
};
|
||||
const runner = makeCondaRunner(
|
||||
run,
|
||||
"D:\\mc\\Library\\bin\\conda.bat",
|
||||
true,
|
||||
(path) => path === "D:\\mc\\Scripts\\conda.exe",
|
||||
);
|
||||
await runner(["info", "--json"]);
|
||||
assert.deepEqual(calls[0][0], "D:\\mc\\Scripts\\conda.exe");
|
||||
|
||||
const fallback = [];
|
||||
const runner2 = makeCondaRunner(
|
||||
async (exe, args) => {
|
||||
fallback.push([exe, args]);
|
||||
return { code: null, stdout: "", stderr: "", error: "spawn ENOENT" };
|
||||
},
|
||||
"D:\\mc\\Library\\bin\\conda.bat",
|
||||
true,
|
||||
() => false,
|
||||
);
|
||||
await runner2(["env", "list", "--json"]);
|
||||
assert.equal(fallback.at(-1)[0], "cmd.exe");
|
||||
assert.ok(fallback.at(-1)[1].includes("/s"));
|
||||
});
|
||||
|
||||
test("detects, classifies and suggests Windows interpreters end to end", async () => {
|
||||
const { io, miniconda, remeServe, old38, storePackage } = windowsFixture();
|
||||
const detection = await detectPythonEnvs(io);
|
||||
|
||||
assert.equal(detection.platform, "win32");
|
||||
assert.equal(detection.condaRoot, miniconda);
|
||||
assert.deepEqual(detection.errors, []);
|
||||
assert.equal(detection.suggested, `${remeServe}\\python.exe`);
|
||||
|
||||
const exes = detection.envs.map((env) => env.exe.toLowerCase());
|
||||
assert.ok(!exes.some((exe) => exe.includes("windowsapps\\\\python.exe")));
|
||||
assert.ok(
|
||||
!exes.some(
|
||||
(exe) =>
|
||||
exe ===
|
||||
"c:\\users\\u\\appdata\\local\\microsoft\\windowsapps\\python.exe",
|
||||
),
|
||||
);
|
||||
assert.equal(detection.envs.length, 6);
|
||||
|
||||
const base = detection.envs.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName === "base",
|
||||
);
|
||||
assert.ok(base);
|
||||
assert.equal(base.exe, `${miniconda}\\python.exe`);
|
||||
assert.equal(base.version, "3.12.3");
|
||||
assert.equal(base.ok, true);
|
||||
assert.equal(base.origin.root, miniconda);
|
||||
assert.ok(base.source.includes("PATH"));
|
||||
assert.ok(base.source.includes("conda env list"));
|
||||
assert.ok(base.source.includes("registry"));
|
||||
|
||||
const named = detection.envs.find(
|
||||
(env) => env.exe === `${remeServe}\\python.exe`,
|
||||
);
|
||||
assert.ok(named);
|
||||
assert.equal(named.origin.kind, "conda");
|
||||
assert.equal(named.origin.envName, "reme-serve");
|
||||
|
||||
const legacy = detection.envs.find(
|
||||
(env) => env.exe === `${old38}\\python.exe`,
|
||||
);
|
||||
assert.ok(legacy);
|
||||
assert.equal(legacy.ok, false);
|
||||
assert.ok(detection.envs.indexOf(legacy) > detection.envs.indexOf(base));
|
||||
|
||||
const pythonOrg = detection.envs.find(
|
||||
(env) => env.exe === "C:\\Python312\\python.exe",
|
||||
);
|
||||
assert.ok(pythonOrg);
|
||||
assert.equal(pythonOrg.origin.kind, "python.org");
|
||||
|
||||
const legacy38 = detection.envs.find(
|
||||
(env) => env.exe === "C:\\Python38\\python.exe",
|
||||
);
|
||||
assert.ok(legacy38);
|
||||
assert.equal(legacy38.ok, false);
|
||||
|
||||
const store = detection.envs.find(
|
||||
(env) => env.exe === `${storePackage}\\python.exe`,
|
||||
);
|
||||
assert.ok(store);
|
||||
assert.equal(store.origin.kind, "windows-store");
|
||||
assert.equal(store.ok, true);
|
||||
|
||||
assert.deepEqual(base.reme, {
|
||||
installed: true,
|
||||
version: "0.4.0.9",
|
||||
versionOk: true,
|
||||
});
|
||||
assert.deepEqual(named.reme, {
|
||||
installed: true,
|
||||
version: "0.4.0.9",
|
||||
versionOk: true,
|
||||
});
|
||||
assert.deepEqual(pythonOrg.reme, {
|
||||
installed: true,
|
||||
version: "0.4.1.11",
|
||||
versionOk: true,
|
||||
});
|
||||
assert.deepEqual(store.reme, {
|
||||
installed: false,
|
||||
version: "",
|
||||
versionOk: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("suggests nothing usable by preferring reme installs, then named conda envs", () => {
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "base", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "reme", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
]),
|
||||
"b",
|
||||
);
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.8.0",
|
||||
ok: false,
|
||||
origin: { kind: "conda", envName: "base", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
{
|
||||
exe: "c",
|
||||
version: "3.11.9",
|
||||
ok: true,
|
||||
origin: { kind: "python.org" },
|
||||
source: "",
|
||||
},
|
||||
]),
|
||||
"c",
|
||||
);
|
||||
// A reme-enabled interpreter outranks a bare named conda env.
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "tools", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: false, version: "" },
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "python.org" },
|
||||
source: "",
|
||||
reme: { installed: true, version: "0.4.0.9" },
|
||||
},
|
||||
]),
|
||||
"b",
|
||||
);
|
||||
// An outdated reme does not make an interpreter ready.
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "tools", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: true, version: "0.3.1.8" },
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "other", root: "r" },
|
||||
source: "",
|
||||
reme: { installed: false, version: "" },
|
||||
},
|
||||
]),
|
||||
"a",
|
||||
);
|
||||
assert.equal(suggestInterpreter([]), "");
|
||||
});
|
||||
|
||||
test("detects Unix interpreters and merges paths that resolve together", async () => {
|
||||
const rules = [
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "python3",
|
||||
result: { code: 0, stdout: "/usr/bin/python3\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "python",
|
||||
result: { code: 0, stdout: "/usr/bin/python\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "conda",
|
||||
result: { code: 0, stdout: "/home/u/miniconda3/bin/conda\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "/home/u/miniconda3/bin/conda" && args[0] === "info",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ root_prefix: "/home/u/miniconda3" }),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "/home/u/miniconda3/bin/conda" && args[0] === "env",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
envs: ["/home/u/miniconda3", "/home/u/miniconda3/envs/reme"],
|
||||
}),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule("/usr/bin/python3", "3.10.12", "/usr"),
|
||||
// /usr/bin/python is a shim whose sys.executable reports python3.
|
||||
{
|
||||
match: (actual, args) =>
|
||||
actual === "/usr/bin/python" &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("import sys"),
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n',
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule(
|
||||
"/home/u/miniconda3/bin/python3",
|
||||
"3.12.1",
|
||||
"/home/u/miniconda3",
|
||||
),
|
||||
verifyRule(
|
||||
"/home/u/miniconda3/envs/reme/bin/python3",
|
||||
"3.11.9",
|
||||
"/home/u/miniconda3/envs/reme",
|
||||
),
|
||||
remeRule("/usr/bin/python3", null),
|
||||
remeRule("/usr/bin/python", null),
|
||||
remeRule("/home/u/miniconda3/bin/python3", "0.4.1.11"),
|
||||
remeRule("/home/u/miniconda3/envs/reme/bin/python3", "0.4.1.11"),
|
||||
];
|
||||
const detection = await detectPythonEnvs({
|
||||
run: fakeRun(rules),
|
||||
platform: "linux",
|
||||
env: {},
|
||||
homedir: "/home/u",
|
||||
exists: () => false,
|
||||
});
|
||||
assert.equal(detection.platform, "linux");
|
||||
assert.equal(detection.condaRoot, "/home/u/miniconda3");
|
||||
assert.equal(detection.suggested, "/home/u/miniconda3/envs/reme/bin/python3");
|
||||
|
||||
const system = detection.envs.filter((env) => env.origin.kind === "system");
|
||||
assert.equal(system.length, 1);
|
||||
assert.equal(system[0].exe, "/usr/bin/python3");
|
||||
assert.equal(system[0].ok, false);
|
||||
|
||||
const named = detection.envs.find((env) => env.origin.envName === "reme");
|
||||
assert.ok(named);
|
||||
assert.equal(named.ok, true);
|
||||
assert.equal(named.version, "3.11.9");
|
||||
});
|
||||
|
||||
test("probes well-known Unix locations beyond the Host PATH", async () => {
|
||||
const homebrew = "/opt/homebrew/bin/python3";
|
||||
const pyenv = "/Users/u/.pyenv/shims/python3";
|
||||
const rules = [
|
||||
{
|
||||
match: (exe) => exe === "which",
|
||||
result: { code: 1, stdout: "", stderr: "" },
|
||||
},
|
||||
verifyRule(homebrew, "3.12.4", "/opt/homebrew"),
|
||||
// pyenv shims report the real interpreter they dispatch to.
|
||||
{
|
||||
match: (actual, args) =>
|
||||
actual === pyenv &&
|
||||
args[0] === VERIFY_ARG0 &&
|
||||
String(args[1]).includes("import sys"),
|
||||
result: {
|
||||
code: 0,
|
||||
stdout:
|
||||
'{"exe":"/Users/u/.pyenv/versions/3.12.4/bin/python3","ver":"3.12.4","base":"/Users/u/.pyenv/versions/3.12.4"}\n',
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
remeRule(homebrew, "0.4.1.11"),
|
||||
remeRule(pyenv, null),
|
||||
remeRule("/Users/u/.pyenv/versions/3.12.4/bin/python3", null),
|
||||
];
|
||||
const detection = await detectPythonEnvs({
|
||||
run: fakeRun(rules),
|
||||
platform: "darwin",
|
||||
env: {},
|
||||
homedir: "/Users/u",
|
||||
exists: (path) => path === homebrew || path === pyenv,
|
||||
});
|
||||
assert.deepEqual(
|
||||
detection.envs.map((env) => env.exe).sort(),
|
||||
[homebrew, "/Users/u/.pyenv/versions/3.12.4/bin/python3"].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
detection.envs.every((env) => env.origin.kind === "system"),
|
||||
"well-known paths are classified as system installations",
|
||||
);
|
||||
});
|
||||
|
||||
test("collects conda failures without rejecting the whole detection", async () => {
|
||||
const { io, miniconda } = windowsFixture();
|
||||
io.run = async (exe, args) => {
|
||||
if (exe === `${miniconda}\\Scripts\\conda.exe`)
|
||||
return { code: null, stdout: "", stderr: "", error: "spawn failed" };
|
||||
const original = windowsFixture();
|
||||
const rule = original.rules.find((entry) => entry.match(exe, args));
|
||||
if (!rule) throw new Error(`unexpected: ${exe} ${args.join(" ")}`);
|
||||
return typeof rule.result === "function" ? rule.result(args) : rule.result;
|
||||
};
|
||||
const detection = await detectPythonEnvs(io);
|
||||
assert.equal(detection.condaRoot, "");
|
||||
assert.ok(detection.errors.some((error) => error.startsWith("conda:")));
|
||||
assert.ok(
|
||||
detection.envs.some((env) => env.exe === `${miniconda}\\python.exe`),
|
||||
);
|
||||
});
|
||||
187
integrations/dsh/tests/env-fields.test.mjs
Normal file
187
integrations/dsh/tests/env-fields.test.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ENV_FIELDS,
|
||||
envFieldMeta,
|
||||
envUpdates,
|
||||
MANAGED_KEYS,
|
||||
SECRET_KEYS,
|
||||
storedState,
|
||||
} from "../dist/environment/env-fields.js";
|
||||
import { DEFAULT_LLM_BACKEND } from "../dist/environment/llm-backend.js";
|
||||
|
||||
/** A file that already holds a value for every key that needs one. */
|
||||
const STORED = { EMBEDDING_API_KEY: true };
|
||||
|
||||
test("the managed keys are the shared list, in one order", () => {
|
||||
assert.deepEqual(MANAGED_KEYS, [
|
||||
"LLM_BACKEND",
|
||||
"LLM_MODEL_NAME",
|
||||
"LLM_API_KEY",
|
||||
"LLM_BASE_URL",
|
||||
"EMBEDDING_BACKEND",
|
||||
"EMBEDDING_MODEL_NAME",
|
||||
"EMBEDDING_API_KEY",
|
||||
"EMBEDDING_BASE_URL",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...MANAGED_KEYS],
|
||||
ENV_FIELDS.map((field) => field.key),
|
||||
);
|
||||
});
|
||||
|
||||
test("only the two credentials are secret", () => {
|
||||
assert.deepEqual([...SECRET_KEYS].sort(), [
|
||||
"EMBEDDING_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
]);
|
||||
for (const key of MANAGED_KEYS)
|
||||
assert.equal(SECRET_KEYS.has(key), key.endsWith("API_KEY"), key);
|
||||
});
|
||||
|
||||
test("the metadata of a key carries its secret flag and placeholder", () => {
|
||||
// What an empty draft means follows the `secret` flag, which the update
|
||||
// rules below apply. A key that names a default is the documented
|
||||
// exception.
|
||||
assert.equal(envFieldMeta("LLM_API_KEY")?.secret, true);
|
||||
assert.equal(envFieldMeta("LLM_BASE_URL")?.secret, false);
|
||||
assert.equal(envFieldMeta("EMBEDDING_API_KEY")?.disabledValue, "EMPTY");
|
||||
assert.equal(envFieldMeta("LLM_BASE_URL")?.disabledValue, undefined);
|
||||
assert.equal(envFieldMeta("NOT_MANAGED"), undefined);
|
||||
});
|
||||
|
||||
test("an untouched key does not travel", () => {
|
||||
const updates = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, STORED);
|
||||
assert.deepEqual(updates, { LLM_MODEL_NAME: "qwen3.7-plus" });
|
||||
// The stored value of every other key survives the save.
|
||||
assert.equal("LLM_API_KEY" in updates, false);
|
||||
assert.equal("LLM_BASE_URL" in updates, false);
|
||||
assert.equal("EMBEDDING_API_KEY" in updates, false);
|
||||
});
|
||||
|
||||
test("a blank secret is dropped, so the stored credential survives", () => {
|
||||
// The key was typed and cleared again: the draft holds an empty value, but
|
||||
// an empty secret means "keep what is stored".
|
||||
const updates = envUpdates(
|
||||
{ LLM_API_KEY: "", EMBEDDING_API_KEY: "" },
|
||||
{ LLM_API_KEY: true, EMBEDDING_API_KEY: true },
|
||||
);
|
||||
assert.equal("LLM_API_KEY" in updates, false);
|
||||
assert.equal("EMBEDDING_API_KEY" in updates, false);
|
||||
assert.deepEqual(updates, {});
|
||||
});
|
||||
|
||||
test("a blank non-secret is an explicit clear", () => {
|
||||
const updates = envUpdates({ LLM_BASE_URL: "" }, STORED);
|
||||
assert.deepEqual(updates, { LLM_BASE_URL: "" });
|
||||
});
|
||||
|
||||
test("a typed secret travels", () => {
|
||||
const updates = envUpdates({ LLM_API_KEY: "sk-new" }, STORED);
|
||||
assert.deepEqual(updates, { LLM_API_KEY: "sk-new" });
|
||||
});
|
||||
|
||||
test("the embedding key is written as EMPTY while it is unset", () => {
|
||||
// ReMe 0.4.0.x instantiates the embedding component unconditionally, so an
|
||||
// empty key there aborts the service start.
|
||||
const untouched = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, {});
|
||||
assert.equal(untouched.EMBEDDING_API_KEY, "EMPTY");
|
||||
// A cleared embedding key is the same case: it must not stay empty.
|
||||
const cleared = envUpdates(
|
||||
{ EMBEDDING_API_KEY: "" },
|
||||
{ EMBEDDING_API_KEY: false },
|
||||
);
|
||||
assert.equal(cleared.EMBEDDING_API_KEY, "EMPTY");
|
||||
// A stored key is never replaced by the placeholder.
|
||||
const stored = envUpdates({}, { EMBEDDING_API_KEY: true });
|
||||
assert.equal("EMBEDDING_API_KEY" in stored, false);
|
||||
});
|
||||
|
||||
test("the stored state reports which keys hold a value", () => {
|
||||
// The tab reads this from the masked projection, so a set secret counts as
|
||||
// present through its non-empty mask.
|
||||
const state = storedState({
|
||||
LLM_MODEL_NAME: "qwen3.7-plus",
|
||||
LLM_API_KEY: "",
|
||||
});
|
||||
assert.equal(state.LLM_MODEL_NAME, true);
|
||||
assert.equal(state.LLM_API_KEY, false);
|
||||
assert.equal(state.LLM_BASE_URL, false);
|
||||
assert.deepEqual(
|
||||
Object.keys(state).sort(),
|
||||
[...MANAGED_KEYS].sort(),
|
||||
"every managed key is reported",
|
||||
);
|
||||
});
|
||||
|
||||
test("a key the tab does not manage travels unchanged", () => {
|
||||
// The Host must not silently drop a value it was asked to write.
|
||||
const updates = envUpdates(
|
||||
{ LLM_MODEL_NAME: "qwen3.7-plus", SECRET_OTHER: "value" },
|
||||
STORED,
|
||||
);
|
||||
assert.deepEqual(updates, {
|
||||
LLM_MODEL_NAME: "qwen3.7-plus",
|
||||
SECRET_OTHER: "value",
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty backend selection is written as the default, not as an empty value", () => {
|
||||
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is absent:
|
||||
// an explicitly empty value names no backend, and the service refuses to
|
||||
// start with it. So the cleared selection writes the default out.
|
||||
const updates = envUpdates({ LLM_BACKEND: "" }, STORED);
|
||||
assert.deepEqual(updates, { LLM_BACKEND: DEFAULT_LLM_BACKEND });
|
||||
assert.equal(updates.LLM_BACKEND, "openai");
|
||||
assert.notEqual(updates.LLM_BACKEND, "");
|
||||
});
|
||||
|
||||
test("a typed backend still travels", () => {
|
||||
const updates = envUpdates({ LLM_BACKEND: "dashscope" }, STORED);
|
||||
assert.deepEqual(updates, { LLM_BACKEND: "dashscope" });
|
||||
});
|
||||
|
||||
test("an untouched backend key does not travel", () => {
|
||||
// The stored backend survives the save, exactly like every other key.
|
||||
const updates = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, STORED);
|
||||
assert.equal("LLM_BACKEND" in updates, false);
|
||||
});
|
||||
|
||||
test("the default rule does not touch the other keys", () => {
|
||||
// A blank secret still keeps the stored credential, and a key that must not
|
||||
// stay empty still gets its placeholder.
|
||||
const secret = envUpdates(
|
||||
{ LLM_API_KEY: "", LLM_BACKEND: "" },
|
||||
{ LLM_API_KEY: true, EMBEDDING_API_KEY: true },
|
||||
);
|
||||
assert.equal("LLM_API_KEY" in secret, false);
|
||||
assert.equal(secret.LLM_BACKEND, "openai");
|
||||
|
||||
const placeholder = envUpdates(
|
||||
{ EMBEDDING_API_KEY: "" },
|
||||
{ EMBEDDING_API_KEY: false },
|
||||
);
|
||||
assert.equal(placeholder.EMBEDDING_API_KEY, "EMPTY");
|
||||
|
||||
// A non-secret key without a default is still an explicit clear.
|
||||
const cleared = envUpdates({ LLM_BASE_URL: "" }, STORED);
|
||||
assert.deepEqual(cleared, { LLM_BASE_URL: "" });
|
||||
});
|
||||
|
||||
test("both backends name a default", () => {
|
||||
// ReMe 0.4.0.x declares `as_embedding` with
|
||||
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so the
|
||||
// embedding backend follows the same rule as the LLM one.
|
||||
assert.equal(envFieldMeta("LLM_BACKEND")?.defaultValue, "openai");
|
||||
assert.equal(envFieldMeta("EMBEDDING_BACKEND")?.defaultValue, "openai");
|
||||
assert.equal(envFieldMeta("LLM_MODEL_NAME")?.defaultValue, undefined);
|
||||
assert.equal(envFieldMeta("LLM_BASE_URL")?.defaultValue, undefined);
|
||||
});
|
||||
|
||||
test("a cleared embedding backend is written as the default", () => {
|
||||
// The same failure as the LLM backend: an empty value names no backend, and
|
||||
// ReMe refuses to start with one.
|
||||
const updates = envUpdates({ EMBEDDING_BACKEND: "" }, STORED);
|
||||
assert.deepEqual(updates, { EMBEDDING_BACKEND: "openai" });
|
||||
});
|
||||
453
integrations/dsh/tests/env-form.test.mjs
Normal file
453
integrations/dsh/tests/env-form.test.mjs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import React from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
ENV_GROUPS,
|
||||
WorkspaceSection,
|
||||
} from "../dist/environment/client/workspace.js";
|
||||
import { envZh, fill } from "../dist/environment/client/locales.js";
|
||||
import { MANAGED_KEYS } from "../dist/environment/env-fields.js";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const WORKSPACE = "C:\\Users\\test\\.reme";
|
||||
const ENV_PATH = `${WORKSPACE}\\.env`;
|
||||
|
||||
/** The summary the Host answers a read with: every managed key, in order. */
|
||||
const SUMMARY = {
|
||||
workspaceDir: WORKSPACE,
|
||||
exists: true,
|
||||
envPath: ENV_PATH,
|
||||
entries: [
|
||||
{
|
||||
key: "LLM_BACKEND",
|
||||
value: "dashscope",
|
||||
masked: "dashscope",
|
||||
secret: false,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "LLM_MODEL_NAME",
|
||||
value: "qwen3.7-plus",
|
||||
masked: "qwen3.7-plus",
|
||||
secret: false,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "LLM_API_KEY",
|
||||
value: "",
|
||||
masked: "••••cret",
|
||||
secret: true,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "LLM_BASE_URL",
|
||||
value: "https://example.com/v1",
|
||||
masked: "https://example.com/v1",
|
||||
secret: false,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "EMBEDDING_BACKEND",
|
||||
value: "openai",
|
||||
masked: "openai",
|
||||
secret: false,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "EMBEDDING_MODEL_NAME",
|
||||
value: "text-embedding-v4",
|
||||
masked: "text-embedding-v4",
|
||||
secret: false,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "EMBEDDING_API_KEY",
|
||||
value: "",
|
||||
masked: "••••MPTY",
|
||||
secret: true,
|
||||
set: true,
|
||||
},
|
||||
{
|
||||
key: "EMBEDDING_BASE_URL",
|
||||
value: "",
|
||||
masked: "",
|
||||
secret: false,
|
||||
set: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const t = (key) => envZh[key];
|
||||
|
||||
function textOf(value) {
|
||||
if (Array.isArray(value)) return value.map(textOf).join("");
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** The `.env` form, driven by the real section and its own workspace hook. */
|
||||
function Harness({ rpc, workspaceDir = WORKSPACE }) {
|
||||
return React.createElement(WorkspaceSection, {
|
||||
// The tab mounts the section with the workspace as its key, so a switch
|
||||
// disposes the previous workspace's state.
|
||||
key: workspaceDir,
|
||||
t,
|
||||
rpc,
|
||||
workspaceDir,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
writable: true,
|
||||
onWorkspaceDir: async () => true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(options = {}) {
|
||||
const calls = { rpc: [] };
|
||||
let resolveWrite;
|
||||
const pending = options.pendingWrite
|
||||
? new Promise((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
})
|
||||
: undefined;
|
||||
const rpc = {
|
||||
call: async (_path, method, payload) => {
|
||||
calls.rpc.push({ method, input: payload?.args?.input });
|
||||
if (method === "remeEnv/readEnv") return { ok: true, value: SUMMARY };
|
||||
if (method === "remeEnv/writeEnv") {
|
||||
if (pending) return { ok: true, value: await pending };
|
||||
return { ok: true, value: SUMMARY };
|
||||
}
|
||||
return { ok: true, value: null };
|
||||
},
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Harness, { rpc }));
|
||||
});
|
||||
// The card starts collapsed; the form is behind the expand button.
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.expand).props.onClick();
|
||||
});
|
||||
// Wait for the summary to arrive instead of guessing how long it takes.
|
||||
await waitFor(() => fieldValue(renderer, "model") === "qwen3.7-plus");
|
||||
return {
|
||||
renderer,
|
||||
calls,
|
||||
resolveWrite(value = SUMMARY) {
|
||||
resolveWrite(value);
|
||||
},
|
||||
restore: () => {
|
||||
act(() => {
|
||||
renderer.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Let the effects settle until a condition holds, or the wait runs out. */
|
||||
async function waitFor(check, tries = 100) {
|
||||
for (let attempt = 0; attempt < tries; attempt += 1) {
|
||||
if (check()) return;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
assert.ok(check(), "the form did not reach the expected state");
|
||||
}
|
||||
|
||||
function buttonByText(renderer, label) {
|
||||
const found = renderer.root.findAll(
|
||||
(node) => node.type === "button" && textOf(node.props.children) === label,
|
||||
);
|
||||
assert.ok(found.length > 0, `button not found: ${label}`);
|
||||
return found[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `.env` controls, in the order the form renders them: the two backend
|
||||
* selects are not inputs, so the six inputs are the two groups' text and
|
||||
* secret fields.
|
||||
*/
|
||||
const FIELD = {
|
||||
model: 0,
|
||||
apiKey: 1,
|
||||
baseUrl: 2,
|
||||
embeddingModel: 3,
|
||||
embeddingApiKey: 4,
|
||||
embeddingBaseUrl: 5,
|
||||
};
|
||||
|
||||
/** The inputs of the `.env` form, without the workspace directory field. */
|
||||
function envInputs(renderer) {
|
||||
const container = renderer.root.findAll((node) =>
|
||||
String(node.props.className ?? "").includes("reme-env-config"),
|
||||
);
|
||||
assert.equal(container.length, 1, "the .env form is rendered once");
|
||||
return container[0].findAll((node) => node.type === "input");
|
||||
}
|
||||
|
||||
function fieldInput(renderer, name) {
|
||||
return envInputs(renderer)[FIELD[name]];
|
||||
}
|
||||
|
||||
/** The value a `.env` field shows right now. */
|
||||
function fieldValue(renderer, name) {
|
||||
return fieldInput(renderer, name)?.props.value ?? "";
|
||||
}
|
||||
|
||||
/** The save's own report, or an empty string while there is none. */
|
||||
function statusText(renderer) {
|
||||
const found = renderer.root.findAll((node) =>
|
||||
String(node.props.className ?? "").includes("reme-settings-success"),
|
||||
);
|
||||
if (!found.length) return "";
|
||||
return textOf(found[0].props.children);
|
||||
}
|
||||
|
||||
/** Every success message on screen, in render order. */
|
||||
function successTexts(renderer) {
|
||||
return renderer.root
|
||||
.findAll((node) =>
|
||||
String(node.props.className ?? "").includes("reme-settings-success"),
|
||||
)
|
||||
.map((node) => textOf(node.props.children));
|
||||
}
|
||||
|
||||
function writeCall(calls) {
|
||||
const write = calls.rpc
|
||||
.filter((call) => call.method === "remeEnv/writeEnv")
|
||||
.at(-1);
|
||||
assert.ok(write, "the save reached the Host");
|
||||
return write.input;
|
||||
}
|
||||
|
||||
test("the form shows every managed key, in the shared order", async (t) => {
|
||||
const groups = ENV_GROUPS;
|
||||
assert.deepEqual(
|
||||
groups.flatMap((group) => group.fields.map((field) => field.key)),
|
||||
[...MANAGED_KEYS],
|
||||
);
|
||||
// The secret flag is the shared one, not a second copy of it.
|
||||
assert.deepEqual(
|
||||
groups
|
||||
.flatMap((group) => group.fields)
|
||||
.filter((field) => field.kind === "secret")
|
||||
.map((field) => field.key),
|
||||
["LLM_API_KEY", "EMBEDDING_API_KEY"],
|
||||
);
|
||||
|
||||
const { renderer, restore } = await mount();
|
||||
t.after(restore);
|
||||
const inputs = envInputs(renderer);
|
||||
assert.equal(inputs.length, 6);
|
||||
assert.equal(inputs[FIELD.apiKey].props.type, "password");
|
||||
assert.equal(inputs[FIELD.embeddingApiKey].props.type, "password");
|
||||
assert.equal(fieldInput(renderer, "model").props.value, "qwen3.7-plus");
|
||||
});
|
||||
|
||||
test("an edit typed while the save is in flight survives the save", async (t) => {
|
||||
const { renderer, calls, resolveWrite, restore } = await mount({
|
||||
pendingWrite: true,
|
||||
});
|
||||
t.after(restore);
|
||||
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-b" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.saveEnv).props.onClick();
|
||||
});
|
||||
// The save is in flight and the field stays editable.
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-c" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
resolveWrite();
|
||||
});
|
||||
await waitFor(() => statusText(renderer) !== "");
|
||||
|
||||
// The save carried the value that was submitted...
|
||||
assert.equal(writeCall(calls).values.LLM_MODEL_NAME, "model-b");
|
||||
// ...and the newer edit is still on screen instead of being reverted.
|
||||
assert.equal(fieldValue(renderer, "model"), "model-c");
|
||||
});
|
||||
|
||||
test("a blank secret keeps the stored credential, a blank field clears it", async (t) => {
|
||||
const { renderer, calls, restore } = await mount();
|
||||
t.after(restore);
|
||||
|
||||
// A replacement key is typed and cleared again: "leave blank to keep".
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "apiKey").props.onChange({
|
||||
target: { value: "sk-new" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "apiKey").props.onChange({ target: { value: "" } });
|
||||
});
|
||||
// A non-secret field is cleared explicitly.
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "baseUrl").props.onChange({ target: { value: "" } });
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.saveEnv).props.onClick();
|
||||
});
|
||||
await waitFor(() => statusText(renderer) !== "");
|
||||
|
||||
const values = writeCall(calls).values;
|
||||
// The emptied secret does not travel: the stored credential survives.
|
||||
assert.equal("LLM_API_KEY" in values, false);
|
||||
// The emptied field is an explicit clear.
|
||||
assert.equal(values.LLM_BASE_URL, "");
|
||||
// Nothing else travelled either.
|
||||
assert.equal("LLM_MODEL_NAME" in values, false);
|
||||
assert.equal("EMBEDDING_API_KEY" in values, false);
|
||||
});
|
||||
|
||||
test("the saved message admits that newer edits are still pending", async (t) => {
|
||||
const { renderer, resolveWrite, restore } = await mount({
|
||||
pendingWrite: true,
|
||||
});
|
||||
t.after(restore);
|
||||
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-b" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.saveEnv).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-c" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
resolveWrite();
|
||||
});
|
||||
await waitFor(() => statusText(renderer) !== "");
|
||||
|
||||
// The form is not clean, so the message must not read like a clean save.
|
||||
assert.equal(
|
||||
statusText(renderer),
|
||||
fill(envZh.savedEnvPending, { path: ENV_PATH }),
|
||||
);
|
||||
assert.notEqual(
|
||||
statusText(renderer),
|
||||
fill(envZh.savedEnv, { path: ENV_PATH }),
|
||||
);
|
||||
});
|
||||
|
||||
test("a save without newer edits reports a clean form", async (t) => {
|
||||
const { renderer, restore } = await mount();
|
||||
t.after(restore);
|
||||
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-b" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.saveEnv).props.onClick();
|
||||
});
|
||||
await waitFor(() => statusText(renderer) !== "");
|
||||
|
||||
assert.equal(statusText(renderer), fill(envZh.savedEnv, { path: ENV_PATH }));
|
||||
});
|
||||
|
||||
const OTHER_WORKSPACE = "D:\\Other\\.reme";
|
||||
const OTHER_ENV_PATH = `${OTHER_WORKSPACE}\\.env`;
|
||||
|
||||
/** The summary of another workspace, so a switch is visible in the form. */
|
||||
const OTHER_SUMMARY = {
|
||||
...SUMMARY,
|
||||
workspaceDir: OTHER_WORKSPACE,
|
||||
envPath: OTHER_ENV_PATH,
|
||||
entries: SUMMARY.entries.map((entry) =>
|
||||
entry.key === "LLM_MODEL_NAME"
|
||||
? { ...entry, value: "other-model", masked: "other-model" }
|
||||
: entry,
|
||||
),
|
||||
};
|
||||
|
||||
/** A Host whose `.env` reads answer for the workspace they were asked about. */
|
||||
function switchingRpc() {
|
||||
return {
|
||||
call: async (_path, method, payload) => {
|
||||
const input = payload?.args?.input ?? {};
|
||||
if (method === "remeEnv/readEnv")
|
||||
return {
|
||||
ok: true,
|
||||
value:
|
||||
input.workspaceDir === OTHER_WORKSPACE ? OTHER_SUMMARY : SUMMARY,
|
||||
};
|
||||
if (method === "remeEnv/writeEnv") return { ok: true, value: SUMMARY };
|
||||
if (method === "remeEnv/testLlm")
|
||||
return {
|
||||
ok: true,
|
||||
value: { ok: true, status: 200, latencyMs: 12, error: "" },
|
||||
};
|
||||
return { ok: true, value: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a workspace switch drops the completed save and LLM-test results", async (t) => {
|
||||
const rpc = switchingRpc();
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Harness, { rpc }));
|
||||
});
|
||||
t.after(() => act(() => renderer.unmount()));
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.expand).props.onClick();
|
||||
});
|
||||
await waitFor(() => fieldValue(renderer, "model") === "qwen3.7-plus");
|
||||
|
||||
// A completed save and a successful probe, both for the first workspace.
|
||||
await act(async () => {
|
||||
fieldInput(renderer, "model").props.onChange({
|
||||
target: { value: "model-b" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.saveEnv).props.onClick();
|
||||
});
|
||||
await waitFor(() =>
|
||||
successTexts(renderer).includes(fill(envZh.savedEnv, { path: ENV_PATH })),
|
||||
);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.testLlmButton).props.onClick();
|
||||
});
|
||||
await waitFor(() =>
|
||||
successTexts(renderer).includes(fill(envZh.llmOk, { ms: 12 })),
|
||||
);
|
||||
|
||||
// The tab remounts the section with the new workspace as its key.
|
||||
await act(async () => {
|
||||
renderer.update(
|
||||
React.createElement(Harness, { rpc, workspaceDir: OTHER_WORKSPACE }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.expand).props.onClick();
|
||||
});
|
||||
await waitFor(() => fieldValue(renderer, "model") === "other-model");
|
||||
|
||||
// Neither result belonged to this workspace, so neither is shown.
|
||||
assert.deepEqual(successTexts(renderer), []);
|
||||
// The form shows this workspace's file, not the previous workspace's.
|
||||
const shown = (text) =>
|
||||
renderer.root.findAll((node) => textOf(node.props.children).includes(text))
|
||||
.length;
|
||||
assert.ok(shown(OTHER_ENV_PATH) > 0, "the new .env path is on screen");
|
||||
assert.equal(shown(ENV_PATH), 0, "the previous .env path is gone");
|
||||
});
|
||||
180
integrations/dsh/tests/env-gateway.test.mjs
Normal file
180
integrations/dsh/tests/env-gateway.test.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Context } from "@deepseek-ai/cordis";
|
||||
|
||||
import { ReMeEnvGateway } from "../dist/environment/host/gateway.js";
|
||||
|
||||
/** Manager stub: no subprocesses, just enough surface for the gateway. */
|
||||
function stubManager() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
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",
|
||||
}),
|
||||
health: async (endpoint, authToken) => ({
|
||||
endpoint,
|
||||
ok: false,
|
||||
version: "",
|
||||
error: authToken ? "auth token forwarded" : "",
|
||||
checkedAt: "",
|
||||
}),
|
||||
installStatus: async (jobId, pythonPath) => {
|
||||
calls.push({ method: "installStatus", jobId, pythonPath });
|
||||
return null;
|
||||
},
|
||||
serviceForget: async (endpoint, expectPid) => {
|
||||
calls.push({ method: "serviceForget", endpoint, expectPid });
|
||||
return {
|
||||
forgotten: false,
|
||||
reason: endpoint,
|
||||
code: "no-record",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mount the gateway inside the same isolated realm the patch creates. */
|
||||
async function mounted() {
|
||||
const root = new Context();
|
||||
let realm;
|
||||
let manager;
|
||||
await root.plugin((ctx) => {
|
||||
realm = ctx.isolate({ remeMemory: true });
|
||||
});
|
||||
await realm.plugin((ctx) => {
|
||||
ctx.provide("remeMemory", {});
|
||||
manager = stubManager();
|
||||
ctx.provide("remeEnv", manager);
|
||||
});
|
||||
await realm.plugin(ReMeEnvGateway);
|
||||
return { root, realm, manager };
|
||||
}
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
test("health keeps the single-input convention and forwards the token", async () => {
|
||||
const { root } = await mounted();
|
||||
const receiver = root.reflect.get("remeEnvGateway", false);
|
||||
const source = Function.prototype.toString.call(
|
||||
Object.getPrototypeOf(receiver).health,
|
||||
);
|
||||
const open = source.indexOf("(");
|
||||
const close = source.indexOf(")", open + 1);
|
||||
const parameters = source
|
||||
.slice(open + 1, close)
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
assert.deepEqual(parameters, ["input"]);
|
||||
|
||||
const result = await receiver.health({
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
authToken: "secret",
|
||||
});
|
||||
assert.equal(result.endpoint, "http://127.0.0.1:2334");
|
||||
assert.equal(result.error, "auth token forwarded");
|
||||
});
|
||||
|
||||
test("serviceForget keeps the single-input convention and forwards the endpoint", async () => {
|
||||
const { root } = await mounted();
|
||||
const receiver = root.reflect.get("remeEnvGateway", false);
|
||||
const source = Function.prototype.toString.call(
|
||||
Object.getPrototypeOf(receiver).serviceForget,
|
||||
);
|
||||
const open = source.indexOf("(");
|
||||
const close = source.indexOf(")", open + 1);
|
||||
const parameters = source
|
||||
.slice(open + 1, close)
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
assert.deepEqual(parameters, ["input"]);
|
||||
|
||||
const result = await receiver.serviceForget({
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
});
|
||||
assert.equal(result.forgotten, false);
|
||||
assert.equal(result.reason, "http://127.0.0.1:2334");
|
||||
});
|
||||
|
||||
test("the gateway forwards the interpreter lookup and the pinned record", async () => {
|
||||
const { root, manager } = await mounted();
|
||||
const receiver = root.reflect.get("remeEnvGateway", false);
|
||||
|
||||
// The tab restores the running job of one interpreter, not the newest job.
|
||||
await receiver.installStatus({ pythonPath: "C:\\env\\a\\python.exe" });
|
||||
const status = manager.calls.find((call) => call.method === "installStatus");
|
||||
assert.equal(status.jobId, "");
|
||||
assert.equal(status.pythonPath, "C:\\env\\a\\python.exe");
|
||||
|
||||
// The confirmed recovery targets the record the Host named, pinned by pid.
|
||||
await receiver.serviceForget({
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
expectPid: 6000,
|
||||
});
|
||||
const forget = manager.calls.find((call) => call.method === "serviceForget");
|
||||
assert.equal(forget.endpoint, "http://127.0.0.1:2333");
|
||||
assert.equal(forget.expectPid, 6000);
|
||||
});
|
||||
377
integrations/dsh/tests/env-install.test.mjs
Normal file
377
integrations/dsh/tests/env-install.test.mjs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { InstallJobs } from "../dist/environment/host/install.js";
|
||||
|
||||
/**
|
||||
* A fake `InstallSpawner`: no pip is really spawned and every termination the
|
||||
* job requests is recorded.
|
||||
*
|
||||
* The fake child models a stubborn process: it survives the graceful signal
|
||||
* and dies only when the force-kill arrives. It never reports an exit on its
|
||||
* own, because whether the Host ever observes one is exactly what these
|
||||
* tests are about.
|
||||
*/
|
||||
function fakeSpawner() {
|
||||
const children = [];
|
||||
const calls = { starts: 0 };
|
||||
const spawner = {
|
||||
start(options) {
|
||||
calls.starts += 1;
|
||||
// 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,
|
||||
/** Whether the process still exists. */
|
||||
alive: true,
|
||||
/** Whether the managed group still holds a process after the exit. */
|
||||
groupAliveFlag: false,
|
||||
/** Every termination the job asked for, in order. */
|
||||
kills: [],
|
||||
onExit(callback) {
|
||||
child.exit = callback;
|
||||
},
|
||||
kill(force = false) {
|
||||
child.kills.push(force ? "force" : "graceful");
|
||||
if (force) child.alive = false;
|
||||
},
|
||||
/** What the real spawner asks the OS: is the group still populated? */
|
||||
groupAlive() {
|
||||
return child.groupAliveFlag;
|
||||
},
|
||||
/** The Host observed the process exiting. */
|
||||
exit(code) {
|
||||
child.alive = false;
|
||||
child.exit?.(code);
|
||||
},
|
||||
};
|
||||
children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return { spawner, children, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* A controllable stand-in for `setTimeout`.
|
||||
*
|
||||
* The install job uses one timer only - the escalation from the graceful
|
||||
* signal to the force-kill - so driving it by hand keeps the tests
|
||||
* deterministic and free of real waiting.
|
||||
*/
|
||||
function fakeTimers(t) {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const pending = new Map();
|
||||
let sequence = 0;
|
||||
globalThis.setTimeout = (callback, delay) => {
|
||||
sequence += 1;
|
||||
const timer = {
|
||||
id: sequence,
|
||||
delay,
|
||||
/** Whether the job unref()ed the timer. */
|
||||
unrefed: false,
|
||||
unref() {
|
||||
timer.unrefed = true;
|
||||
return timer;
|
||||
},
|
||||
ref() {
|
||||
return timer;
|
||||
},
|
||||
};
|
||||
pending.set(timer.id, { timer, callback });
|
||||
return timer;
|
||||
};
|
||||
globalThis.clearTimeout = (timer) => {
|
||||
if (timer && typeof timer.id === "number") pending.delete(timer.id);
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
});
|
||||
return {
|
||||
/** The timers that are still pending. */
|
||||
pending: () => [...pending.values()].map((entry) => entry.timer),
|
||||
/** Fire every pending timer, oldest first. */
|
||||
fire() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const { callback } of entries) callback();
|
||||
return entries.length;
|
||||
},
|
||||
/**
|
||||
* Fire every pending timer, including the ones they schedule themselves.
|
||||
*
|
||||
* This is what "after every escalation timer has fired" means: a chain of
|
||||
* timers must be drained, not only the first one.
|
||||
*/
|
||||
fireAll() {
|
||||
let fired = 0;
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const entries = [...pending.values()];
|
||||
if (entries.length === 0) break;
|
||||
pending.clear();
|
||||
for (const { callback } of entries) callback();
|
||||
fired += entries.length;
|
||||
}
|
||||
return fired;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeJobs(t, options = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-install-jobs-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { spawner, children, calls } = fakeSpawner();
|
||||
const jobs = new InstallJobs({ spawner, logDir: directory, ...options });
|
||||
return { jobs, children, calls, directory };
|
||||
}
|
||||
|
||||
test("a cancelled job stays running and cancelling until the exit is observed", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t, { killGraceMs: 5_000 });
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(jobs.cancel(started.id), true);
|
||||
// The graceful signal goes out first.
|
||||
assert.deepEqual(children[0].kills, ["graceful"]);
|
||||
|
||||
// Every escalation timer has fired and the child still never exited.
|
||||
timers.fireAll();
|
||||
const status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "running");
|
||||
assert.equal(status?.cancelling, true);
|
||||
// A failed termination must not be reported as a completed cancellation.
|
||||
assert.notEqual(status?.phase, "cancelled");
|
||||
assert.notEqual(status?.phase, "failed");
|
||||
assert.equal(status?.exitCode, undefined);
|
||||
assert.equal(status?.finishedAt, undefined);
|
||||
// The force-kill was sent, so the job is waiting for a real exit.
|
||||
assert.deepEqual(children[0].kills, ["graceful", "force"]);
|
||||
});
|
||||
|
||||
test("an unconfirmed cancellation keeps the interpreter locked", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children, calls } = makeJobs(t, { killGraceMs: 5_000 });
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
timers.fireAll(); // The escalation fired; the child never exited.
|
||||
|
||||
// A second installation for the same interpreter must not spawn a second
|
||||
// child while the first one is still unconfirmed.
|
||||
const again = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(again.id, started.id);
|
||||
assert.equal(calls.starts, 1);
|
||||
assert.equal(children.length, 1);
|
||||
// The lock is visible through the interpreter lookup as well.
|
||||
const locked = jobs.status("", "C:\\env\\python.exe");
|
||||
assert.equal(locked?.id, started.id);
|
||||
assert.equal(locked?.cancelling, true);
|
||||
});
|
||||
|
||||
test("the interpreter lock folds case on Windows paths", (t) => {
|
||||
const { jobs, calls } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
// The Host finds the same interpreter in another spelling.
|
||||
const again = jobs.start({ pythonPath: "c:/env/python.exe" });
|
||||
assert.equal(again.id, started.id);
|
||||
assert.equal(calls.starts, 1);
|
||||
assert.equal(jobs.status("", "C:\\ENV\\PYTHON.EXE")?.id, started.id);
|
||||
// A POSIX path stays case-sensitive.
|
||||
const posix = jobs.start({ pythonPath: "/opt/venv/bin/python" });
|
||||
assert.notEqual(posix.id, started.id);
|
||||
assert.equal(jobs.status("", "/opt/Venv/bin/python"), null);
|
||||
});
|
||||
|
||||
test("cancel escalates to the force-kill after the grace period", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t, { killGraceMs: 1_234 });
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
|
||||
// The force-kill is not sent before the grace period is over.
|
||||
assert.deepEqual(children[0].kills, ["graceful"]);
|
||||
const pending = timers.pending();
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].delay, 1_234);
|
||||
// A pending escalation must never keep the Host process alive.
|
||||
assert.equal(pending[0].unrefed, true);
|
||||
|
||||
timers.fire();
|
||||
assert.deepEqual(children[0].kills, ["graceful", "force"]);
|
||||
});
|
||||
|
||||
test("the default cancellation grace gives pip five seconds to clean up", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
const pending = timers.pending();
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].delay, 5_000);
|
||||
});
|
||||
|
||||
test("dispose force-kills a child that ignores the graceful signal", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t); // The default dispose grace is 0.
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.dispose();
|
||||
|
||||
// The force-kill follows the graceful signal immediately, so a child that
|
||||
// ignores it cannot outlive the Host.
|
||||
assert.deepEqual(children[0].kills, ["graceful", "force"]);
|
||||
assert.equal(children[0].alive, false);
|
||||
// Nothing is left pending: the plugin is unloading.
|
||||
assert.equal(timers.pending().length, 0);
|
||||
});
|
||||
|
||||
test("dispose escalates after the configured grace period", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t, { disposeGraceMs: 250 });
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.dispose();
|
||||
|
||||
assert.deepEqual(children[0].kills, ["graceful"]);
|
||||
assert.equal(children[0].alive, true);
|
||||
const pending = timers.pending();
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].delay, 250);
|
||||
assert.equal(pending[0].unrefed, true);
|
||||
|
||||
timers.fire();
|
||||
assert.deepEqual(children[0].kills, ["graceful", "force"]);
|
||||
assert.equal(children[0].alive, false);
|
||||
});
|
||||
|
||||
test("a cancelled job waits for its process group, not only for its leader", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children, calls } = makeJobs(t, {
|
||||
killGraceMs: 5_000,
|
||||
groupPollMs: 25,
|
||||
groupTimeoutMs: 60_000,
|
||||
});
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
|
||||
// The leader exits on the graceful signal while a build child that ignored
|
||||
// it keeps the managed group alive: the regression the review reproduced.
|
||||
children[0].groupAliveFlag = true;
|
||||
children[0].exit(1);
|
||||
|
||||
let status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "running");
|
||||
assert.equal(status?.cancelling, true);
|
||||
// The group gets the force-kill, and the job waits for it to be empty.
|
||||
assert.deepEqual(children[0].kills, ["graceful", "force"]);
|
||||
assert.equal(status?.finishedAt, undefined);
|
||||
// While the group is alive the interpreter stays locked.
|
||||
assert.equal(
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
|
||||
started.id,
|
||||
);
|
||||
assert.equal(calls.starts, 1);
|
||||
|
||||
// The force-kill cleared the group: the next confirmation settles the job
|
||||
// and releases the interpreter.
|
||||
children[0].groupAliveFlag = false;
|
||||
timers.fireAll();
|
||||
status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "cancelled");
|
||||
assert.equal(status?.exitCode, 1);
|
||||
assert.equal(status?.cancelling, undefined);
|
||||
assert.notEqual(
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
|
||||
started.id,
|
||||
);
|
||||
assert.equal(calls.starts, 2);
|
||||
// Nothing is left pending once the group is confirmed empty.
|
||||
assert.equal(timers.pending().length, 0);
|
||||
});
|
||||
|
||||
test("a group that never empties keeps the job unconfirmed", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children, calls } = makeJobs(t, {
|
||||
killGraceMs: 5_000,
|
||||
groupPollMs: 25,
|
||||
groupTimeoutMs: 60_000,
|
||||
});
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
children[0].groupAliveFlag = true;
|
||||
children[0].exit(1);
|
||||
|
||||
// Every poll keeps finding the group alive, so the job never settles.
|
||||
timers.fireAll();
|
||||
const status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "running");
|
||||
assert.equal(status?.cancelling, true);
|
||||
assert.notEqual(status?.phase, "cancelled");
|
||||
// The interpreter is still locked: a build child may still be running.
|
||||
assert.equal(
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
|
||||
started.id,
|
||||
);
|
||||
assert.equal(calls.starts, 1);
|
||||
});
|
||||
|
||||
test("a cancelled job with an observed exit settles once, as cancelled", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t, { killGraceMs: 5_000 });
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
|
||||
children[0].exit(1);
|
||||
const settled = jobs.status(started.id);
|
||||
assert.equal(settled?.phase, "cancelled");
|
||||
assert.equal(settled?.cancelling, undefined);
|
||||
assert.equal(settled?.exitCode, 1);
|
||||
// The pending escalation is dropped, so it cannot fire after the exit.
|
||||
assert.equal(timers.pending().length, 0);
|
||||
|
||||
// A second, late exit must not settle the job a second time.
|
||||
children[0].exit(1);
|
||||
const again = jobs.status(started.id);
|
||||
assert.equal(again?.phase, "cancelled");
|
||||
assert.equal(again?.finishedAt, settled?.finishedAt);
|
||||
});
|
||||
|
||||
test("dispose does not settle the job; the observed exit does", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.dispose();
|
||||
|
||||
const status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "running");
|
||||
assert.equal(status?.cancelling, true);
|
||||
assert.equal(status?.finishedAt, undefined);
|
||||
// The default dispose grace is zero, so the force-kill already went out and
|
||||
// nothing is left pending.
|
||||
assert.equal(timers.pending().length, 0);
|
||||
// The interpreter is still locked: the child may still be dying.
|
||||
assert.equal(
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
|
||||
started.id,
|
||||
);
|
||||
|
||||
children[0].exit(null);
|
||||
assert.equal(jobs.status(started.id)?.phase, "cancelled");
|
||||
});
|
||||
|
||||
test("the status of an unconfirmed cancellation stays JSON-safe", (t) => {
|
||||
const timers = fakeTimers(t);
|
||||
const { jobs } = makeJobs(t, { killGraceMs: 5_000 });
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.cancel(started.id);
|
||||
timers.fireAll();
|
||||
|
||||
const status = jobs.status(started.id);
|
||||
assert.equal(status?.phase, "running");
|
||||
// Optional keys are omitted, never written as undefined.
|
||||
assert.equal("exitCode" in status, false);
|
||||
assert.equal("finishedAt" in status, false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(status)), status);
|
||||
});
|
||||
865
integrations/dsh/tests/env-manager.test.mjs
Normal file
865
integrations/dsh/tests/env-manager.test.mjs
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
|
||||
import {
|
||||
readServiceState,
|
||||
writeServiceState,
|
||||
} from "../dist/environment/host/service.js";
|
||||
|
||||
/** A service adapter that records launches instead of starting processes. */
|
||||
function fakeServiceIO() {
|
||||
const calls = { killTree: [], spawn: [] };
|
||||
return {
|
||||
calls,
|
||||
io: {
|
||||
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
|
||||
portOpen: async () => false,
|
||||
async spawnService(options) {
|
||||
calls.spawn.push(options);
|
||||
// A launch is only visible once the OS acknowledged it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { pid: 5000 + calls.spawn.length };
|
||||
},
|
||||
killTree() {},
|
||||
probePid() {},
|
||||
processImage: async () => "python.exe",
|
||||
processCommandLine: async () => "",
|
||||
exists: () => true,
|
||||
platform: "win32",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managerIn(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-manager-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { io, calls } = fakeServiceIO();
|
||||
const manager = new ReMeEnvManager({ DSH_HOME: directory }, io);
|
||||
// Both probes are Host process work; the test replaces them with facts.
|
||||
manager.checkReme = async () => ({
|
||||
installed: true,
|
||||
version: "0.4.2.1",
|
||||
versionOk: true,
|
||||
hasCore: true,
|
||||
cliPath: "",
|
||||
error: "",
|
||||
});
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: endpoint || "http://127.0.0.1:2333",
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
return { manager, calls, directory, io };
|
||||
}
|
||||
|
||||
test("serializes concurrent starts into one service launch", async (t) => {
|
||||
const { manager, calls, directory } = managerIn(t);
|
||||
const options = [
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
];
|
||||
const [first, second] = await Promise.all([
|
||||
manager.serviceStart(...options),
|
||||
manager.serviceStart(...options),
|
||||
]);
|
||||
assert.equal(calls.spawn.length, 1, "the second request joins the first");
|
||||
assert.equal(first.started, true);
|
||||
assert.equal(second.started, false);
|
||||
assert.equal(second.alreadyRunning, true);
|
||||
});
|
||||
|
||||
test("auto-start only attempts a launch for a local root-path endpoint", async (t) => {
|
||||
const { manager, calls, directory } = managerIn(t);
|
||||
const workspace = join(directory, "workspace");
|
||||
const statuses = [];
|
||||
manager.serviceStatus = async (endpoint) => {
|
||||
statuses.push(endpoint);
|
||||
return {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint,
|
||||
logPath: join(directory, "service.log"),
|
||||
};
|
||||
};
|
||||
|
||||
// A gateway prefix cannot be launched, so auto-start does not even probe it
|
||||
// for a start: it could only fail after stopping a same-origin service, and
|
||||
// would report a failure on every DSH start.
|
||||
await manager.autoStart({
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
workspaceDir: workspace,
|
||||
});
|
||||
assert.deepEqual(statuses, []);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
|
||||
// A root-path endpoint is auto-started as before.
|
||||
await manager.autoStart({
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
workspaceDir: workspace,
|
||||
});
|
||||
assert.ok(statuses.length > 0);
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
});
|
||||
|
||||
test("a failed spawn can be retried immediately", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
io.spawnService = async (options) => {
|
||||
calls.spawn.push(options);
|
||||
if (calls.spawn.length === 1) throw new Error("spawn ENOENT");
|
||||
return { pid: 7000 };
|
||||
};
|
||||
const options = [
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
];
|
||||
const first = await manager.serviceStart(...options);
|
||||
assert.equal(first.started, false);
|
||||
assert.match(first.error, /ENOENT/);
|
||||
|
||||
// The start marker was cleared with the failure; a retry must really spawn
|
||||
// instead of being reported as "already starting".
|
||||
const second = await manager.serviceStart(...options);
|
||||
assert.equal(second.started, true);
|
||||
assert.equal(calls.spawn.length, 2);
|
||||
});
|
||||
|
||||
test("refuses to spawn while another process owns the port", async (t) => {
|
||||
const { manager, calls, directory } = managerIn(t);
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "conflict",
|
||||
managed: false,
|
||||
endpoint,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, false);
|
||||
assert.match(result.error, /already listening/);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
});
|
||||
|
||||
test("stops a previous launch that never became healthy before replacing it", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint,
|
||||
// A live record for this endpoint means a launch that is still booting.
|
||||
pid: 6000,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
writeServiceState(join(directory, "reme-memory", "service.json"), {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(calls.killTree, [6000]);
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
});
|
||||
|
||||
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",
|
||||
managed: false,
|
||||
endpoint,
|
||||
pid: 6000,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// The process exists, but its command line cannot be read, so ownership is
|
||||
// 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, false);
|
||||
assert.equal(result.code, "unverified-record");
|
||||
assert.equal(result.pid, 6000, "the client needs the pid in the message");
|
||||
// The record is named, so the client can offer to forget exactly this one
|
||||
// instead of the endpoint it happens to be showing.
|
||||
assert.deepEqual(result.record, {
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
pid: 6000,
|
||||
});
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
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("a provably foreign previous record is discarded and replaced", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint,
|
||||
pid: 6000,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// The live pid belongs to something else now, so the record is provably
|
||||
// stale: the start drops it without killing the process and spawns fresh.
|
||||
io.processCommandLine = async () => "python -m http.server 2334";
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(calls.killTree, [], "a foreign process is never signalled");
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
assert.equal(readServiceState(statePath)?.pid, 5001);
|
||||
});
|
||||
|
||||
test("forgetting a record removes it without signalling the process", async (t) => {
|
||||
const { manager, calls, directory } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Another endpoint cannot drop this record.
|
||||
const foreign = await manager.serviceForget("http://127.0.0.1:2333");
|
||||
assert.deepEqual(foreign, {
|
||||
forgotten: false,
|
||||
reason: "The recorded service does not belong to this endpoint",
|
||||
code: "foreign",
|
||||
});
|
||||
assert.equal(readServiceState(statePath)?.pid, 6000);
|
||||
|
||||
const forgotten = await manager.serviceForget("http://127.0.0.1:2334");
|
||||
assert.deepEqual(forgotten, { forgotten: true, pid: 6000 });
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
// Forgetting only touches the record: the process is never signalled.
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
|
||||
const again = await manager.serviceForget("http://127.0.0.1:2334");
|
||||
assert.equal(again.forgotten, false);
|
||||
assert.equal(again.code, "no-record");
|
||||
});
|
||||
|
||||
test("refuses to forget a record that changed since it was inspected", async (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// The user was shown pid 6000, but the record now describes another pid.
|
||||
const changed = await manager.serviceForget("http://127.0.0.1:2334", 7000);
|
||||
assert.equal(changed.forgotten, false);
|
||||
assert.equal(changed.code, "record-changed");
|
||||
assert.equal(readServiceState(statePath)?.pid, 6000, "the record survives");
|
||||
|
||||
// The record the Host names is the one a retry may act on.
|
||||
const current = await manager.serviceForget("http://127.0.0.1:2334", 6000);
|
||||
assert.equal(current.forgotten, true);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("a blocked start names the record, and forgetting targets that record", async (t) => {
|
||||
// The reported case: a live but unverifiable record for port 2333 while the
|
||||
// configured endpoint is 2444. The start is blocked by the 2333 record, so
|
||||
// the recovery has to act on that record, not on the endpoint on screen.
|
||||
const { manager, directory, io } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// The process exists, but its command line cannot be read, so ownership is
|
||||
// unproven and the record must survive.
|
||||
io.processCommandLine = async () => "";
|
||||
|
||||
const started = await manager.serviceStart(
|
||||
"http://127.0.0.1:2444",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(started.started, false);
|
||||
assert.equal(started.code, "unverified-record");
|
||||
assert.deepEqual(started.record, {
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
pid: 6000,
|
||||
});
|
||||
|
||||
// Forgetting the endpoint on screen is refused as foreign: the record
|
||||
// belongs to another port.
|
||||
const foreign = await manager.serviceForget("http://127.0.0.1:2444");
|
||||
assert.equal(foreign.forgotten, false);
|
||||
assert.equal(foreign.code, "foreign");
|
||||
|
||||
// The record's own endpoint, pinned by the pid the Host reported, is what
|
||||
// the confirmed recovery targets.
|
||||
const forgotten = await manager.serviceForget("http://127.0.0.1:2333", 6000);
|
||||
assert.equal(forgotten.forgotten, true);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("refuses to overwrite a live record owned by another endpoint", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = async (endpoint) => ({
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
writeServiceState(join(directory, "reme-memory", "service.json"), {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2333`;
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, false);
|
||||
assert.match(result.error, /still running/);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
});
|
||||
|
||||
test("replaces a leftover launch recorded under a same-origin path prefix", async (t) => {
|
||||
// A version that still launched prefixed endpoints recorded this service
|
||||
// under `…/reme`. The launch configures only host and port, so it is the
|
||||
// same local service; the endpoint on screen cannot stop it, and the start
|
||||
// must replace it instead of refusing with an unreachable recovery.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(calls.killTree, [6000]);
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
assert.equal(readServiceState(statePath)?.pid, 5001);
|
||||
});
|
||||
|
||||
test("adopts a leftover record under a same-origin path prefix", async (t) => {
|
||||
// The leftover record was filed under `…/reme`, but the launch configures
|
||||
// only host and port, so it is the service the endpoint on screen talks to.
|
||||
// It must be re-keyed to that endpoint: otherwise it looks foreign, the tab
|
||||
// offers no Stop, and the record lingers until the process exits.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
// The real status read: the leftover is only adopted by the Host itself.
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
io.fetchHealth = async () => ({ ok: true, version: "0.4.2.1", error: "" });
|
||||
|
||||
const status = await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(status.phase, "running");
|
||||
assert.equal(status.managed, true, "the leftover is ours to manage");
|
||||
assert.equal(status.pid, 6000);
|
||||
assert.equal(
|
||||
readServiceState(statePath)?.endpoint,
|
||||
"http://127.0.0.1:2334",
|
||||
"the record is filed under the endpoint on screen",
|
||||
);
|
||||
|
||||
// The adopted record is stoppable: the endpoint on screen can act on it.
|
||||
// A healthy service is only observed, though: Start reports it as already
|
||||
// running and replaces nothing.
|
||||
const started = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(started.started, false);
|
||||
assert.equal(started.alreadyRunning, true);
|
||||
assert.equal(calls.spawn.length, 0, "a healthy service is not replaced");
|
||||
|
||||
const stopped = await manager.serviceStop("http://127.0.0.1:2334");
|
||||
assert.equal(stopped.stopped, true);
|
||||
assert.deepEqual(calls.killTree, [6000]);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("replaces a leftover launch that holds the port without being healthy", async (t) => {
|
||||
// The leftover record was filed under `…/reme` and its process occupies the
|
||||
// port without answering the health check. The endpoint on screen cannot
|
||||
// stop that record (it names another endpoint) and the conflict card offers
|
||||
// no way out, so the start must replace it instead of refusing.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
// The port answers, but the service behind it is not healthy.
|
||||
io.fetchHealth = async () => ({ ok: false, version: "", error: "boom" });
|
||||
io.portOpen = async () => true;
|
||||
|
||||
const status = await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.managed, true, "the leftover is ours to manage");
|
||||
assert.equal(status.pid, 6000);
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(calls.killTree, [6000], "the leftover is stopped first");
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
assert.equal(readServiceState(statePath)?.pid, 5001);
|
||||
});
|
||||
|
||||
test("adopting a leftover never overwrites a record that changed meanwhile", async (t) => {
|
||||
// The ownership probe reads the process command line, which takes long
|
||||
// enough for another launch to record its own service. The adoption must
|
||||
// not write the record it read before the probe: that would orphan the
|
||||
// fresh service, or re-key a record that belongs to another endpoint.
|
||||
const { manager, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// Another launch records its own service while the ownership probe runs.
|
||||
io.processCommandLine = async () => {
|
||||
writeServiceState(statePath, {
|
||||
pid: 7000,
|
||||
endpoint: "http://127.0.0.1:2335",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:01:00.000Z",
|
||||
});
|
||||
return `reme start workspace_dir=${directory} service.port=2334`;
|
||||
};
|
||||
|
||||
await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(
|
||||
readServiceState(statePath)?.pid,
|
||||
7000,
|
||||
"the newer record survives the adoption",
|
||||
);
|
||||
assert.equal(
|
||||
readServiceState(statePath)?.endpoint,
|
||||
"http://127.0.0.1:2335",
|
||||
"and it is not re-keyed to another endpoint",
|
||||
);
|
||||
});
|
||||
|
||||
test("a stop acts on a leftover record without a prior status read", async (t) => {
|
||||
// The Host must not depend on the client having read the status first: the
|
||||
// stop itself re-keys the leftover, so it stops the right process instead of
|
||||
// refusing the record as foreign.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
|
||||
const stopped = await manager.serviceStop("http://127.0.0.1:2334");
|
||||
assert.equal(stopped.stopped, true);
|
||||
assert.deepEqual(calls.killTree, [6000]);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("replaces a leftover launch whose record cannot be re-keyed", async (t) => {
|
||||
// The re-keying of the leftover is a write to the state file. When that
|
||||
// write fails, the record keeps naming the old endpoint and the conflict
|
||||
// branch has to fall back to the same guard: otherwise the port stays
|
||||
// occupied by our own service and the tab has no way out.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334/reme",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
// The state file is written through a temporary file next to it; a directory
|
||||
// in its place makes that write fail, exactly as a read-only directory would.
|
||||
const blocked = `${statePath}.${process.pid}.tmp`;
|
||||
mkdirSync(blocked, { recursive: true });
|
||||
t.after(() => rmSync(blocked, { recursive: true, force: true }));
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
// The write works again once the leftover is gone.
|
||||
rmSync(blocked, { recursive: true, force: true });
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
io.fetchHealth = async () => ({ ok: false, version: "", error: "boom" });
|
||||
io.portOpen = async () => true;
|
||||
|
||||
// The record cannot be re-keyed, so the leftover stays foreign.
|
||||
const status = await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.managed, false);
|
||||
assert.equal(
|
||||
readServiceState(statePath)?.endpoint,
|
||||
"http://127.0.0.1:2334/reme",
|
||||
"the record keeps its old endpoint",
|
||||
);
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(
|
||||
calls.killTree,
|
||||
[6000],
|
||||
"the leftover is stopped even though it could not be re-keyed",
|
||||
);
|
||||
assert.equal(calls.spawn.length, 1);
|
||||
assert.equal(readServiceState(statePath)?.pid, 5001);
|
||||
});
|
||||
|
||||
test("a conflict held by an unverified record names that record", async (t) => {
|
||||
// The recorded process holds the port, but its identity cannot be read: it
|
||||
// must never be signalled. The refusal still names the record, because the
|
||||
// record is the only way out of a port the tab cannot clear.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
// The process is alive, but its command line cannot be read.
|
||||
io.processCommandLine = async () => "";
|
||||
io.fetchHealth = async () => ({ ok: false, version: "", error: "boom" });
|
||||
io.portOpen = async () => true;
|
||||
|
||||
const status = await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.managed, false);
|
||||
assert.deepEqual(status.record, {
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
pid: 6000,
|
||||
});
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, false);
|
||||
assert.equal(result.code, "unverified-record");
|
||||
assert.deepEqual(result.record, {
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
pid: 6000,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.killTree,
|
||||
[],
|
||||
"an unverified process is never signalled",
|
||||
);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
});
|
||||
|
||||
test("a conflict held by an unrelated process stays a plain conflict", async (t) => {
|
||||
// No record at all: the port belongs to something else, and there is no
|
||||
// record the tab could forget.
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
manager.serviceStatus = ReMeEnvManager.prototype.serviceStatus;
|
||||
io.fetchHealth = async () => ({ ok: false, version: "", error: "boom" });
|
||||
io.portOpen = async () => true;
|
||||
|
||||
const status = await manager.serviceStatus("http://127.0.0.1:2334");
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.record, undefined);
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, false);
|
||||
assert.equal(result.code, "conflict");
|
||||
assert.equal(result.record, undefined, "there is no record to name");
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
});
|
||||
|
||||
test("a prefixed request never stops the healthy service it cannot replace", async (t) => {
|
||||
// A prefixed endpoint can never be launched: the launch sets only host and
|
||||
// port, so the request is refused later anyway. Replacing the same-origin
|
||||
// root-path service first would kill a healthy service and start nothing
|
||||
// (autoStart would do that on every DSH start).
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
const statePath = join(directory, "reme-memory", "service.json");
|
||||
writeServiceState(statePath, {
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
});
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${directory} service.port=2334`;
|
||||
|
||||
const result = await manager.serviceStart(
|
||||
"http://127.0.0.1:2334/reme",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
assert.equal(result.started, false);
|
||||
assert.equal(result.code, "other-endpoint-running");
|
||||
assert.match(result.error, /still running/);
|
||||
assert.deepEqual(calls.killTree, [], "the healthy service is left alone");
|
||||
assert.equal(calls.spawn.length, 0);
|
||||
assert.equal(readServiceState(statePath)?.pid, 6000, "the record survives");
|
||||
});
|
||||
|
||||
test("stop waits for an in-flight start and then stops that launch", async (t) => {
|
||||
const { manager, calls, directory, io } = managerIn(t);
|
||||
let running = true;
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.processCommandLine = async () =>
|
||||
`reme start workspace_dir=${join(
|
||||
directory,
|
||||
"workspace",
|
||||
)} service.port=2334`;
|
||||
|
||||
const start = manager.serviceStart(
|
||||
"http://127.0.0.1:2334",
|
||||
"D:\\env\\python.exe",
|
||||
join(directory, "workspace"),
|
||||
);
|
||||
// Arrives while the start is still being recorded, so it must join the queue
|
||||
// rather than answering "No service started by this plugin".
|
||||
const stop = manager.serviceStop("http://127.0.0.1:2334");
|
||||
const [started, stopped] = await Promise.all([start, stop]);
|
||||
assert.equal(started.started, true);
|
||||
assert.equal(stopped.stopped, true);
|
||||
assert.deepEqual(calls.killTree, [5001]);
|
||||
});
|
||||
|
||||
test("probes health from the Host and rejects a non-http endpoint", async (t) => {
|
||||
const { manager } = managerIn(t);
|
||||
const health = await manager.health("http://127.0.0.1:2334");
|
||||
assert.equal(health.endpoint, "http://127.0.0.1:2334");
|
||||
assert.equal(health.ok, false);
|
||||
assert.equal(typeof health.checkedAt, "string");
|
||||
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);
|
||||
// The path names the file actually read, so the UI cannot label a rotated
|
||||
// backup as the live log.
|
||||
assert.equal(log.logPath, join(directory, "service.log.1"));
|
||||
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");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
"LLM_MODEL_NAME=qwen3.7-plus\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n",
|
||||
);
|
||||
|
||||
// An untouched required key still satisfies the requirement.
|
||||
const summary = manager.writeEnv(directory, {
|
||||
LLM_MODEL_NAME: "qwen3.8-plus",
|
||||
});
|
||||
assert.equal(
|
||||
summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME").value,
|
||||
"qwen3.8-plus",
|
||||
);
|
||||
assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m);
|
||||
|
||||
// An emptied secret never reaches the file: "leave blank to keep" wins over
|
||||
// an explicit clear, so the stored key survives and the file is unchanged.
|
||||
manager.writeEnv(directory, { LLM_API_KEY: "" });
|
||||
assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m);
|
||||
});
|
||||
|
||||
test("writes .env atomically with a backup and owner-only mode", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
"LLM_API_KEY=sk-old\nEMBEDDING_API_KEY=EMPTY\nLLM_MODEL_NAME=old\n",
|
||||
);
|
||||
|
||||
manager.writeEnv(directory, { LLM_MODEL_NAME: "new" });
|
||||
assert.match(readFileSync(envPath, "utf8"), /^LLM_MODEL_NAME=new$/m);
|
||||
const backup = readFileSync(`${envPath}.bak`, "utf8");
|
||||
assert.match(backup, /^LLM_MODEL_NAME=old$/m);
|
||||
assert.match(backup, /^LLM_API_KEY=sk-old$/m);
|
||||
if (process.platform !== "win32")
|
||||
assert.equal(statSync(envPath).mode & 0o777, 0o600);
|
||||
});
|
||||
350
integrations/dsh/tests/env-reme.test.mjs
Normal file
350
integrations/dsh/tests/env-reme.test.mjs
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import {
|
||||
checkReme,
|
||||
compareVersions,
|
||||
parseRemeVersion,
|
||||
remeCliPath,
|
||||
remeVersionAtLeast,
|
||||
} from "../dist/environment/host/reme.js";
|
||||
import {
|
||||
InstallJobs,
|
||||
pipArgs,
|
||||
tailLog,
|
||||
} from "../dist/environment/host/install.js";
|
||||
|
||||
const SNIPPETS = {
|
||||
version: "importlib.metadata",
|
||||
core: "import agentscope",
|
||||
};
|
||||
|
||||
function remeIO(handlers, platform = "win32") {
|
||||
return {
|
||||
run: async (exe, args) => {
|
||||
const handler = handlers[exe];
|
||||
if (!handler) return { code: 1, stdout: "", stderr: "no handler" };
|
||||
return handler(args);
|
||||
},
|
||||
platform,
|
||||
exists: (path) =>
|
||||
path.endsWith("Scripts\\reme.exe") || path.endsWith("bin\\reme"),
|
||||
};
|
||||
}
|
||||
|
||||
test("compares dotted versions across ReMe's four-segment scheme", () => {
|
||||
assert.equal(compareVersions("0.4.0.9", "0.4.0"), 1);
|
||||
assert.equal(compareVersions("0.4.0", "0.4.0"), 0);
|
||||
assert.equal(compareVersions("0.3.9.9", "0.4.0"), -1);
|
||||
assert.equal(compareVersions("1.0", "0.4.0.9"), 1);
|
||||
assert.equal(remeVersionAtLeast("0.4.0.9"), true);
|
||||
assert.equal(remeVersionAtLeast("0.4.0"), true);
|
||||
assert.equal(remeVersionAtLeast("0.4.1.11"), true);
|
||||
assert.equal(remeVersionAtLeast("0.3.2"), false);
|
||||
assert.equal(remeVersionAtLeast(""), false);
|
||||
assert.equal(remeVersionAtLeast("v0.4.0"), false);
|
||||
});
|
||||
|
||||
test("parses a version line out of noisy metadata output", () => {
|
||||
assert.equal(parseRemeVersion("0.4.0.9\n"), "0.4.0.9");
|
||||
assert.equal(parseRemeVersion("warning\n0.4.0.9\ntrailer"), "0.4.0.9");
|
||||
assert.equal(parseRemeVersion("PackageNotFoundError"), "");
|
||||
assert.equal(parseRemeVersion(""), "");
|
||||
});
|
||||
|
||||
test("checks an installed interpreter including [core] and the CLI", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.4.0.9\n", stderr: "" }
|
||||
: { code: 0, stdout: "", stderr: "" },
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, true);
|
||||
assert.equal(check.version, "0.4.0.9");
|
||||
assert.equal(check.versionOk, true);
|
||||
assert.equal(check.hasCore, true);
|
||||
assert.equal(check.cliPath, "C:\\env\\Scripts\\reme.exe");
|
||||
assert.equal(check.error, "");
|
||||
});
|
||||
|
||||
test("finds the CLI for venv interpreters that live inside Scripts", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\venv\\Scripts\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.4.1.1\n", stderr: "" }
|
||||
: { code: 0, stdout: "", stderr: "" },
|
||||
});
|
||||
io.exists = (path) => path.toLowerCase() === "c:\\venv\\scripts\\reme.exe";
|
||||
const check = await checkReme(io, "C:\\venv\\Scripts\\python.exe");
|
||||
assert.equal(check.cliPath, "C:\\venv\\Scripts\\reme.exe");
|
||||
});
|
||||
|
||||
test("flags old versions and missing [core] extras", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": (args) =>
|
||||
String(args[1]).includes(SNIPPETS.version)
|
||||
? { code: 0, stdout: "0.3.2\n", stderr: "" }
|
||||
: { code: 1, stdout: "", stderr: "ModuleNotFoundError" },
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, true);
|
||||
assert.equal(check.versionOk, false);
|
||||
assert.equal(check.hasCore, false);
|
||||
});
|
||||
|
||||
test("reports a missing installation without core or CLI", async () => {
|
||||
const io = remeIO({
|
||||
"C:\\env\\python.exe": () => ({ code: 1, stdout: "", stderr: "not found" }),
|
||||
});
|
||||
const check = await checkReme(io, "C:\\env\\python.exe");
|
||||
assert.equal(check.installed, false);
|
||||
assert.equal(check.versionOk, false);
|
||||
assert.equal(check.hasCore, false);
|
||||
assert.equal(check.cliPath, "");
|
||||
});
|
||||
|
||||
test("rejects relative interpreter paths", async () => {
|
||||
const check = await checkReme(remeIO({}), "python.exe");
|
||||
assert.equal(check.installed, false);
|
||||
assert.match(check.error, /absolute/);
|
||||
});
|
||||
|
||||
test("composes pip arguments for install, upgrade and mirrors", () => {
|
||||
assert.deepEqual(pipArgs({ pythonPath: "C:\\env\\python.exe" }), [
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
"reme-ai[core]",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
pipArgs({ pythonPath: "C:\\env\\python.exe", upgrade: true }),
|
||||
[
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
"--no-input",
|
||||
"-U",
|
||||
"reme-ai[core]",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
pipArgs({
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
}).slice(-3),
|
||||
[
|
||||
"--index-url",
|
||||
"https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
"reme-ai[core]",
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
pipArgs({
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
forceReinstall: true,
|
||||
}).includes("--force-reinstall"),
|
||||
);
|
||||
assert.throws(() =>
|
||||
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
|
||||
);
|
||||
// Credentials would be echoed in the job command, the log and the browser.
|
||||
assert.throws(
|
||||
() =>
|
||||
pipArgs({
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
indexUrl: "https://user:token@mirror.example/simple",
|
||||
}),
|
||||
/must not embed credentials/,
|
||||
);
|
||||
});
|
||||
|
||||
test("tails a log with CRLF and progress carriage returns", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-install-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, "install.log");
|
||||
writeFileSync(path, "line1\r\nline2\r\nprogress 40%\rprogress 80%\rline3\n");
|
||||
const tail = tailLog(path);
|
||||
assert.ok(tail.includes("line1"));
|
||||
assert.ok(tail.includes("line3"));
|
||||
assert.ok(tail.includes("progress 80%"));
|
||||
assert.equal(tailLog(join(directory, "missing.log")), "");
|
||||
});
|
||||
|
||||
function fakeSpawner() {
|
||||
const children = [];
|
||||
const spawner = {
|
||||
start(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,
|
||||
onExit(callback) {
|
||||
child.exit = callback;
|
||||
},
|
||||
kill(force = false) {
|
||||
child.killed = true;
|
||||
child.force = force;
|
||||
},
|
||||
};
|
||||
children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return { spawner, children };
|
||||
}
|
||||
|
||||
function makeJobs(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-jobs-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { spawner, children } = fakeSpawner();
|
||||
const jobs = new InstallJobs({ spawner, logDir: directory });
|
||||
return { jobs, children, directory };
|
||||
}
|
||||
|
||||
test("finds the reme CLI beside a Windows interpreter on any host", () => {
|
||||
const seen = [];
|
||||
const cli = remeCliPath(
|
||||
{
|
||||
run: async () => ({ code: 0, stdout: "", stderr: "" }),
|
||||
platform: "win32",
|
||||
exists: (path) => {
|
||||
seen.push(path);
|
||||
return path.endsWith("reme.exe");
|
||||
},
|
||||
},
|
||||
"C:\\env\\python.exe",
|
||||
);
|
||||
assert.equal(cli, "C:\\env\\Scripts\\reme.exe");
|
||||
assert.deepEqual(seen, ["C:\\env\\Scripts\\reme.exe"]);
|
||||
});
|
||||
|
||||
test("finds the reme CLI in a POSIX bin directory", () => {
|
||||
const cli = remeCliPath(
|
||||
{
|
||||
run: async () => ({ code: 0, stdout: "", stderr: "" }),
|
||||
platform: "linux",
|
||||
exists: () => true,
|
||||
},
|
||||
"/home/u/venv/bin/python",
|
||||
);
|
||||
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" });
|
||||
assert.equal(started.phase, "running");
|
||||
assert.ok(started.logPath.startsWith(directory));
|
||||
assert.match(started.command, /reme-ai\[core\]/);
|
||||
assert.equal(jobs.status()?.id, started.id);
|
||||
|
||||
children[0].exit(0);
|
||||
const done = jobs.status(started.id);
|
||||
assert.equal(done?.phase, "succeeded");
|
||||
assert.equal(done?.exitCode, 0);
|
||||
assert.ok(done?.finishedAt);
|
||||
});
|
||||
|
||||
test("tracks a failed pip job and keeps the log tail", (t) => {
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
writeFileSync(started.logPath, "ERROR: Could not find a version\n");
|
||||
children[0].exit(1);
|
||||
const done = jobs.status(started.id);
|
||||
assert.equal(done?.phase, "failed");
|
||||
assert.equal(done?.exitCode, 1);
|
||||
assert.match(done?.logTail ?? "", /Could not find a version/);
|
||||
});
|
||||
|
||||
test("keeps the interpreter locked until a cancelled job exits", (t) => {
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(jobs.cancel(started.id), true);
|
||||
assert.equal(children[0].killed, true);
|
||||
// The process still owns the interpreter, so the job stays running until the
|
||||
// exit is observed and no second installation may start.
|
||||
const cancelling = jobs.status(started.id);
|
||||
assert.equal(cancelling?.phase, "running");
|
||||
assert.equal(cancelling?.cancelling, true);
|
||||
assert.equal(
|
||||
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
|
||||
started.id,
|
||||
);
|
||||
assert.equal(jobs.cancel(started.id), false);
|
||||
children[0].exit(1); // A late exit settles the cancelled job, not a failure.
|
||||
assert.equal(jobs.status(started.id)?.phase, "cancelled");
|
||||
assert.equal(jobs.status(started.id)?.cancelling, undefined);
|
||||
});
|
||||
|
||||
test("dispose stops the installations the manager still owns", (t) => {
|
||||
const { jobs, children } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
jobs.dispose();
|
||||
assert.equal(children[0].killed, true);
|
||||
children[0].exit(null);
|
||||
assert.equal(jobs.status(started.id)?.phase, "cancelled");
|
||||
});
|
||||
|
||||
test("reuses the running job for the same interpreter and validates paths", (t) => {
|
||||
const { jobs } = makeJobs(t);
|
||||
const first = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
const second = jobs.start({ pythonPath: "C:\\env\\python.exe" });
|
||||
assert.equal(first.id, second.id);
|
||||
assert.throws(() => jobs.start({ pythonPath: "relative\\python.exe" }));
|
||||
// Root-relative Windows paths pass win32.isAbsolute but are not absolute in
|
||||
// the sense the install needs (they resolve against the current drive).
|
||||
assert.throws(() => jobs.start({ pythonPath: "\\python.exe" }));
|
||||
assert.equal(jobs.status("unknown-job"), null);
|
||||
});
|
||||
|
||||
test("restores the running job of one interpreter, not the newest job", (t) => {
|
||||
// Two interpreters may install at the same time, so "the newest job" is not
|
||||
// necessarily the one a tab is looking for.
|
||||
const { jobs } = makeJobs(t);
|
||||
const first = jobs.start({ pythonPath: "C:\\env\\a\\python.exe" });
|
||||
const second = jobs.start({ pythonPath: "C:\\env\\b\\python.exe" });
|
||||
assert.notEqual(first.id, second.id);
|
||||
assert.equal(jobs.status("", "C:\\env\\a\\python.exe")?.id, first.id);
|
||||
assert.equal(jobs.status("", "C:\\env\\b\\python.exe")?.id, second.id);
|
||||
// An interpreter without a running job has nothing to restore.
|
||||
assert.equal(jobs.status("", "C:\\env\\c\\python.exe"), null);
|
||||
// Without an interpreter the newest job overall is still reported.
|
||||
assert.equal(jobs.status("")?.id, second.id);
|
||||
assert.equal(jobs.status(first.id)?.id, first.id);
|
||||
// A named job wins over the interpreter lookup: the poller asks for its own
|
||||
// job by id.
|
||||
assert.equal(jobs.status(first.id, "C:\\env\\b\\python.exe")?.id, first.id);
|
||||
});
|
||||
|
||||
test("an interpreter is matched by its own path semantics", (t) => {
|
||||
const { jobs } = makeJobs(t);
|
||||
const started = jobs.start({ pythonPath: "C:\\env\\a\\python.exe" });
|
||||
// Windows paths fold case and separators, so the same interpreter is found.
|
||||
assert.equal(jobs.status("", "c:/env/a/python.exe")?.id, started.id);
|
||||
// A POSIX path stays case-sensitive.
|
||||
const posix = jobs.start({ pythonPath: "/opt/venv/bin/python" });
|
||||
assert.equal(jobs.status("", "/opt/venv/bin/python")?.id, posix.id);
|
||||
assert.equal(jobs.status("", "/opt/Venv/bin/python"), null);
|
||||
});
|
||||
171
integrations/dsh/tests/env-rules.test.mjs
Normal file
171
integrations/dsh/tests/env-rules.test.mjs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
canHostService,
|
||||
hasCoreFeatures,
|
||||
} from "../dist/environment/dependency.js";
|
||||
import {
|
||||
isLocalEndpoint,
|
||||
parseLocalEndpoint,
|
||||
} from "../dist/environment/endpoint-rules.js";
|
||||
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
|
||||
|
||||
/** A manager whose process work is replaced by facts. */
|
||||
function managerIn(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-rules-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const manager = new ReMeEnvManager(
|
||||
{ DSH_HOME: directory },
|
||||
{
|
||||
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
|
||||
portOpen: async () => false,
|
||||
spawnService: async () => ({ pid: 1 }),
|
||||
killTree() {},
|
||||
probePid() {},
|
||||
processImage: async () => "python.exe",
|
||||
processCommandLine: async () => "",
|
||||
exists: () => true,
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
return { manager, directory };
|
||||
}
|
||||
|
||||
const INSTALLED = {
|
||||
installed: true,
|
||||
version: "0.4.2.1",
|
||||
versionOk: true,
|
||||
hasCore: true,
|
||||
cliPath: "",
|
||||
error: "",
|
||||
};
|
||||
|
||||
test("the interpreter readiness has one definition", () => {
|
||||
// The Host refuses a start unless reme-ai is installed and new enough, so
|
||||
// the tab must offer exactly the same starts.
|
||||
assert.equal(canHostService(INSTALLED), true);
|
||||
assert.equal(canHostService(undefined), false);
|
||||
assert.equal(
|
||||
canHostService({ ...INSTALLED, installed: false, versionOk: false }),
|
||||
false,
|
||||
);
|
||||
assert.equal(canHostService({ ...INSTALLED, versionOk: false }), false);
|
||||
|
||||
// The [core] extras are a stronger state: without them the service still
|
||||
// starts, but automatic memory and consolidation do not.
|
||||
assert.equal(hasCoreFeatures(INSTALLED), true);
|
||||
assert.equal(hasCoreFeatures({ ...INSTALLED, hasCore: false }), false);
|
||||
assert.equal(hasCoreFeatures({ ...INSTALLED, versionOk: false }), false);
|
||||
});
|
||||
|
||||
test("only a loopback HTTP endpoint is a manageable local service", () => {
|
||||
assert.equal(isLocalEndpoint("http://127.0.0.1:2333"), true);
|
||||
assert.equal(isLocalEndpoint("http://localhost:2333"), true);
|
||||
// A trailing slash is still the root of the origin.
|
||||
assert.equal(isLocalEndpoint("http://127.0.0.1:2333/"), true);
|
||||
assert.equal(isLocalEndpoint("https://127.0.0.1:2333"), false);
|
||||
assert.equal(isLocalEndpoint("http://10.0.0.5:2333"), false);
|
||||
assert.equal(isLocalEndpoint("http://[::1]:2333"), false);
|
||||
assert.equal(isLocalEndpoint("not-a-url"), false);
|
||||
// A path prefix is a gateway, not a service this plugin can launch: the
|
||||
// start command configures host and port only, so the startup health check
|
||||
// at `/reme/health_check` would 404 there. Such an endpoint is still probed
|
||||
// as a remote one, at its own path.
|
||||
assert.equal(isLocalEndpoint("http://127.0.0.1:2333/reme"), false);
|
||||
assert.equal(isLocalEndpoint("http://localhost:2333/reme"), false);
|
||||
|
||||
// The Host and the tab read the same rule, so the port they report is the
|
||||
// same one.
|
||||
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2334,
|
||||
});
|
||||
assert.deepEqual(parseLocalEndpoint("http://localhost:2333"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2333,
|
||||
});
|
||||
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334/"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2334,
|
||||
});
|
||||
assert.throws(
|
||||
() => parseLocalEndpoint("http://127.0.0.1:2333/reme"),
|
||||
/root-path/,
|
||||
);
|
||||
});
|
||||
|
||||
test("an emptied secret never reaches the .env file", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
[
|
||||
"LLM_MODEL_NAME=qwen3.7-plus",
|
||||
"LLM_API_KEY=sk-keep",
|
||||
"EMBEDDING_API_KEY=EMPTY",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// "Leave blank to keep": the stored credential survives, so the other
|
||||
// changes are saved instead of being blocked by the empty value.
|
||||
const summary = manager.writeEnv(directory, {
|
||||
LLM_MODEL_NAME: "qwen3.8-plus",
|
||||
LLM_API_KEY: "",
|
||||
});
|
||||
const text = readFileSync(envPath, "utf8");
|
||||
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
|
||||
assert.match(text, /^LLM_MODEL_NAME=qwen3.8-plus$/m);
|
||||
assert.equal(
|
||||
summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME").value,
|
||||
"qwen3.8-plus",
|
||||
);
|
||||
});
|
||||
|
||||
test("an emptied non-secret field is still an explicit clear", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
"LLM_API_KEY=sk-keep\nLLM_BASE_URL=https://example.com/v1\n",
|
||||
);
|
||||
|
||||
manager.writeEnv(directory, { LLM_BASE_URL: "" });
|
||||
const text = readFileSync(envPath, "utf8");
|
||||
assert.match(text, /^LLM_BASE_URL=$/m);
|
||||
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
|
||||
});
|
||||
|
||||
test("the embedding key keeps the placeholder that lets ReMe start", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(envPath, "LLM_API_KEY=sk-keep\n");
|
||||
|
||||
// ReMe 0.4.0.x needs a non-empty embedding key even without vector search,
|
||||
// so an unset one is written as the documented placeholder.
|
||||
manager.writeEnv(directory, {});
|
||||
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=EMPTY$/m);
|
||||
|
||||
// A stored key survives; the placeholder is not written over it.
|
||||
writeFileSync(envPath, "LLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=real-key\n");
|
||||
manager.writeEnv(directory, {});
|
||||
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=real-key$/m);
|
||||
});
|
||||
|
||||
test("a required key that was never stored still fails the save", (t) => {
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(envPath, "EMBEDDING_API_KEY=EMPTY\n");
|
||||
|
||||
// "Keep" can only keep what is stored; without a stored LLM key the file
|
||||
// cannot start ReMe, so the save is refused and the file is left alone.
|
||||
assert.throws(
|
||||
() => manager.writeEnv(directory, { LLM_API_KEY: "" }),
|
||||
/LLM_API_KEY is required/,
|
||||
);
|
||||
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=EMPTY$/m);
|
||||
});
|
||||
93
integrations/dsh/tests/env-runner.test.mjs
Normal file
93
integrations/dsh/tests/env-runner.test.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
closeSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createInstallSpawner } from "../dist/environment/host/install.js";
|
||||
import {
|
||||
openAppendLog,
|
||||
pruneInstallLogs,
|
||||
} from "../dist/environment/host/log.js";
|
||||
import { createNodeRunner } from "../dist/environment/host/runner.js";
|
||||
|
||||
test("a timeout settles with an explicit error instead of hanging", async () => {
|
||||
// The child ignores SIGTERM, so the grace path must force it and settle.
|
||||
const run = createNodeRunner({ PATH: process.env.PATH });
|
||||
const started = Date.now();
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
|
||||
{ timeoutMs: 200 },
|
||||
);
|
||||
assert.match(result.error, /timed out after 200 ms/);
|
||||
assert.ok(
|
||||
Date.now() - started < 5_000,
|
||||
"the probe must not wait for a process that never closes",
|
||||
);
|
||||
});
|
||||
|
||||
test("a child that exits on SIGTERM is still reported as a timeout", async () => {
|
||||
const run = createNodeRunner({ PATH: process.env.PATH });
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{ timeoutMs: 200 },
|
||||
);
|
||||
assert.match(result.error ?? "", /timed out after 200 ms/);
|
||||
});
|
||||
|
||||
test("the real install spawner starts and stops a process", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-spawner-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const spawner = createInstallSpawner({ PATH: process.env.PATH });
|
||||
const logPath = join(directory, "install.log");
|
||||
const child = spawner.start({
|
||||
exe: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
logPath,
|
||||
});
|
||||
assert.ok(child.pid !== undefined && child.pid > 1);
|
||||
|
||||
const exited = new Promise((resolve) => child.onExit(resolve));
|
||||
child.kill();
|
||||
const outcome = await Promise.race([
|
||||
exited,
|
||||
new Promise((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
assert.notEqual(outcome, "timeout", "the install process must actually exit");
|
||||
assert.match(readFileSync(logPath, "utf8"), /^$/);
|
||||
});
|
||||
|
||||
test("an oversized log is rotated before the next append", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-log-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, "install.log");
|
||||
writeFileSync(path, "x".repeat(200));
|
||||
|
||||
const fd = openAppendLog(path, 100);
|
||||
closeSync(fd);
|
||||
assert.equal(readFileSync(`${path}.1`, "utf8").length, 200);
|
||||
assert.equal(statSync(path).size, 0);
|
||||
});
|
||||
|
||||
test("per-job install logs are pruned to the newest files", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-prune-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
for (let index = 0; index < 25; index += 1)
|
||||
writeFileSync(join(directory, `install-${index}.log`), "x");
|
||||
|
||||
pruneInstallLogs(directory, 20);
|
||||
const remaining = readdirSync(directory).filter((name) =>
|
||||
name.endsWith(".log"),
|
||||
);
|
||||
assert.equal(remaining.length, 20);
|
||||
});
|
||||
712
integrations/dsh/tests/env-service.test.mjs
Normal file
712
integrations/dsh/tests/env-service.test.mjs
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
buildStartCommand,
|
||||
createServiceIO,
|
||||
forgetService,
|
||||
ownsService,
|
||||
parseLocalEndpoint,
|
||||
readServiceState,
|
||||
serviceOwnership,
|
||||
serviceStatus,
|
||||
startService,
|
||||
stopService,
|
||||
writeServiceState,
|
||||
} from "../dist/environment/host/service.js";
|
||||
|
||||
const ENDPOINT = "http://127.0.0.1:2334";
|
||||
|
||||
function fakeIO(overrides = {}) {
|
||||
const calls = { killTree: [], force: [], spawn: [] };
|
||||
const io = {
|
||||
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
|
||||
portOpen: async () => false,
|
||||
async spawnService(options) {
|
||||
calls.spawn.push(options);
|
||||
return { pid: 4321 };
|
||||
},
|
||||
killTree(pid, force = false) {
|
||||
calls.killTree.push(pid);
|
||||
if (force) calls.force.push(pid);
|
||||
},
|
||||
probePid() {},
|
||||
processImage: async () => "python.exe",
|
||||
processCommandLine: async () => "",
|
||||
exists: () => true,
|
||||
platform: "win32",
|
||||
...overrides,
|
||||
};
|
||||
return { io, calls };
|
||||
}
|
||||
|
||||
/** The command line a launch for `statePath` would actually have. */
|
||||
function commandFor(statePath, port = 2334) {
|
||||
const state = readServiceState(statePath);
|
||||
return `reme start workspace_dir=${state.workspaceDir} service.port=${port}`;
|
||||
}
|
||||
|
||||
function tempState(t, prefix) {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
return { directory, statePath: join(directory, "service.json") };
|
||||
}
|
||||
|
||||
function record(statePath, overrides = {}) {
|
||||
writeServiceState(statePath, {
|
||||
pid: 777,
|
||||
endpoint: ENDPOINT,
|
||||
workspaceDir: join(statePath, ".."),
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
command: "reme start",
|
||||
startedAt: "2026-10-27T00:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("accepts only loopback HTTP endpoints and validates the port", () => {
|
||||
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2334,
|
||||
});
|
||||
assert.deepEqual(parseLocalEndpoint("http://localhost:2333"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2333,
|
||||
});
|
||||
// A trailing slash is the origin root and stays launchable.
|
||||
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334/"), {
|
||||
host: "127.0.0.1",
|
||||
port: 2334,
|
||||
});
|
||||
assert.throws(() => parseLocalEndpoint("http://10.0.0.5:2333"));
|
||||
assert.throws(() => parseLocalEndpoint("not-a-url"));
|
||||
assert.throws(() => parseLocalEndpoint("http://127.0.0.1:0"));
|
||||
// The plugin starts a plain HTTP service, so https on loopback can never be
|
||||
// healthy; it must not be offered as manageable.
|
||||
assert.throws(
|
||||
() => parseLocalEndpoint("https://127.0.0.1:2333"),
|
||||
/http:\/\//,
|
||||
);
|
||||
// The launch binds service.host=127.0.0.1, so an IPv6 loopback endpoint
|
||||
// would start a service that can never answer it.
|
||||
assert.throws(() => parseLocalEndpoint("http://[::1]:2333"), /IPv4/);
|
||||
// The launch configures host and port only; a path prefix (a gateway that
|
||||
// mounts ReMe under, say, `/reme`) cannot be served by it, so the startup
|
||||
// health check at `/reme/health_check` would 404.
|
||||
assert.throws(
|
||||
() => parseLocalEndpoint("http://127.0.0.1:2334/reme"),
|
||||
/root-path/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLocalEndpoint("http://localhost:2333/reme"),
|
||||
/root-path/,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds reme start with the workspace cwd contract", () => {
|
||||
const command = buildStartCommand({
|
||||
cliPath: "D:\\env\\Scripts\\reme.exe",
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
workspaceDir: "C:\\Users\\u\\.reme",
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
disableDreamCron: false,
|
||||
exists: () => true,
|
||||
});
|
||||
assert.equal(command.exe, "D:\\env\\Scripts\\reme.exe");
|
||||
assert.ok(command.args.includes("start"));
|
||||
assert.ok(command.args.includes("workspace_dir=C:\\Users\\u\\.reme"));
|
||||
assert.ok(command.args.includes("service.host=127.0.0.1"));
|
||||
assert.ok(command.args.includes("service.port=2334"));
|
||||
assert.ok(command.args.includes("enable_logo=false"));
|
||||
assert.ok(!command.args.includes("jobs.dream_cron.enabled=false"));
|
||||
|
||||
const newer = buildStartCommand({
|
||||
cliPath: "D:\\env\\Scripts\\reme.exe",
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
workspaceDir: "C:\\Users\\u\\.reme",
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
disableDreamCron: true,
|
||||
exists: () => true,
|
||||
});
|
||||
assert.ok(newer.args.includes("jobs.dream_cron.enabled=false"));
|
||||
});
|
||||
|
||||
test("falls back to the interpreter wrapper when the CLI is missing", () => {
|
||||
const command = buildStartCommand({
|
||||
cliPath: "",
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
workspaceDir: "C:\\Users\\u\\.reme",
|
||||
endpoint: "http://127.0.0.1:2334",
|
||||
disableDreamCron: false,
|
||||
exists: () => false,
|
||||
});
|
||||
assert.equal(command.exe, "D:\\env\\python.exe");
|
||||
assert.equal(command.args[0], "-c");
|
||||
assert.match(String(command.args[1]), /sys\.argv/);
|
||||
assert.match(String(command.args[1]), /from reme\.reme import main/);
|
||||
assert.match(String(command.args[1]), /service\.port=2334/);
|
||||
});
|
||||
|
||||
test("derives running, conflict and stopped phases", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-service-");
|
||||
const base = {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
};
|
||||
|
||||
const runningIo = fakeIO({
|
||||
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
|
||||
});
|
||||
let status = await serviceStatus(runningIo.io, base);
|
||||
assert.equal(status.phase, "running");
|
||||
assert.equal(status.managed, false);
|
||||
assert.equal(status.version, "0.4.0.9");
|
||||
|
||||
record(statePath);
|
||||
runningIo.io.processCommandLine = async () => commandFor(statePath);
|
||||
status = await serviceStatus(runningIo.io, base);
|
||||
assert.equal(status.phase, "running");
|
||||
assert.equal(status.managed, true);
|
||||
assert.equal(status.pid, 777);
|
||||
|
||||
const conflict = await serviceStatus(
|
||||
fakeIO({ portOpen: async () => true }).io,
|
||||
base,
|
||||
);
|
||||
assert.equal(conflict.phase, "conflict");
|
||||
|
||||
const stopped = await serviceStatus(fakeIO().io, base);
|
||||
assert.equal(stopped.phase, "stopped");
|
||||
});
|
||||
|
||||
test("a service on another endpoint is not reported as managed", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-own-");
|
||||
record(statePath, { endpoint: "http://127.0.0.1:2333" });
|
||||
const { io } = fakeIO({
|
||||
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.phase, "running");
|
||||
assert.equal(status.managed, false);
|
||||
assert.equal(status.pid, undefined);
|
||||
});
|
||||
|
||||
test("a loopback alias still identifies the service this plugin started", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-alias-");
|
||||
record(statePath, { endpoint: "http://localhost:2334/" });
|
||||
const { io } = fakeIO({
|
||||
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.managed, true);
|
||||
assert.equal(status.pid, 777);
|
||||
});
|
||||
|
||||
test("a conflict still identifies a provably owned service", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-conflict-");
|
||||
record(statePath);
|
||||
const { io } = fakeIO({
|
||||
portOpen: async () => true,
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.managed, true);
|
||||
assert.equal(status.pid, 777);
|
||||
});
|
||||
|
||||
test("an unreadable identity names the record the status cannot clear", async (t) => {
|
||||
// The process is alive, but its command line cannot be read: ownership stays
|
||||
// unproven, so the process is never signalled. The record is nevertheless
|
||||
// the only way out of a port the tab cannot clear, so it travels with the
|
||||
// status instead of waiting for a refusal the card cannot trigger.
|
||||
const { directory, statePath } = tempState(t, "reme-unverified-");
|
||||
record(statePath);
|
||||
const { io } = fakeIO({
|
||||
portOpen: async () => true,
|
||||
processCommandLine: async () => "",
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.managed, false, "an unproven identity is not ours");
|
||||
assert.equal(status.pid, undefined, "and no pid is claimed either");
|
||||
assert.deepEqual(status.record, { endpoint: ENDPOINT, pid: 777 });
|
||||
});
|
||||
|
||||
test("a record that names no backend stays out of the status", async (t) => {
|
||||
// A record for another endpoint says nothing about this one, so it is not
|
||||
// offered as a recovery here.
|
||||
const { directory, statePath } = tempState(t, "reme-other-record-");
|
||||
record(statePath, { endpoint: "http://127.0.0.1:2333" });
|
||||
const { io } = fakeIO({
|
||||
portOpen: async () => true,
|
||||
processCommandLine: async () => "",
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.phase, "conflict");
|
||||
assert.equal(status.record, undefined);
|
||||
});
|
||||
|
||||
test("a recycled pid that is no longer the service is not managed", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-pid-");
|
||||
record(statePath);
|
||||
const { io } = fakeIO({
|
||||
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
|
||||
processImage: async () => "node.exe",
|
||||
});
|
||||
assert.equal(
|
||||
await ownsService(io, readServiceState(statePath), ENDPOINT),
|
||||
false,
|
||||
);
|
||||
const { io: gone } = fakeIO({
|
||||
probePid() {
|
||||
throw new Error("ESRCH");
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await ownsService(gone, readServiceState(statePath), ENDPOINT),
|
||||
false,
|
||||
);
|
||||
// Ownership guards a destructive action: an image that cannot be read is
|
||||
// not evidence that the pid is ours, and a prefix match would accept e.g.
|
||||
// pycharm64.exe.
|
||||
for (const image of ["", " ", "pycharm64.exe", "python-config"]) {
|
||||
const { io: unreadable } = fakeIO({ processImage: async () => image });
|
||||
assert.equal(
|
||||
await ownsService(unreadable, readServiceState(statePath), ENDPOINT),
|
||||
false,
|
||||
`image ${JSON.stringify(image)} must not be owned`,
|
||||
);
|
||||
}
|
||||
// A Python image whose command line describes another launch is foreign,
|
||||
// not ours; an unreadable command line is unproven, not ownership.
|
||||
const { io: otherLaunch } = fakeIO({
|
||||
processCommandLine: async () => "python -m http.server 2334",
|
||||
});
|
||||
assert.equal(
|
||||
await serviceOwnership(otherLaunch, readServiceState(statePath), ENDPOINT),
|
||||
"foreign",
|
||||
);
|
||||
const { io: unproven } = fakeIO();
|
||||
assert.equal(
|
||||
await serviceOwnership(unproven, readServiceState(statePath), ENDPOINT),
|
||||
"unknown",
|
||||
);
|
||||
assert.equal(
|
||||
await ownsService(unproven, readServiceState(statePath), ENDPOINT),
|
||||
false,
|
||||
);
|
||||
// Matching is boundary-aware: a longer workspace path or port is another
|
||||
// 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({
|
||||
processCommandLine: async () => command,
|
||||
});
|
||||
assert.equal(
|
||||
await serviceOwnership(longer, state, ENDPOINT),
|
||||
"foreign",
|
||||
command,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 prefixed legacy record stays identifiable, so it can be stopped", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-prefix-");
|
||||
// A record from a version that still launched a prefixed loopback endpoint.
|
||||
// The launch rule has changed, but the process it started is still ours and
|
||||
// must not become an unowned orphan just because the endpoint is not
|
||||
// launchable any more.
|
||||
const endpoint = "http://127.0.0.1:2334/reme";
|
||||
record(statePath, { endpoint });
|
||||
const { io, calls } = fakeIO({
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
probePid() {
|
||||
// The record's process only disappears once it was asked to stop.
|
||||
if (calls.killTree.length === 0) return;
|
||||
const error = new Error("ESRCH");
|
||||
error.code = "ESRCH";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const state = readServiceState(statePath);
|
||||
assert.equal(await serviceOwnership(io, state, endpoint), "owned");
|
||||
assert.equal(await ownsService(io, state, endpoint), true);
|
||||
|
||||
// Because ownership is proven, Stop acts on the recorded pid instead of
|
||||
// refusing it as foreign, and the record is dropped once it exited.
|
||||
const stopped = await stopService(io, {
|
||||
statePath,
|
||||
endpoint,
|
||||
timeoutMs: 50,
|
||||
pollMs: 5,
|
||||
});
|
||||
assert.equal(stopped.stopped, true);
|
||||
assert.deepEqual(calls.killTree, [777]);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-dead-");
|
||||
record(statePath);
|
||||
const { io } = fakeIO({
|
||||
probePid() {
|
||||
throw new Error("ESRCH");
|
||||
},
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(status.phase, "stopped");
|
||||
assert.equal(status.pid, undefined);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("a probe denied by permissions is not treated as a dead process", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-eperm-");
|
||||
record(statePath);
|
||||
const denied = new Error("operation not permitted");
|
||||
denied.code = "EPERM";
|
||||
const { io } = fakeIO({
|
||||
probePid() {
|
||||
throw denied;
|
||||
},
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
// The process exists but this user cannot signal it (e.g. an elevated
|
||||
// service): the record must survive so it is not orphaned.
|
||||
assert.equal(status.phase, "stopped");
|
||||
assert.equal(status.pid, 777);
|
||||
assert.equal(readServiceState(statePath)?.pid, 777);
|
||||
});
|
||||
|
||||
test("starts detached with the GBK-safe environment and records state", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-start-");
|
||||
const { io, calls } = fakeIO();
|
||||
const result = await startService(io, {
|
||||
endpoint: ENDPOINT,
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
cliPath: "D:\\env\\Scripts\\reme.exe",
|
||||
disableDreamCron: true,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
env: { PATH: "x" },
|
||||
});
|
||||
assert.equal(result.started, true);
|
||||
assert.equal(result.pid, 4321);
|
||||
const spawned = calls.spawn[0];
|
||||
assert.equal(spawned.cwd, directory);
|
||||
assert.equal(spawned.env.PYTHONUTF8, "1");
|
||||
assert.equal(spawned.env.NO_PROXY, "localhost,127.0.0.1,::1");
|
||||
const persisted = readServiceState(statePath);
|
||||
assert.equal(persisted?.pid, 4321);
|
||||
assert.equal(persisted?.workspaceDir, directory);
|
||||
});
|
||||
|
||||
test("records nothing when the spawn fails", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-spawn-");
|
||||
const { io } = fakeIO({
|
||||
async spawnService() {
|
||||
throw new Error("spawn ENOENT");
|
||||
},
|
||||
});
|
||||
const result = await startService(io, {
|
||||
endpoint: ENDPOINT,
|
||||
workspaceDir: directory,
|
||||
pythonPath: "D:\\env\\python.exe",
|
||||
cliPath: "D:\\missing\\reme.exe",
|
||||
disableDreamCron: false,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
env: {},
|
||||
});
|
||||
assert.equal(result.started, false);
|
||||
assert.match(result.error, /ENOENT/);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("stops the managed pid and clears the state file after it exits", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-stop-");
|
||||
record(statePath, { pid: 999, workspaceDir: directory });
|
||||
const { io, calls } = fakeIO({
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
});
|
||||
let running = true;
|
||||
io.probePid = () => {
|
||||
if (!running) throw new Error("ESRCH");
|
||||
};
|
||||
io.killTree = (pid) => {
|
||||
calls.killTree.push(pid);
|
||||
running = false;
|
||||
};
|
||||
const stopped = await stopService(io, {
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
timeoutMs: 100,
|
||||
pollMs: 5,
|
||||
});
|
||||
assert.deepEqual(stopped, { stopped: true, pid: 999 });
|
||||
assert.deepEqual(calls.killTree, [999]);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), {
|
||||
stopped: false,
|
||||
reason: "No service started by this plugin",
|
||||
code: "no-record",
|
||||
});
|
||||
});
|
||||
|
||||
test("escalates to SIGKILL and keeps the record when the service never exits", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-stubborn-");
|
||||
record(statePath, { pid: 999, workspaceDir: directory });
|
||||
const { io, calls } = fakeIO({
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
});
|
||||
const stopped = await stopService(io, {
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
timeoutMs: 10,
|
||||
forceTimeoutMs: 10,
|
||||
pollMs: 2,
|
||||
});
|
||||
assert.equal(stopped.stopped, false);
|
||||
assert.match(stopped.reason, /did not exit/);
|
||||
// The record survives, so it is named: the tab can offer to forget exactly
|
||||
// this record.
|
||||
assert.deepEqual(stopped.record, { endpoint: ENDPOINT, pid: 999 });
|
||||
assert.deepEqual(calls.killTree, [999, 999]);
|
||||
assert.deepEqual(calls.force, [999]);
|
||||
// The pid survives in the record, so Stop can be tried again later.
|
||||
assert.equal(readServiceState(statePath)?.pid, 999);
|
||||
});
|
||||
|
||||
test("refuses to stop a service recorded for another endpoint", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-refuse-");
|
||||
record(statePath, { endpoint: "http://127.0.0.1:2333" });
|
||||
const { io, calls } = fakeIO();
|
||||
const stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
|
||||
assert.equal(stopped.stopped, false);
|
||||
assert.match(stopped.reason, /does not belong/);
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
assert.ok(readServiceState(statePath), "the foreign record is left in place");
|
||||
});
|
||||
|
||||
test("refuses to stop a process whose identity cannot be verified", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-unknown-");
|
||||
record(statePath);
|
||||
const { io, calls } = fakeIO(); // image readable, command line not
|
||||
const stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
|
||||
assert.equal(stopped.stopped, false);
|
||||
assert.match(stopped.reason, /could not be verified/);
|
||||
// The record is named, so the client can offer the record-only way out.
|
||||
assert.deepEqual(stopped.record, { endpoint: ENDPOINT, pid: 777 });
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
assert.equal(readServiceState(statePath)?.pid, 777);
|
||||
});
|
||||
|
||||
test("forgetting refuses a record that changed since it was inspected", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-changed-");
|
||||
record(statePath, { pid: 999 });
|
||||
const { calls } = fakeIO();
|
||||
const changed = await forgetService({
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
expectPid: 777,
|
||||
});
|
||||
assert.equal(changed.forgotten, false);
|
||||
assert.equal(changed.code, "record-changed");
|
||||
assert.deepEqual(changed.record, { endpoint: ENDPOINT, pid: 999 });
|
||||
// The record is still there: dropping it would orphan the process.
|
||||
assert.equal(readServiceState(statePath)?.pid, 999);
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
|
||||
const forgotten = await forgetService({
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
expectPid: 999,
|
||||
});
|
||||
assert.equal(forgotten.forgotten, true);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Real IO: the pieces the fake adapter above cannot vouch for. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function listening(handler, t) {
|
||||
const server = createServer(handler);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => {
|
||||
// fetch keeps pooled sockets alive; close them so the next test is not
|
||||
// racing a server that is still draining.
|
||||
server.closeAllConnections?.();
|
||||
server.close();
|
||||
});
|
||||
const address = server.address();
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows may keep a just-killed process's cwd/log handle for a moment, so an
|
||||
* immediate recursive remove can fail with EBUSY and fail the whole test.
|
||||
* Only EBUSY/ENOTEMPTY are transient; anything else (and a persistent lock)
|
||||
* fails loudly instead of leaking a temporary directory.
|
||||
*/
|
||||
async function removeDirectory(path) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = error?.code;
|
||||
if ((code !== "EBUSY" && code !== "ENOTEMPTY") || attempt >= 19)
|
||||
throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("real IO accepts only a ReMe health report", async (t) => {
|
||||
// One server for every case: several servers plus the 3 s probe timeout made
|
||||
// this test fail under parallel load.
|
||||
let authorization = "";
|
||||
const base = await listening((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
const path = String(request.url ?? "").replace(/\/health_check$/, "");
|
||||
if (path === "/reme")
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
metadata: {
|
||||
health: { version: "0.4.2.1", healthy: true, components: {} },
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (path === "/partial")
|
||||
// A health object that does not parse is not a ReMe health report: the
|
||||
// shared client requires the version, the health flag and the
|
||||
// components, so a generic `{}` never passes.
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
metadata: { health: { version: "0.4.2.1" } },
|
||||
}),
|
||||
);
|
||||
else if (path === "/failing") {
|
||||
response.statusCode = 503;
|
||||
response.end("{}");
|
||||
} else if (path === "/unhappy")
|
||||
response.end(JSON.stringify({ success: false }));
|
||||
else if (path === "/guarded") {
|
||||
authorization = String(request.headers.authorization ?? "");
|
||||
response.end("{}");
|
||||
} else response.end("{}");
|
||||
}, t);
|
||||
|
||||
const io = createServiceIO();
|
||||
const good = await io.fetchHealth(`${base}/reme`, "");
|
||||
assert.equal(good.ok, true);
|
||||
assert.equal(good.version, "0.4.2.1");
|
||||
assert.equal(good.code, undefined);
|
||||
|
||||
const empty = await io.fetchHealth(`${base}/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");
|
||||
|
||||
// A health report that does not carry the expected fields is not one.
|
||||
const partial = await io.fetchHealth(`${base}/partial`, "");
|
||||
assert.equal(partial.ok, false);
|
||||
assert.equal(partial.code, "not-reme");
|
||||
|
||||
const httpError = await io.fetchHealth(`${base}/failing`, "");
|
||||
assert.equal(httpError.code, "http-status");
|
||||
|
||||
const reported = await io.fetchHealth(`${base}/unhappy`, "");
|
||||
assert.equal(reported.code, "reme-error");
|
||||
|
||||
// The Authorization header is actually sent when a token is configured.
|
||||
await io.fetchHealth(`${base}/guarded`, "secret-token");
|
||||
assert.equal(authorization, "Bearer secret-token");
|
||||
});
|
||||
|
||||
test("real IO spawns, identifies and stops a detached process", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-real-service-"));
|
||||
t.after(() => removeDirectory(directory));
|
||||
const io = createServiceIO();
|
||||
const { pid } = await io.spawnService({
|
||||
exe: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
cwd: directory,
|
||||
logPath: join(directory, "service.log"),
|
||||
env: process.env,
|
||||
});
|
||||
assert.ok(pid !== undefined && pid > 1);
|
||||
assert.doesNotThrow(() => io.probePid(pid));
|
||||
assert.doesNotMatch(await io.processImage(pid), /^$/);
|
||||
assert.match(await io.processCommandLine(pid), /node/i);
|
||||
|
||||
io.killTree(pid);
|
||||
const deadline = Date.now() + 5_000;
|
||||
let running = true;
|
||||
while (running && Date.now() < deadline) {
|
||||
try {
|
||||
io.probePid(pid);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} catch {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
assert.equal(running, false, "the detached process must actually exit");
|
||||
});
|
||||
1918
integrations/dsh/tests/env-tab.test.mjs
Normal file
1918
integrations/dsh/tests/env-tab.test.mjs
Normal file
File diff suppressed because it is too large
Load diff
617
integrations/dsh/tests/env-workspace.test.mjs
Normal file
617
integrations/dsh/tests/env-workspace.test.mjs
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
envSummary,
|
||||
LLM_PROTOCOLS,
|
||||
llmProbeSnippet,
|
||||
llmProtocolFor,
|
||||
maskSecret,
|
||||
mergeEnvFile,
|
||||
parseEnvFile,
|
||||
parseLlmTest,
|
||||
resolveWorkspaceDir,
|
||||
testLlm,
|
||||
validateEnvUpdate,
|
||||
} from "../dist/environment/host/workspace.js";
|
||||
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
|
||||
import { MANAGED_KEYS } from "../dist/environment/env-fields.js";
|
||||
|
||||
/** A manager whose process work is replaced by facts. */
|
||||
function managerIn(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-env-save-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const manager = new ReMeEnvManager(
|
||||
{ DSH_HOME: directory },
|
||||
{
|
||||
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
|
||||
portOpen: async () => false,
|
||||
spawnService: async () => ({ pid: 1 }),
|
||||
killTree() {},
|
||||
probePid() {},
|
||||
processImage: async () => "python.exe",
|
||||
processCommandLine: async () => "",
|
||||
exists: () => true,
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
return { manager, directory };
|
||||
}
|
||||
|
||||
test("resolves the workspace directory with ~/.reme defaults", () => {
|
||||
const home = process.platform === "win32" ? "C:\\Users\\u" : "/home/u";
|
||||
assert.equal(resolveWorkspaceDir("", home), join(home, ".reme"));
|
||||
assert.equal(resolveWorkspaceDir("~/.reme", home), join(home, ".reme"));
|
||||
assert.equal(resolveWorkspaceDir("~", home), home);
|
||||
assert.throws(() => resolveWorkspaceDir("relative/dir", home));
|
||||
});
|
||||
|
||||
test("parses KEY=VALUE lines tolerating comments, blanks and quotes", () => {
|
||||
const parsed = parseEnvFile(
|
||||
[
|
||||
"# service credentials",
|
||||
"",
|
||||
"LLM_BACKEND=openai",
|
||||
'LLM_MODEL_NAME="qwen3.7-plus"',
|
||||
"LLM_API_KEY='sk-test'",
|
||||
"UNKNOWN_KEY=kept",
|
||||
"BROKEN LINE",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.deepEqual(parsed, {
|
||||
LLM_BACKEND: "openai",
|
||||
LLM_MODEL_NAME: "qwen3.7-plus",
|
||||
LLM_API_KEY: "sk-test",
|
||||
UNKNOWN_KEY: "kept",
|
||||
});
|
||||
});
|
||||
|
||||
test("merges updates while preserving comments, order and unknown keys", () => {
|
||||
const existing = [
|
||||
"# managed by hand",
|
||||
"LLM_BACKEND=openai",
|
||||
"LLM_API_KEY=old-key",
|
||||
"",
|
||||
"EMBEDDING_API_KEY=EMPTY",
|
||||
"# keep me",
|
||||
].join("\n");
|
||||
const merged = mergeEnvFile(existing, {
|
||||
LLM_API_KEY: "new-key",
|
||||
LLM_BASE_URL: "https://example.com/v1",
|
||||
});
|
||||
assert.match(merged, /^# managed by hand$/m);
|
||||
assert.match(merged, /^LLM_API_KEY=new-key$/m);
|
||||
assert.doesNotMatch(merged, /old-key/);
|
||||
assert.match(merged, /^EMBEDDING_API_KEY=EMPTY$/m);
|
||||
assert.match(merged, /^# keep me$/m);
|
||||
assert.match(merged, /^LLM_BASE_URL=https:\/\/example\.com\/v1$/m);
|
||||
assert.ok(merged.endsWith("\n"));
|
||||
// Idempotent updates do not duplicate keys.
|
||||
assert.equal(
|
||||
(
|
||||
mergeEnvFile(merged, { LLM_API_KEY: "new-key" }).match(
|
||||
/^LLM_API_KEY=/gm,
|
||||
) ?? []
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("a duplicated key is rewritten everywhere, so parsing agrees", () => {
|
||||
const existing = [
|
||||
"LLM_API_KEY=old-key",
|
||||
"LLM_MODEL_NAME=qwen3.7-plus",
|
||||
"LLM_API_KEY=older-key",
|
||||
].join("\n");
|
||||
const merged = mergeEnvFile(existing, { LLM_API_KEY: "new-key" });
|
||||
assert.equal((merged.match(/^LLM_API_KEY=new-key$/gm) ?? []).length, 2);
|
||||
// Parsing keeps the last occurrence, and that occurrence is the new value.
|
||||
assert.equal(parseEnvFile(merged)["LLM_API_KEY"], "new-key");
|
||||
assert.equal((merged.match(/^LLM_API_KEY=/gm) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("keeps the file's own line ending", () => {
|
||||
const merged = mergeEnvFile(
|
||||
"LLM_BACKEND=openai\r\nEMBEDDING_API_KEY=EMPTY\r\n",
|
||||
{
|
||||
LLM_BACKEND: "dashscope",
|
||||
},
|
||||
);
|
||||
assert.match(merged, /^LLM_BACKEND=dashscope\r$/m);
|
||||
assert.equal(merged.includes("\n\n"), false);
|
||||
assert.ok(merged.endsWith("\r\n"));
|
||||
assert.equal(mergeEnvFile("", { A: "1" }), "A=1\n");
|
||||
});
|
||||
|
||||
test("masks secrets down to the last four characters", () => {
|
||||
assert.equal(maskSecret("sk-abcdefgh"), "••••efgh");
|
||||
assert.equal(maskSecret("abc"), "••••");
|
||||
assert.equal(maskSecret(""), "");
|
||||
});
|
||||
|
||||
test("summaries expose masked secrets and plain non-secret values", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-env-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, ".env");
|
||||
writeFileSync(
|
||||
path,
|
||||
"LLM_MODEL_NAME=qwen3.7-plus\nLLM_API_KEY=sk-super-secret\n",
|
||||
);
|
||||
const summary = envSummary(path, directory);
|
||||
assert.equal(summary.exists, true);
|
||||
assert.equal(summary.envPath, path);
|
||||
const model = summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME");
|
||||
assert.equal(model.value, "qwen3.7-plus");
|
||||
assert.equal(model.secret, false);
|
||||
const key = summary.entries.find((entry) => entry.key === "LLM_API_KEY");
|
||||
assert.equal(key.set, true);
|
||||
assert.equal(key.secret, true);
|
||||
assert.equal(key.value, "");
|
||||
assert.equal(key.masked, "••••cret");
|
||||
assert.doesNotMatch(JSON.stringify(summary), /super-secret/);
|
||||
});
|
||||
|
||||
test("the summary lists the managed keys in the shared order and masks the secrets", (t) => {
|
||||
// The Host projection and the browser form read the same key list, so the
|
||||
// summary has to name the same keys in the same order.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-env-order-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, ".env");
|
||||
writeFileSync(path, "EMBEDDING_API_KEY=EMPTY\nLLM_API_KEY=sk-super-secret\n");
|
||||
const summary = envSummary(path, directory);
|
||||
assert.deepEqual(
|
||||
summary.entries.map((entry) => entry.key),
|
||||
[...MANAGED_KEYS],
|
||||
);
|
||||
const key = summary.entries.find((entry) => entry.key === "LLM_API_KEY");
|
||||
assert.equal(key.secret, true);
|
||||
assert.equal(key.set, true);
|
||||
assert.equal(key.value, "");
|
||||
assert.equal(key.masked, "••••cret");
|
||||
assert.doesNotMatch(JSON.stringify(summary), /super-secret/);
|
||||
// A key the file does not hold is reported as unset, not as missing.
|
||||
const base = summary.entries.find((entry) => entry.key === "LLM_BASE_URL");
|
||||
assert.equal(base.set, false);
|
||||
assert.equal(base.value, "");
|
||||
});
|
||||
|
||||
test("validates the embedding key that aborts ReMe 0.4.0.x startup", () => {
|
||||
assert.throws(() =>
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "" }),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateEnvUpdate({ LLM_API_KEY: "", EMBEDDING_API_KEY: "EMPTY" }),
|
||||
);
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "EMPTY" });
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "real-key" });
|
||||
});
|
||||
|
||||
test("parses LLM test results from noisy python output", () => {
|
||||
assert.deepEqual(
|
||||
parseLlmTest(
|
||||
'warn\n{"ok": true, "status": 200, "latencyMs": 321, "error": ""}\n',
|
||||
),
|
||||
{ ok: true, status: 200, latencyMs: 321, error: "" },
|
||||
);
|
||||
assert.deepEqual(parseLlmTest("no json"), {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM test produced no result",
|
||||
});
|
||||
});
|
||||
|
||||
test("runs the LLM probe inside the workspace and classifies failures", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const io = {
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
const stdout =
|
||||
calls.length === 1
|
||||
? '{"ok": true, "status": 200, "latencyMs": 120, "error": ""}\n'
|
||||
: '{"ok": false, "status": 401, "latencyMs": 40, "error": "HTTP 401"}\n';
|
||||
return { code: 0, stdout, stderr: "" };
|
||||
},
|
||||
};
|
||||
const ok = await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
assert.equal(ok.ok, true);
|
||||
assert.equal(ok.latencyMs, 120);
|
||||
assert.equal(calls[0].options.cwd, directory);
|
||||
assert.equal(calls[0].exe, "C:\\env\\python.exe");
|
||||
|
||||
const denied = await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
assert.equal(denied.ok, false);
|
||||
assert.match(denied.error, /LLM_API_KEY/);
|
||||
|
||||
const broken = await testLlm(
|
||||
{
|
||||
run: async () => ({
|
||||
code: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: "spawn ENOENT",
|
||||
}),
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\missing\\python.exe" },
|
||||
);
|
||||
assert.equal(broken.ok, false);
|
||||
assert.match(broken.error, /ENOENT/);
|
||||
});
|
||||
|
||||
test("the LLM probe speaks the protocol of the selected backend", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-backend-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const io = {
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
const probe = async (backend) => {
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
`LLM_BACKEND=${backend}`,
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
"LLM_BASE_URL=https://provider.invalid",
|
||||
].join("\n"),
|
||||
);
|
||||
calls.length = 0;
|
||||
await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
return String(calls[0]?.args[1] ?? "");
|
||||
};
|
||||
|
||||
// The OpenAI-compatible providers keep the OpenAI-style request.
|
||||
const openai = await probe("openai");
|
||||
assert.match(openai, /\/chat\/completions/);
|
||||
assert.match(openai, /"Authorization"/);
|
||||
assert.match(openai, /Bearer /);
|
||||
|
||||
// A native provider must not be probed with the OpenAI protocol: that would
|
||||
// report a working configuration as broken.
|
||||
const anthropic = await probe("anthropic");
|
||||
assert.match(anthropic, /\/v1\/messages/);
|
||||
assert.match(anthropic, /"x-api-key"/);
|
||||
assert.match(anthropic, /anthropic-version/);
|
||||
assert.doesNotMatch(anthropic, /\/chat\/completions/);
|
||||
|
||||
const gemini = await probe("gemini");
|
||||
assert.match(gemini, /generateContent/);
|
||||
assert.match(gemini, /"x-goog-api-key"/);
|
||||
assert.doesNotMatch(gemini, /\/chat\/completions/);
|
||||
|
||||
const ollama = await probe("ollama");
|
||||
assert.match(ollama, /\/api\/chat/);
|
||||
assert.doesNotMatch(ollama, /"Authorization"/);
|
||||
|
||||
// An unknown backend falls back to ReMe's default rather than failing.
|
||||
const unknown = await probe("something-else");
|
||||
assert.match(unknown, /\/chat\/completions/);
|
||||
});
|
||||
|
||||
test("the probe builds a valid request for every backend", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-request-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const model = "test-model";
|
||||
for (const backend of Object.keys(LLM_PROTOCOLS)) {
|
||||
const protocol = llmProtocolFor(backend);
|
||||
// The body is a JSON template: the placeholder is replaced with the
|
||||
// JSON-quoted model name, so the result must still be valid JSON.
|
||||
const body = protocol.body.replace("{model}", JSON.stringify(model));
|
||||
const parsed = JSON.parse(body);
|
||||
assert.equal(typeof parsed, "object", `${backend} body is JSON`);
|
||||
if (protocol.path.includes("{model}")) {
|
||||
// The model belongs in the path, URL-encoded.
|
||||
assert.ok(!protocol.body.includes("{model}"), `${backend} body`);
|
||||
const url = `https://provider.invalid${protocol.path.replace(
|
||||
"{model}",
|
||||
encodeURIComponent(model),
|
||||
)}`;
|
||||
assert.ok(url.includes(model), `${backend} url carries the model`);
|
||||
} else {
|
||||
assert.ok(
|
||||
protocol.body.includes("{model}"),
|
||||
`${backend} carries the model in the body`,
|
||||
);
|
||||
}
|
||||
// The URL is the base plus the provider's path, exactly as the provider's
|
||||
// own SDK builds it: nothing is trimmed away, so a base that already
|
||||
// carries the version suffix fails in the probe just like it fails in the
|
||||
// service.
|
||||
assert.ok(
|
||||
protocol.path.startsWith("/"),
|
||||
`${backend} path is appended to the base`,
|
||||
);
|
||||
const snippet = llmProbeSnippet(protocol);
|
||||
assert.doesNotMatch(
|
||||
snippet,
|
||||
/base\s*\[|base\.endswith\(|base\.removesuffix\(/,
|
||||
`${backend} does not rewrite the configured base`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a backend that ignores LLM_BASE_URL is probed with its own endpoint", async (t) => {
|
||||
// ReMe's Gemini and Ollama credentials do not read LLM_BASE_URL, so the
|
||||
// probe must not read it either: a stale value left in the .env would send
|
||||
// the probe to a host the service never calls.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-base-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const io = {
|
||||
run: async (exe, args) => {
|
||||
calls.push({ exe, args });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
const probe = async (backend, baseUrl) => {
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
`LLM_BACKEND=${backend}`,
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
...(baseUrl === undefined ? [] : [`LLM_BASE_URL=${baseUrl}`]),
|
||||
].join("\n"),
|
||||
);
|
||||
calls.length = 0;
|
||||
await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
return String(calls[0]?.args[1] ?? "");
|
||||
};
|
||||
|
||||
// Without a base URL the provider's own endpoint is used.
|
||||
const gemini = await probe("gemini");
|
||||
assert.match(gemini, /generativelanguage\.googleapis\.com/);
|
||||
assert.doesNotMatch(gemini, /env\.get\("LLM_BASE_URL"/);
|
||||
|
||||
// A stale base URL in the .env is ignored, exactly as ReMe ignores it.
|
||||
const stale = await probe("ollama", "https://stale.invalid");
|
||||
assert.match(stale, /localhost:11434/);
|
||||
assert.doesNotMatch(stale, /stale\.invalid/);
|
||||
|
||||
// xAI is the same case: its credential carries an api host, not a base URL.
|
||||
const xai = await probe("xai", "https://stale.invalid");
|
||||
assert.match(xai, /api\.x\.ai/);
|
||||
assert.doesNotMatch(xai, /stale\.invalid/);
|
||||
assert.doesNotMatch(xai, /env\.get\("LLM_BASE_URL"/);
|
||||
|
||||
// Ollama needs neither a base URL nor a key.
|
||||
const ollama = await probe("ollama");
|
||||
assert.match(ollama, /localhost:11434/);
|
||||
assert.match(ollama, /if False and not key/);
|
||||
|
||||
// The OpenAI-compatible providers do read LLM_BASE_URL, so it stays required.
|
||||
const openai = await probe("openai");
|
||||
assert.match(openai, /missing\.append\("LLM_BASE_URL"\)/);
|
||||
assert.match(openai, /env\.get\("LLM_BASE_URL"/);
|
||||
});
|
||||
|
||||
test("a cleared backend is saved as the default, never as an empty value", (t) => {
|
||||
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is absent.
|
||||
// An empty value would name no backend, and the service would refuse to
|
||||
// start, so the saved file must hold the default.
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
"LLM_BACKEND=dashscope\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n",
|
||||
);
|
||||
|
||||
manager.writeEnv(directory, { LLM_BACKEND: "" });
|
||||
|
||||
const text = readFileSync(envPath, "utf8");
|
||||
assert.match(text, /^LLM_BACKEND=openai$/m);
|
||||
assert.doesNotMatch(text, /^LLM_BACKEND=$/m);
|
||||
// The other keys are untouched.
|
||||
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
|
||||
assert.match(text, /^EMBEDDING_API_KEY=EMPTY$/m);
|
||||
});
|
||||
|
||||
test("a cleared embedding backend is saved as the default", (t) => {
|
||||
// ReMe 0.4.0.x declares `as_embedding` with
|
||||
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so an empty
|
||||
// value names no backend there either.
|
||||
const { manager, directory } = managerIn(t);
|
||||
const envPath = join(directory, ".env");
|
||||
writeFileSync(
|
||||
envPath,
|
||||
"EMBEDDING_BACKEND=gemini\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n",
|
||||
);
|
||||
|
||||
manager.writeEnv(directory, { EMBEDDING_BACKEND: "" });
|
||||
|
||||
const text = readFileSync(envPath, "utf8");
|
||||
assert.match(text, /^EMBEDDING_BACKEND=openai$/m);
|
||||
assert.doesNotMatch(text, /^EMBEDDING_BACKEND=$/m);
|
||||
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
|
||||
});
|
||||
|
||||
test("the probe refuses a .env whose backend is empty", async (t) => {
|
||||
// An explicitly empty backend names no backend, and ReMe would start with
|
||||
// none at all. The honest answer is an incomplete configuration, and no
|
||||
// request is sent.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-empty-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
"LLM_BACKEND=",
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
"LLM_BASE_URL=https://provider.invalid",
|
||||
].join("\n"),
|
||||
);
|
||||
const calls = [];
|
||||
const result = await testLlm(
|
||||
{
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM config incomplete (LLM_BACKEND)",
|
||||
});
|
||||
assert.equal(calls.length, 0, "the snippet is not run");
|
||||
});
|
||||
|
||||
test("the probe refuses a known backend written in the wrong case", async (t) => {
|
||||
// ReMe's backend registry is case-sensitive, so "OpenAI" is refused as an
|
||||
// unregistered backend. Probing it would test a configuration the service
|
||||
// cannot use.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-case-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
"LLM_BACKEND=OpenAI",
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
"LLM_BASE_URL=https://provider.invalid",
|
||||
].join("\n"),
|
||||
);
|
||||
const calls = [];
|
||||
const result = await testLlm(
|
||||
{
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /LLM_BACKEND/);
|
||||
assert.match(result.error, /openai/, "the message names the spelling to use");
|
||||
assert.equal(calls.length, 0, "the snippet is not run");
|
||||
});
|
||||
|
||||
test("a backend name the plugin does not know is probed tolerantly, whatever it looks like", async (t) => {
|
||||
// The protocol table is a plain object, so it inherits from
|
||||
// `Object.prototype`: `constructor` and `__proto__` are not backends. A name
|
||||
// the plugin has never heard of must be probed like any other unknown name,
|
||||
// and must never be reported as a known backend written in the wrong case.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-prototype-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const names = ["constructor", "__proto__", "CONSTRUCTOR", "__PROTO__"];
|
||||
for (const name of names) {
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
`LLM_BACKEND=${name}`,
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
"LLM_BASE_URL=https://provider.invalid",
|
||||
].join("\n"),
|
||||
);
|
||||
const calls = [];
|
||||
const result = await testLlm(
|
||||
{
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
return {
|
||||
code: 0,
|
||||
stdout:
|
||||
'{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
result.error ?? "",
|
||||
/lower case/,
|
||||
`${name} is not reported as a known backend in the wrong case`,
|
||||
);
|
||||
assert.equal(calls.length, 1, `${name} is probed tolerantly`);
|
||||
const snippet = String(calls[0]?.args[1] ?? "");
|
||||
assert.match(
|
||||
snippet,
|
||||
/\/chat\/completions/,
|
||||
`${name} gets the default protocol`,
|
||||
);
|
||||
assert.match(
|
||||
snippet,
|
||||
/"Authorization"/,
|
||||
`${name} gets the default protocol`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
snippet,
|
||||
/undefined/,
|
||||
`${name} builds no malformed snippet`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a .env without the backend key is probed with the default protocol", async (t) => {
|
||||
// The key is absent, so ReMe applies its default; the probe does the same.
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-absent-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
[
|
||||
"LLM_MODEL_NAME=test-model",
|
||||
"LLM_API_KEY=sk-test",
|
||||
"LLM_BASE_URL=https://provider.invalid",
|
||||
].join("\n"),
|
||||
);
|
||||
const calls = [];
|
||||
await testLlm(
|
||||
{
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
|
||||
);
|
||||
const snippet = String(calls[0]?.args[1] ?? "");
|
||||
assert.match(snippet, /\/chat\/completions/);
|
||||
assert.match(snippet, /"Authorization"/);
|
||||
assert.match(snippet, /Bearer /);
|
||||
});
|
||||
113
integrations/dsh/tests/settings-specs.test.mjs
Normal file
113
integrations/dsh/tests/settings-specs.test.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SETTINGS_SPECS,
|
||||
timeFromCron,
|
||||
validTimezone,
|
||||
} from "../dist/client/settings-specs.js";
|
||||
|
||||
const spec = (field) => {
|
||||
const found = SETTINGS_SPECS.find((item) => item.field === field);
|
||||
assert.ok(found, `missing spec for ${field}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
/** The write a draft stages, or the string "invalid" when it is refused. */
|
||||
const parse = (field, text) => {
|
||||
const write = spec(field).parse(text);
|
||||
return write === undefined ? "invalid" : write;
|
||||
};
|
||||
|
||||
test("the one spec table defines every setting exactly once", () => {
|
||||
// One table is the only definition: the exported field list and the
|
||||
// `SettingField` union are derived from it, so there is no second list to
|
||||
// drift. What remains worth asserting is that the table itself is sound.
|
||||
const fields = SETTINGS_SPECS.map((item) => item.field);
|
||||
assert.equal(
|
||||
new Set(fields).size,
|
||||
fields.length,
|
||||
"a setting is defined twice",
|
||||
);
|
||||
assert.equal(fields.length, 15);
|
||||
});
|
||||
|
||||
test("numbers accept a bounded whole number and clear on empty", () => {
|
||||
assert.deepEqual(parse("searchLimit", "8"), { kind: "set", value: 8 });
|
||||
assert.deepEqual(parse("searchLimit", " 12 "), { kind: "set", value: 12 });
|
||||
assert.deepEqual(parse("searchLimit", ""), { kind: "clear" });
|
||||
// Out of range, not whole, or not a number blocks the save.
|
||||
assert.equal(parse("searchLimit", "0"), "invalid");
|
||||
assert.equal(parse("searchLimit", "51"), "invalid");
|
||||
assert.equal(parse("searchLimit", "8.5"), "invalid");
|
||||
assert.equal(parse("searchLimit", "eight"), "invalid");
|
||||
assert.equal(parse("shutdownTimeoutMs", "99"), "invalid");
|
||||
assert.equal(parse("requestTimeoutMs", "120001"), "invalid");
|
||||
});
|
||||
|
||||
test("booleans round-trip through their control text", () => {
|
||||
assert.deepEqual(parse("autoMemoryEnabled", "true"), {
|
||||
kind: "set",
|
||||
value: true,
|
||||
});
|
||||
assert.deepEqual(parse("rootAgentsOnly", "false"), {
|
||||
kind: "set",
|
||||
value: false,
|
||||
});
|
||||
assert.equal(spec("autoDreamEnabled").format(true), "true");
|
||||
assert.equal(spec("autoDreamEnabled").format(false), "false");
|
||||
});
|
||||
|
||||
test("the endpoint keeps its URL shape and rejects credentials", () => {
|
||||
assert.deepEqual(parse("endpoint", " http://127.0.0.1:2333/ "), {
|
||||
kind: "set",
|
||||
value: "http://127.0.0.1:2333",
|
||||
});
|
||||
for (const bad of [
|
||||
"not-a-url",
|
||||
"http://alice:SECRET@localhost:2333",
|
||||
"http://localhost:2333?token=SECRET",
|
||||
"http://localhost:2333#search",
|
||||
" ",
|
||||
])
|
||||
assert.equal(parse("endpoint", bad), "invalid", bad);
|
||||
});
|
||||
|
||||
test("the daily time control writes a cron expression", () => {
|
||||
assert.deepEqual(parse("dreamCron", "09:30"), {
|
||||
kind: "set",
|
||||
value: "30 9 * * *",
|
||||
});
|
||||
assert.deepEqual(parse("dreamCron", "0:05"), {
|
||||
kind: "set",
|
||||
value: "5 0 * * *",
|
||||
});
|
||||
assert.equal(spec("dreamCron").format("30 9 * * *"), "09:30");
|
||||
assert.equal(spec("dreamCron").format("*/5 * * * *"), "");
|
||||
assert.equal(parse("dreamCron", "24:00"), "invalid");
|
||||
assert.equal(parse("dreamCron", "09:60"), "invalid");
|
||||
assert.equal(parse("dreamCron", ""), "invalid");
|
||||
assert.equal(timeFromCron("5 0 * * *"), "00:05");
|
||||
});
|
||||
|
||||
test("language and timezone only accept what the Host accepts", () => {
|
||||
assert.deepEqual(parse("language", "en"), { kind: "set", value: "en" });
|
||||
assert.deepEqual(parse("language", "zh"), { kind: "set", value: "zh" });
|
||||
assert.equal(parse("language", "fr"), "invalid");
|
||||
assert.equal(spec("language").format(undefined), "zh");
|
||||
assert.deepEqual(parse("timezone", " Asia/Shanghai "), {
|
||||
kind: "set",
|
||||
value: "Asia/Shanghai",
|
||||
});
|
||||
assert.equal(parse("timezone", "Mars/Olympus"), "invalid");
|
||||
assert.equal(parse("timezone", ""), "invalid");
|
||||
assert.equal(validTimezone("Europe/Berlin"), true);
|
||||
});
|
||||
|
||||
test("the guidance text is taken verbatim", () => {
|
||||
assert.deepEqual(parse("dreamHint", "summarise the week"), {
|
||||
kind: "set",
|
||||
value: "summarise the week",
|
||||
});
|
||||
assert.deepEqual(parse("dreamHint", ""), { kind: "set", value: "" });
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue