mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-15 09:11:24 +00:00
Merge main and preserve workspace state in unified agent streaming
This commit is contained in:
commit
831e3969d0
267 changed files with 43090 additions and 1506 deletions
11
.github/workflows/_build-python-packages.yml
vendored
11
.github/workflows/_build-python-packages.yml
vendored
|
|
@ -64,6 +64,7 @@ jobs:
|
|||
python -m zipfile -l "${REME_WHEEL}" | (! grep 'reme_studio/')
|
||||
python -m venv "${RUNNER_TEMP}/reme-package-smoke"
|
||||
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -m pip install "${REME_WHEEL}[as]"
|
||||
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -m pip check
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -c "import reme"
|
||||
|
||||
|
|
@ -93,16 +94,6 @@ jobs:
|
|||
assert runtime.config["jobs"]["auto_bash"]["steps"] == [{"backend": "auto_bash_step"}]
|
||||
PY
|
||||
|
||||
- name: Reject incompatible published ReMe for Home
|
||||
run: |
|
||||
HOME_WHEEL="$(pwd)/$(ls dist/home/reme_home-[0-9]*.whl)"
|
||||
if python -m pip install --dry-run --ignore-installed "${HOME_WHEEL}" "reme-ai[as]==0.4.2.0" > "${RUNNER_TEMP}/home-incompatible.log" 2>&1; then
|
||||
echo 'Home must not install with the published ReMe 0.4.2.0 wheel.'
|
||||
exit 1
|
||||
fi
|
||||
cat "${RUNNER_TEMP}/home-incompatible.log"
|
||||
grep -q 'ResolutionImpossible' "${RUNNER_TEMP}/home-incompatible.log"
|
||||
|
||||
- name: Verify released core dependencies
|
||||
if: inputs.upload_artifacts
|
||||
run: |
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -45,6 +45,9 @@ tmp*/
|
|||
temp*/
|
||||
.trash/
|
||||
|
||||
# Local tool indexes (rebuildable, machine-local)
|
||||
.codegraph/
|
||||
|
||||
# ReMe runtime data
|
||||
.reme/
|
||||
reme_workspace/
|
||||
|
|
@ -59,6 +62,9 @@ docs/_build/
|
|||
site/
|
||||
|
||||
evaluation/
|
||||
# Home's evaluation package is source code, not benchmark output.
|
||||
!plugins/home/src/reme_home/evaluation/
|
||||
!plugins/home/src/reme_home/evaluation/*.py
|
||||
# The pi-Bench suite ships its own trace-history render config, which must
|
||||
# stay in git even though it lives under an evaluation/ directory.
|
||||
!benchmark/pibench/config/bench/evaluation/
|
||||
|
|
|
|||
|
|
@ -142,3 +142,101 @@ Memory Construction average token consumption (default agent, full build over 35
|
|||
| Agent | input tok/case | output tok/case | total tok/case |
|
||||
|---|---|---|---|
|
||||
| default | 31,943,817 | 1,417,061 | 33,360,878 |
|
||||
|
||||
|
||||
## Prepare a Home experiment
|
||||
|
||||
`run_home.py` prepares all BEAM **100K** cases, the workspace, and committed candidate
|
||||
code. It exits after preparation; it does not evaluate cases or start optimization.
|
||||
Run from the repository root after obtaining the dataset described above:
|
||||
|
||||
```bash
|
||||
reme plugins install plugins/home
|
||||
python benchmark/beam/run_home.py
|
||||
# Optional separate workspace (a successful preparation cannot be repeated):
|
||||
python benchmark/beam/run_home.py --workspace-dir /path/to/new-workspace
|
||||
```
|
||||
|
||||
[config_home.yaml](config_home.yaml) controls the preparation. Its paths are
|
||||
repository-relative regardless of the current working directory; `--config`
|
||||
accepts an alternative runner YAML file. The default workspace is
|
||||
`benchmark/beam/workspaces/home-100K`. The evaluated runtime loads `beam`;
|
||||
the host application loads `home`. `source_ref: HEAD` exports committed source
|
||||
only, excluding uncommitted modifications. Preparation failure rolls back its
|
||||
outputs; after success, another call fails instead of resetting the experiment.
|
||||
|
||||
The top-level `sampling_policy` configures Home evaluation defaults: `max_cases`,
|
||||
`max_queries` (null means unlimited), `temperature`, `ewma_alpha` and
|
||||
`uniform_exploration`. The runner validates these and passes them to the host
|
||||
`home.evaluation` Component. It still only prepares; it does not run evaluations.
|
||||
Each evaluation controls `sampling` and `record_history` separately.
|
||||
|
||||
The top-level `validation_cycle_time_budget_minutes` configures the host optimizer's
|
||||
advisory time between completed formal evaluations. It defaults to 150 minutes;
|
||||
set a positive number to change it or null to disable timed reminders.
|
||||
|
||||
The top-level `max_evaluations` sets the optimizer's formal-evaluation budget
|
||||
(positive integer, default 10). A slot is consumed at first sandbox startup,
|
||||
including later failures or cancellations; initial baseline and debug are excluded.
|
||||
|
||||
The top-level `memory_limit_mib` sets the host agent sandbox memory limit for
|
||||
optimization, diagnosis, case studies and evaluation reviews. Set `memory_limit_mib: 0`
|
||||
in the runner YAML to disable the Linux cgroup limit. Null or omission keeps platform
|
||||
defaults (4096 MiB on Linux); macOS does not enforce memory limits.
|
||||
|
||||
After preparation, run local one-shot optimization with the same configuration:
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home.py --optimize
|
||||
# Use the same --config and --workspace-dir overrides as preparation, if supplied.
|
||||
python benchmark/beam/run_home.py --optimize --instruction "Improve retrieval quality"
|
||||
```
|
||||
|
||||
This starts a fresh application, applies both budgets and the sampling policy,
|
||||
runs `home_optimize`, and closes the application. It does not repeat preparation.
|
||||
Python callers can use `await optimize(config_path, workspace_dir=...)` from the
|
||||
same module. The default `run()` and CLI behavior remain preparation only.
|
||||
|
||||
Python callers can use the same lifecycle:
|
||||
|
||||
```python
|
||||
from benchmark.beam.run_home import run
|
||||
|
||||
result = await run(workspace_dir="/path/to/new-workspace")
|
||||
```
|
||||
|
||||
|
||||
## Evaluate a candidate on an independent test set
|
||||
|
||||
The default evaluates every 100K case, matching the previous standalone evaluation. This is an independent
|
||||
workspace, not a disjoint held-out split. Select another `variant`, or replace `all_cases: true` with
|
||||
`case_ids: [...]`, in [config_home_eval.yaml](config_home_eval.yaml) for a different test set.
|
||||
|
||||
The entry point imports a full candidate SHA or unambiguous hexadecimal prefix from an existing Home
|
||||
optimization workspace and copies its prepared fixed runtime. It does not require the original ReMe source
|
||||
repository or change the optimizer's checkout. Paths in the configuration are repository-relative.
|
||||
|
||||
Prepare first, then use the service from two terminals (replace `CANDIDATE_SHA`):
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
|
||||
# Terminal 1:
|
||||
reme start workspace_dir=benchmark/beam/workspaces/home-test-100K 'plugins=["home","beam-judge"]'
|
||||
# Terminal 2:
|
||||
reme home_evaluate workspace_dir=benchmark/beam/workspaces/home-test-100K record_history=false
|
||||
```
|
||||
|
||||
### Local one-shot test evaluation
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA
|
||||
# Optional overrides: --config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
|
||||
```
|
||||
|
||||
Python callers can use `await benchmark.beam.run_home_eval.run(commit="CANDIDATE_SHA")`.
|
||||
The runner rebuilds construction and scores every selected query with sampling and history recording disabled.
|
||||
It uses the existing Docker evaluation runtime and host judge configuration; model credentials are required
|
||||
for evaluation. Results stay in the test workspace's `home/evaluations/`, with original candidate provenance
|
||||
in each manifest's `test_evaluation`. The exported snapshot has its own local `evaluation_commit_sha`.
|
||||
Rerunning the same request reopens unchanged prepared inputs and creates another evaluation result. Use a
|
||||
new test workspace for another candidate or selection. Source and test workspaces must not overlap.
|
||||
|
|
|
|||
|
|
@ -130,3 +130,84 @@ Memory Construction 平均 token 消耗(default agent,35 cases 全量构建
|
|||
| Agent | input tok/case | output tok/case | total tok/case |
|
||||
|---|---|---|---|
|
||||
| default | 31,943,817 | 1,417,061 | 33,360,878 |
|
||||
|
||||
|
||||
## 准备 Home 实验
|
||||
|
||||
安装 Home 插件后,在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
reme plugins install plugins/home
|
||||
python benchmark/beam/run_home.py
|
||||
```
|
||||
|
||||
该入口只创建工作区、准备数据和代码,不运行评测或优化。
|
||||
参数见 [config_home.yaml](config_home.yaml),路径相对仓库根目录;
|
||||
可通过 `--config` 更换配置,通过 `--workspace-dir` 指定新工作区。
|
||||
默认工作区为 `benchmark/beam/workspaces/home-100K`。
|
||||
代码从 `source_ref: HEAD` 对应的 Git 提交导出,不包含未提交修改。
|
||||
准备失败会回滚;成功后禁止再次准备同一工作区。
|
||||
Python 中也可调用 `await benchmark.beam.run_home.run(workspace_dir=...)`。
|
||||
|
||||
顶层 `sampling_policy` 配置采样默认值:`max_cases`、`max_queries`(null 表示不限)、
|
||||
`temperature`、`ewma_alpha` 和 `uniform_exploration`。入口校验后将其传给宿主
|
||||
`home.evaluation` Component,仍只准备、不执行评测;每次评测分别控制 `sampling`
|
||||
和 `record_history`。
|
||||
|
||||
顶层 `validation_cycle_time_budget_minutes` 配置宿主 optimizer 两次已完成正式评测之间的
|
||||
建议用时,默认 150 分钟;可改为其他正数,或设为 null 关闭时间提醒。
|
||||
|
||||
顶层 `max_evaluations` 配置 optimizer 的正式评测次数预算,必须为正整数,默认 10。
|
||||
首次成功启动 sandbox 时扣一次,之后失败或取消也不退还;初始 baseline 和 debug 不计入。
|
||||
|
||||
顶层 `memory_limit_mib` 配置优化、诊断、case study 和评估审查的宿主 agent 沙箱内存限制。
|
||||
在 runner YAML 中设置 `memory_limit_mib: 0` 可关闭 Linux cgroup 内存限制;
|
||||
省略或设为 null 时保留平台默认值(Linux 为 4096 MiB),macOS 不执行内存限制。
|
||||
|
||||
准备完成后,用同一份配置进行本地一次性优化:
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home.py --optimize
|
||||
```
|
||||
|
||||
若准备时指定了 `--config` 或 `--workspace-dir`,优化时传入相同参数。
|
||||
可用 `--instruction "优化检索质量"` 指定目标。该入口将配置中的两个预算和采样策略
|
||||
应用到实际 `home_optimize` 调用,不重复准备;Python 可调用同模块的 `await optimize(...)`。
|
||||
不加 `--optimize` 时仍只准备。
|
||||
|
||||
默认选择 BEAM **100K 全部 case**。
|
||||
|
||||
|
||||
## 独立测试集评测
|
||||
|
||||
默认在独立工作区评测 **100K 全部 case**,与旧独立评测入口一致,不代表与训练集互斥的留出集。
|
||||
可在 [config_home_eval.yaml](config_home_eval.yaml) 更换 `variant`,或把 `all_cases: true`
|
||||
替换为 `case_ids: [...]`,指定测试数据。
|
||||
|
||||
入口从已有 Home 优化工作区导出指定候选提交,接受完整 SHA 或无歧义的十六进制短 SHA,
|
||||
并复制该实验已准备的固定 runtime。无需原始 ReMe 源码仓库,也不会切换或修改优化工作区。
|
||||
配置内路径均相对仓库根目录。
|
||||
|
||||
先准备测试工作区,再分别在两个终端启动服务和调用评测(替换 `CANDIDATE_SHA`):
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
|
||||
# 终端 1:
|
||||
reme start workspace_dir=benchmark/beam/workspaces/home-test-100K 'plugins=["home","beam-judge"]'
|
||||
# 终端 2:
|
||||
reme home_evaluate workspace_dir=benchmark/beam/workspaces/home-test-100K record_history=false
|
||||
```
|
||||
|
||||
### 本地一次性测试评测
|
||||
|
||||
```bash
|
||||
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA
|
||||
# 可选:--config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
|
||||
```
|
||||
|
||||
Python 可调用 `await benchmark.beam.run_home_eval.run(commit="CANDIDATE_SHA")`。
|
||||
入口完整重建记忆并对所选 case 的全部 query 评分,关闭采样与优化历史记录,使用现有 Docker
|
||||
评测环境和宿主 judge 配置;实际评测需要模型凭证。结果保存在测试工作区的 `home/evaluations/`,
|
||||
manifest 的 `test_evaluation` 记录来源候选,导出后的本地提交另记为 `evaluation_commit_sha`。
|
||||
相同请求可复用未改动的准备结果并新增一次评测;更换候选或数据选择时需使用新测试工作区。
|
||||
测试工作区与来源工作区必须互不包含。
|
||||
|
|
|
|||
31
benchmark/beam/config_home.yaml
Normal file
31
benchmark/beam/config_home.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Preparation by default; --optimize applies these settings to the prepared workspace.
|
||||
# Paths are relative to the ReMe repository root, not the cwd.
|
||||
workspace_dir: benchmark/beam/workspaces/home-100K
|
||||
# Formal optimizer evaluations; charged once the first sandbox starts, excluding init/debug.
|
||||
max_evaluations: 10
|
||||
# Host agent sandbox memory limit in MiB; null keeps platform defaults, 0 disables the Linux cgroup limit.
|
||||
memory_limit_mib: 0
|
||||
# Target minutes between completed formal optimizer evaluations; null disables timed reminders.
|
||||
validation_cycle_time_budget_minutes: 150
|
||||
# Policy defaults for Home evaluation; each call still controls sampling=true/false.
|
||||
sampling_policy:
|
||||
max_cases: 5
|
||||
max_queries: null
|
||||
temperature: 1.0
|
||||
ewma_alpha: 0.3
|
||||
uniform_exploration: 0.2
|
||||
preparation:
|
||||
benchmark:
|
||||
name: beam
|
||||
source: benchmark/beam/dataset/BEAM
|
||||
variant: 100K
|
||||
all_cases: true
|
||||
runtime:
|
||||
source_repo: .
|
||||
source_ref: HEAD # Committed source only; resolved and saved as a full SHA.
|
||||
config: benchmark.yaml
|
||||
plugins: [beam]
|
||||
debug:
|
||||
enabled: true
|
||||
sessions_per_case: 3
|
||||
messages_per_session: 6
|
||||
13
benchmark/beam/config_home_eval.yaml
Normal file
13
benchmark/beam/config_home_eval.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Independent full-dataset evaluation; paths are relative to the ReMe repository.
|
||||
source_workspace: benchmark/beam/workspaces/home-100K
|
||||
test_workspace: benchmark/beam/workspaces/home-test-100K
|
||||
code_commit: "" # Required: set a candidate SHA/prefix, or pass --commit.
|
||||
benchmark:
|
||||
name: beam
|
||||
source: benchmark/beam/dataset/BEAM
|
||||
variant: 100K
|
||||
all_cases: true # Full 100K evaluation; not a disjoint held-out split.
|
||||
evaluation:
|
||||
concurrency: 4
|
||||
timeout_seconds: 7200
|
||||
record_history: false
|
||||
173
benchmark/beam/run_home.py
Normal file
173
benchmark/beam/run_home.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Prepare the BEAM Home workspace, or optimize it with --optimize after preparation."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
import yaml
|
||||
|
||||
# Support direct execution from any working directory as well as module imports.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from reme_home.preparation.spec import PreparationSpec # noqa: E402
|
||||
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
|
||||
from reme_home.sampling import SamplingPolicy # noqa: E402
|
||||
|
||||
from reme.application import Application # noqa: E402
|
||||
from reme.utils.env_utils import load_env # noqa: E402
|
||||
|
||||
# pylint: enable=wrong-import-position
|
||||
|
||||
DEFAULT_CONFIG = Path(__file__).with_name("config_home.yaml")
|
||||
|
||||
|
||||
class RunHomeConfig(BaseModel):
|
||||
"""Runner settings kept separate from Home's public preparation contract."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
workspace_dir: str = Field(min_length=1)
|
||||
preparation: dict
|
||||
sampling_policy: SamplingPolicy = Field(default_factory=SamplingPolicy)
|
||||
validation_cycle_time_budget_minutes: float | None = Field(default=150, gt=0, allow_inf_nan=False)
|
||||
max_evaluations: int = Field(default=10, ge=1)
|
||||
memory_limit_mib: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
def project_path(value: str, project_root: Path) -> Path:
|
||||
"""Use repository-relative paths consistently regardless of the caller's cwd."""
|
||||
if value.startswith("~"):
|
||||
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
|
||||
return (project_root / value).resolve()
|
||||
|
||||
|
||||
def _load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
|
||||
"""Resolve source paths and validate explicit Home preparation parameters."""
|
||||
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
||||
config = RunHomeConfig.model_validate(raw)
|
||||
config.preparation.setdefault("candidate", {"paths": ["plugins/beam/src"]})
|
||||
spec = PreparationSpec.model_validate(config.preparation)
|
||||
spec.benchmark.source = str(project_path(spec.benchmark.source, project_root))
|
||||
if spec.benchmark.evidence_source is not None:
|
||||
spec.benchmark.evidence_source = str(project_path(spec.benchmark.evidence_source, project_root))
|
||||
spec.runtime.source_repo = str(project_path(spec.runtime.source_repo, project_root))
|
||||
workspace = project_path(workspace_dir if workspace_dir is not None else config.workspace_dir, project_root)
|
||||
return workspace, spec.model_dump(mode="json"), config
|
||||
|
||||
|
||||
def load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
|
||||
"""Return resolved preparation inputs, preserving the preparation-only caller contract."""
|
||||
workspace, spec, _ = _load_config(config_path, workspace_dir=workspace_dir, project_root=project_root)
|
||||
return workspace, spec
|
||||
|
||||
|
||||
async def _run(config_path, *, workspace_dir=None, optimize_workspace=False, instruction=None):
|
||||
"""Apply the same runner configuration to each preparation or optimization lifecycle."""
|
||||
workspace, spec, config = _load_config(config_path, workspace_dir=workspace_dir)
|
||||
load_env(PROJECT_ROOT / ".env", override=False)
|
||||
memory_options = {}
|
||||
jobs = {}
|
||||
if config.memory_limit_mib is not None:
|
||||
memory_options = {
|
||||
"candidate_gate_memory_limit_mib": config.memory_limit_mib,
|
||||
"construction_equivalence_memory_limit_mib": config.memory_limit_mib,
|
||||
}
|
||||
for name, backend, wrapper in (
|
||||
("home_optimize", "home_optimize_step", "home_optimizer"),
|
||||
("home_diagnose", "home_diagnose_step", "home_diagnostic"),
|
||||
("home_case_study", "home_case_study_step", "home_case_study"),
|
||||
):
|
||||
jobs[name] = {
|
||||
"steps": [
|
||||
{
|
||||
"backend": backend,
|
||||
"agent_wrapper": wrapper,
|
||||
"memory_limit_mib": config.memory_limit_mib,
|
||||
**({"concurrency": 4, "output_token_limit": 2000} if name == "home_case_study" else {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
app = Application(
|
||||
workspace_dir=str(workspace),
|
||||
plugins=["home", JUDGE_PLUGINS[spec["benchmark"]["name"]]],
|
||||
components={
|
||||
"home.evaluation": {
|
||||
"default": {"sampling_policy": config.sampling_policy.model_dump(mode="json"), **memory_options},
|
||||
},
|
||||
"agent_wrapper": {
|
||||
"home_optimizer": {
|
||||
"validation_cycle_time_budget_minutes": config.validation_cycle_time_budget_minutes,
|
||||
"max_evaluations": config.max_evaluations,
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs=jobs,
|
||||
service={"backend": "cli"},
|
||||
enable_logo=False,
|
||||
log_to_file=False,
|
||||
)
|
||||
try:
|
||||
await app.start()
|
||||
if optimize_workspace:
|
||||
arguments = {} if instruction is None else {"instruction": instruction}
|
||||
response = await app.run_job("home_optimize", **arguments)
|
||||
else:
|
||||
response = await app.run_job("home_prepare", spec=spec)
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
return {"workspace_dir": str(workspace), **response.answer}
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
|
||||
async def prepare(config_path: str | Path, *, workspace_dir: str | None = None) -> dict:
|
||||
"""Prepare once without running evaluations or optimization."""
|
||||
return await _run(config_path, workspace_dir=workspace_dir)
|
||||
|
||||
|
||||
async def optimize(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir=None, instruction=None) -> dict:
|
||||
"""Optimize an already prepared workspace using this config's actual model budgets."""
|
||||
return await _run(config_path, workspace_dir=workspace_dir, optimize_workspace=True, instruction=instruction)
|
||||
|
||||
|
||||
async def run(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir: str | None = None) -> dict:
|
||||
"""Prepare through the same Home Job as the CLI, then close the application."""
|
||||
return await prepare(config_path, workspace_dir=workspace_dir)
|
||||
|
||||
|
||||
def main(default_config: Path = DEFAULT_CONFIG) -> None:
|
||||
"""Run preparation or explicit local one-shot optimization with the benchmark config."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=default_config, help="Home runner YAML configuration")
|
||||
parser.add_argument("--workspace-dir", help="Override workspace directory (absolute or project-relative)")
|
||||
parser.add_argument("--optimize", action="store_true", help="Optimize an already prepared workspace")
|
||||
parser.add_argument("--instruction", help="Optimization focus; requires --optimize")
|
||||
args = parser.parse_args()
|
||||
if args.instruction is not None and not args.optimize:
|
||||
parser.error("--instruction requires --optimize")
|
||||
if args.optimize:
|
||||
result = asyncio.run(optimize(args.config, workspace_dir=args.workspace_dir, instruction=args.instruction))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return
|
||||
result = asyncio.run(prepare(args.config, workspace_dir=args.workspace_dir))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"workspace_dir": result["workspace_dir"],
|
||||
"case_count": result["dataset"]["case_count"],
|
||||
"query_count": result["dataset"]["query_count"],
|
||||
"source_commit": result["code"]["source_commit"],
|
||||
"initial_commit": result["code"]["initial_commit"],
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
226
benchmark/beam/run_home_eval.py
Normal file
226
benchmark/beam/run_home_eval.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""Evaluate a BEAM Home candidate in an independent test workspace."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
import yaml
|
||||
|
||||
# Support direct execution from any working directory as well as module imports.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from reme_home.code.repository import resolve_revision, validate_repository # noqa: E402
|
||||
from reme_home.evaluation.artifacts import contained, read_json # noqa: E402
|
||||
from reme_home.evaluation.component import blocking # noqa: E402
|
||||
from reme_home.evaluation.inputs import EvaluationSpec, load_inputs # noqa: E402
|
||||
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
|
||||
from reme_home.preparation.code import tree_fingerprint # noqa: E402
|
||||
from reme_home.preparation.data.models import fingerprint # noqa: E402
|
||||
from reme_home.preparation.evaluation_source import EvaluationSource # noqa: E402
|
||||
from reme_home.preparation.spec import BenchmarkSpec, DebugSpec, SpecModel # noqa: E402
|
||||
|
||||
from reme.application import Application # noqa: E402
|
||||
from reme.utils.env_utils import load_env # noqa: E402
|
||||
|
||||
# pylint: enable=wrong-import-position
|
||||
|
||||
DEFAULT_CONFIG = Path(__file__).with_name("config_home_eval.yaml")
|
||||
|
||||
|
||||
class TestRunConfig(SpecModel):
|
||||
"""Explicit dataset selection and execution options for one pinned candidate."""
|
||||
|
||||
source_workspace: str = Field(min_length=1)
|
||||
test_workspace: str = Field(min_length=1)
|
||||
code_commit: str = Field(min_length=4)
|
||||
benchmark: BenchmarkSpec
|
||||
exclude_training_cases: bool = False
|
||||
evaluation: EvaluationSpec = Field(default_factory=lambda: EvaluationSpec(record_history=False))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_test_mode(self):
|
||||
"""Test runs score every prepared query and never consume optimizer budgets."""
|
||||
options = self.evaluation
|
||||
if any(
|
||||
(
|
||||
options.debug,
|
||||
options.sampling,
|
||||
options.record_history,
|
||||
options.max_evaluations is not None,
|
||||
options.sampling_policy is not None,
|
||||
options.case_ids is not None,
|
||||
options.query_ids is not None,
|
||||
options.reuse_construction_from is not None,
|
||||
),
|
||||
):
|
||||
raise ValueError(
|
||||
"Test evaluation requires full scoring with record_history=false; select cases in benchmark",
|
||||
)
|
||||
if self.exclude_training_cases and (self.benchmark.name != "longmemeval" or not self.benchmark.all_cases):
|
||||
raise ValueError("exclude_training_cases requires LongMemEval with all_cases=true")
|
||||
return self
|
||||
|
||||
|
||||
def project_path(value: str, project_root: Path) -> Path:
|
||||
"""Resolve runner paths independently of the launching working directory."""
|
||||
if value.startswith("~"):
|
||||
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
|
||||
return (project_root / value).resolve()
|
||||
|
||||
|
||||
def load_request(config_path, *, commit=None, source_workspace=None, test_workspace=None, project_root=PROJECT_ROOT):
|
||||
"""Validate all source inputs before starting an application or writing test data."""
|
||||
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
||||
for key, value in (
|
||||
("code_commit", commit),
|
||||
("source_workspace", source_workspace),
|
||||
("test_workspace", test_workspace),
|
||||
):
|
||||
if value is not None:
|
||||
raw[key] = value
|
||||
# Execution settings can be partial while retaining the test-only history default.
|
||||
raw["evaluation"] = {"record_history": False, **raw.get("evaluation", {})}
|
||||
config = TestRunConfig.model_validate(raw)
|
||||
source_path = project_path(config.source_workspace, project_root)
|
||||
target = project_path(config.test_workspace, project_root)
|
||||
if target.is_relative_to(source_path) or source_path.is_relative_to(target):
|
||||
raise ValueError("Test and source workspaces must be separate, non-nested directories")
|
||||
if target.exists() and (
|
||||
not target.is_dir() or (any(target.iterdir()) and not (target / "home/metadata/workspace.json").is_file())
|
||||
):
|
||||
raise ValueError("Use an empty test workspace or a compatible prepared Home test workspace")
|
||||
source = EvaluationSource.load(source_path, config.code_commit)
|
||||
benchmark = config.benchmark.model_copy(deep=True)
|
||||
if benchmark.name != source.spec.benchmark.name:
|
||||
raise ValueError("Test benchmark must match the source experiment")
|
||||
benchmark.source = str(project_path(benchmark.source, project_root).resolve(strict=True))
|
||||
if benchmark.evidence_source is not None:
|
||||
benchmark.evidence_source = str(project_path(benchmark.evidence_source, project_root).resolve(strict=True))
|
||||
if config.exclude_training_cases:
|
||||
items = json.loads(Path(benchmark.source).read_text(encoding="utf-8"))
|
||||
ids = [str(item["question_id"]) for item in items]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Duplicate LongMemEval question IDs")
|
||||
training = set(source.record["dataset"]["case_ids"])
|
||||
if training - set(ids):
|
||||
raise ValueError("Test dataset is missing training IDs; provide the original full dataset")
|
||||
benchmark.case_ids = [case_id for case_id in ids if case_id not in training]
|
||||
benchmark.all_cases = False
|
||||
benchmark = BenchmarkSpec.model_validate(benchmark.model_dump())
|
||||
spec = source.spec.model_copy(deep=True)
|
||||
spec.benchmark = benchmark
|
||||
spec.debug = DebugSpec(enabled=False)
|
||||
return config, target, source, spec
|
||||
|
||||
|
||||
def existing_preparation(home, source, spec):
|
||||
"""Reopen only the same request with unchanged code, runtime and normalized data."""
|
||||
marker = contained(home, "metadata/preparation.json")
|
||||
if not marker.exists():
|
||||
return None
|
||||
record = read_json(marker)
|
||||
expected = {**source.provenance, "request_fingerprint": fingerprint(spec.model_dump(mode="json"))}
|
||||
if record.get("test_evaluation") != expected:
|
||||
raise ValueError("Test workspace belongs to a different candidate or dataset; use a new workspace")
|
||||
domain = yaml.safe_load(contained(home, "domain_spec.yaml").read_text(encoding="utf-8"))
|
||||
if fingerprint(domain) != record["config_fingerprint"]:
|
||||
raise ValueError("Test preparation configuration has changed")
|
||||
repository, _ = validate_repository(home, record)
|
||||
if (
|
||||
resolve_revision(repository, "HEAD") != record["code"]["initial_commit"]
|
||||
or tree_fingerprint(repository) != record["code"]["candidate_fingerprint"]
|
||||
or tree_fingerprint(contained(home, "code/runtime", directory=True)) != source.provenance["runtime_fingerprint"]
|
||||
):
|
||||
raise ValueError("Prepared test code or runtime has changed")
|
||||
load_inputs(home, EvaluationSpec(record_history=False))
|
||||
return record
|
||||
|
||||
|
||||
async def run_evaluation(
|
||||
config_path,
|
||||
*,
|
||||
commit=None,
|
||||
source_workspace=None,
|
||||
test_workspace=None,
|
||||
prepare_only=False,
|
||||
expected_benchmark=None,
|
||||
):
|
||||
"""Prepare transactionally, then score through the normal application lifecycle."""
|
||||
config, target, source, spec = await blocking(
|
||||
load_request,
|
||||
config_path,
|
||||
commit=commit,
|
||||
source_workspace=source_workspace,
|
||||
test_workspace=test_workspace,
|
||||
)
|
||||
if expected_benchmark is not None and config.benchmark.name != expected_benchmark:
|
||||
raise ValueError(f"This entry point requires benchmark.name={expected_benchmark}")
|
||||
load_env(PROJECT_ROOT / ".env", override=False)
|
||||
app = Application(
|
||||
workspace_dir=str(target),
|
||||
plugins=["home", JUDGE_PLUGINS[config.benchmark.name]],
|
||||
service={"backend": "cli"},
|
||||
enable_logo=False,
|
||||
log_to_file=False,
|
||||
)
|
||||
try:
|
||||
await app.start()
|
||||
home = target / "home"
|
||||
prepared = await blocking(existing_preparation, home, source, spec)
|
||||
if prepared is None:
|
||||
preparation = app.context.components["home.preparation"]["default"]
|
||||
prepared = await preparation.prepare(spec=spec.model_dump(mode="json"), code_source=source)
|
||||
result = {
|
||||
"test_workspace": str(target),
|
||||
**source.provenance,
|
||||
"evaluation_commit_sha": prepared["code"]["initial_commit"],
|
||||
"case_count": prepared["dataset"]["case_count"],
|
||||
"query_count": prepared["dataset"]["query_count"],
|
||||
}
|
||||
if not prepare_only:
|
||||
response = await app.run_job(
|
||||
"home_evaluate",
|
||||
**config.evaluation.model_dump(mode="json", exclude_unset=True),
|
||||
)
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
result["evaluation"] = response.answer
|
||||
return result
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
|
||||
async def run(config_path=DEFAULT_CONFIG, **kwargs):
|
||||
"""Score the selected commit in its own Home workspace."""
|
||||
return await run_evaluation(config_path, expected_benchmark="beam", **kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
"""Explicit local one-shot entry point, with preparation-only service setup."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--commit", help="Candidate commit SHA or unambiguous hexadecimal prefix")
|
||||
parser.add_argument("--source-workspace", help="Prepared optimization workspace")
|
||||
parser.add_argument("--test-workspace", help="Independent test output workspace")
|
||||
parser.add_argument("--prepare-only", action="store_true", help="Prepare for a later home_evaluate service call")
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(
|
||||
run(
|
||||
args.config,
|
||||
commit=args.commit,
|
||||
source_workspace=args.source_workspace,
|
||||
test_workspace=args.test_workspace,
|
||||
prepare_only=args.prepare_only,
|
||||
),
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -112,3 +112,111 @@ agentscope==2.0.4.post1, conda reme env, 32 workers, eval-only (reusing prebuilt
|
|||
| single-session-user | 0.986 | 27,433 | 359 | 27,792 | 2.60 |
|
||||
| temporal-reasoning | 0.902 | 62,674 | 985 | 63,659 | 4.97 |
|
||||
| **OVERALL** | **0.894** | **43,448** | **876** | **44,324** | **3.69** |
|
||||
|
||||
|
||||
## Prepare a Home experiment
|
||||
|
||||
`run_home.py` prepares the fixed **group_1–group_5** training split (250 cases), the workspace, and committed candidate
|
||||
code. It exits after preparation; it does not evaluate cases or start optimization.
|
||||
Run from the repository root after obtaining the dataset described above:
|
||||
|
||||
```bash
|
||||
reme plugins install plugins/home
|
||||
python benchmark/longmemeval/run_home.py
|
||||
# Optional separate workspace (a successful preparation cannot be repeated):
|
||||
python benchmark/longmemeval/run_home.py --workspace-dir /path/to/new-workspace
|
||||
```
|
||||
|
||||
[config_home.yaml](config_home.yaml) controls the preparation. Its paths are
|
||||
repository-relative regardless of the current working directory; `--config`
|
||||
accepts an alternative runner YAML file. The default workspace is
|
||||
`benchmark/longmemeval/workspaces/home-groups-1-5`. The evaluated runtime loads `lme`;
|
||||
the host application loads `home`. `source_ref: HEAD` exports committed source
|
||||
only, excluding uncommitted modifications. Preparation failure rolls back its
|
||||
outputs; after success, another call fails instead of resetting the experiment.
|
||||
|
||||
The top-level `sampling_policy` configures Home evaluation defaults: `max_cases`,
|
||||
`max_queries` (null means unlimited), `temperature`, `ewma_alpha` and
|
||||
`uniform_exploration`. The runner validates these and passes them to the host
|
||||
`home.evaluation` Component. It still only prepares; it does not run evaluations.
|
||||
Each evaluation controls `sampling` and `record_history` separately.
|
||||
|
||||
The top-level `validation_cycle_time_budget_minutes` configures the host optimizer's
|
||||
advisory time between completed formal evaluations. It defaults to 150 minutes;
|
||||
set a positive number to change it or null to disable timed reminders.
|
||||
|
||||
The top-level `max_evaluations` sets the optimizer's formal-evaluation budget
|
||||
(positive integer, default 10). A slot is consumed at first sandbox startup,
|
||||
including later failures or cancellations; initial baseline and debug are excluded.
|
||||
|
||||
The top-level `memory_limit_mib` sets the host agent sandbox memory limit for
|
||||
optimization, diagnosis, case studies and evaluation reviews. Set `memory_limit_mib: 0`
|
||||
in the runner YAML to disable the Linux cgroup limit. Null or omission keeps platform
|
||||
defaults (4096 MiB on Linux); macOS does not enforce memory limits.
|
||||
|
||||
After preparation, run local one-shot optimization with the same configuration:
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home.py --optimize
|
||||
# Use the same --config and --workspace-dir overrides as preparation, if supplied.
|
||||
python benchmark/longmemeval/run_home.py --optimize --instruction "Improve retrieval quality"
|
||||
```
|
||||
|
||||
This starts a fresh application, applies both budgets and the sampling policy,
|
||||
runs `home_optimize`, and closes the application. It does not repeat preparation.
|
||||
Python callers can use `await optimize(config_path, workspace_dir=...)` from the
|
||||
same module. The default `run()` and CLI behavior remain preparation only.
|
||||
|
||||
Python callers can use the same lifecycle:
|
||||
|
||||
```python
|
||||
from benchmark.longmemeval.run_home import run
|
||||
|
||||
result = await run(workspace_dir="/path/to/new-workspace")
|
||||
```
|
||||
|
||||
The 250 `benchmark.case_ids` in [config_home.yaml](config_home.yaml) are copied
|
||||
in order from groups 1–5 of `dataset/groups.json` at `dev_meta` commit
|
||||
`b53048e44833937df80a52fccceea78d054790e8`. The original splitter used seed 42
|
||||
and type-stratified allocation into ten 50-case groups; groups 6–10 remain held
|
||||
out. The runner reads the IDs directly from YAML without loading group files.
|
||||
[home_evidence_sessions.jsonl](home_evidence_sessions.jsonl) is a byte-for-byte
|
||||
copy of that revision's `dataset/longmemeval_evidence_sessions.jsonl`;
|
||||
reviewed evidence-session annotations are preserved during normalization.
|
||||
|
||||
|
||||
## Evaluate a candidate on an independent test set
|
||||
|
||||
The default excludes the source experiment's actual training IDs from the full dataset. With the default
|
||||
250-case training workspace this selects held-out groups 6–10. For an explicit selection, set
|
||||
`exclude_training_cases: false` and replace `all_cases: true` with `case_ids: [...]` in
|
||||
[config_home_eval.yaml](config_home_eval.yaml). An empty held-out selection fails before preparation.
|
||||
|
||||
The entry point imports a full candidate SHA or unambiguous hexadecimal prefix from an existing Home
|
||||
optimization workspace and copies its prepared fixed runtime. It does not require the original ReMe source
|
||||
repository or change the optimizer's checkout. Paths in the configuration are repository-relative.
|
||||
|
||||
Prepare first, then use the service from two terminals (replace `CANDIDATE_SHA`):
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
|
||||
# Terminal 1:
|
||||
reme start workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out 'plugins=["home","lme-judge"]'
|
||||
# Terminal 2:
|
||||
reme home_evaluate workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out record_history=false
|
||||
```
|
||||
|
||||
### Local one-shot test evaluation
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA
|
||||
# Optional overrides: --config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
|
||||
```
|
||||
|
||||
Python callers can use `await benchmark.longmemeval.run_home_eval.run(commit="CANDIDATE_SHA")`.
|
||||
The runner rebuilds construction and scores every selected query with sampling and history recording disabled.
|
||||
It uses the existing Docker evaluation runtime and host judge configuration; model credentials are required
|
||||
for evaluation. Results stay in the test workspace's `home/evaluations/`, with original candidate provenance
|
||||
in each manifest's `test_evaluation`. The exported snapshot has its own local `evaluation_commit_sha`.
|
||||
Rerunning the same request reopens unchanged prepared inputs and creates another evaluation result. Use a
|
||||
new test workspace for another candidate or selection. Source and test workspaces must not overlap.
|
||||
|
|
|
|||
|
|
@ -101,3 +101,89 @@ agentscope==2.0.4.post1, conda reme env, 32 workers, eval-only(复用预构建
|
|||
| single-session-user | 0.986 | 27,433 | 359 | 27,792 | 2.60 |
|
||||
| temporal-reasoning | 0.902 | 62,674 | 985 | 63,659 | 4.97 |
|
||||
| **OVERALL** | **0.894** | **43,448** | **876** | **44,324** | **3.69** |
|
||||
|
||||
|
||||
## 准备 Home 实验
|
||||
|
||||
安装 Home 插件后,在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
reme plugins install plugins/home
|
||||
python benchmark/longmemeval/run_home.py
|
||||
```
|
||||
|
||||
该入口只创建工作区、准备数据和代码,不运行评测或优化。
|
||||
参数见 [config_home.yaml](config_home.yaml),路径相对仓库根目录;
|
||||
可通过 `--config` 更换配置,通过 `--workspace-dir` 指定新工作区。
|
||||
默认工作区为 `benchmark/longmemeval/workspaces/home-groups-1-5`。
|
||||
代码从 `source_ref: HEAD` 对应的 Git 提交导出,不包含未提交修改。
|
||||
准备失败会回滚;成功后禁止再次准备同一工作区。
|
||||
Python 中也可调用 `await benchmark.longmemeval.run_home.run(workspace_dir=...)`。
|
||||
|
||||
顶层 `sampling_policy` 配置采样默认值:`max_cases`、`max_queries`(null 表示不限)、
|
||||
`temperature`、`ewma_alpha` 和 `uniform_exploration`。入口校验后将其传给宿主
|
||||
`home.evaluation` Component,仍只准备、不执行评测;每次评测分别控制 `sampling`
|
||||
和 `record_history`。
|
||||
|
||||
顶层 `validation_cycle_time_budget_minutes` 配置宿主 optimizer 两次已完成正式评测之间的
|
||||
建议用时,默认 150 分钟;可改为其他正数,或设为 null 关闭时间提醒。
|
||||
|
||||
顶层 `max_evaluations` 配置 optimizer 的正式评测次数预算,必须为正整数,默认 10。
|
||||
首次成功启动 sandbox 时扣一次,之后失败或取消也不退还;初始 baseline 和 debug 不计入。
|
||||
|
||||
顶层 `memory_limit_mib` 配置优化、诊断、case study 和评估审查的宿主 agent 沙箱内存限制。
|
||||
在 runner YAML 中设置 `memory_limit_mib: 0` 可关闭 Linux cgroup 内存限制;
|
||||
省略或设为 null 时保留平台默认值(Linux 为 4096 MiB),macOS 不执行内存限制。
|
||||
|
||||
准备完成后,用同一份配置进行本地一次性优化:
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home.py --optimize
|
||||
```
|
||||
|
||||
若准备时指定了 `--config` 或 `--workspace-dir`,优化时传入相同参数。
|
||||
可用 `--instruction "优化检索质量"` 指定目标。该入口将配置中的两个预算和采样策略
|
||||
应用到实际 `home_optimize` 调用,不重复准备;Python 可调用同模块的 `await optimize(...)`。
|
||||
不加 `--optimize` 时仍只准备。
|
||||
|
||||
默认使用 `dev_meta` 已保存的 **group_1–group_5,共 250 个 case**,
|
||||
ID 已按原顺序直接写入 [config_home.yaml](config_home.yaml) 的 `benchmark.case_ids`,
|
||||
运行时无需读取分组文件,group_6–group_10 保持为留出集。
|
||||
独立审阅的证据标注保存在
|
||||
[home_evidence_sessions.jsonl](home_evidence_sessions.jsonl),来源见英文说明。
|
||||
|
||||
|
||||
## 独立测试集评测
|
||||
|
||||
默认从完整数据中排除来源工作区实际使用的训练 ID;使用默认 250 个训练 case 时,测试集为
|
||||
**group_6–group_10,共 250 个 case**。若要显式选取测试 case,可在
|
||||
[config_home_eval.yaml](config_home_eval.yaml) 设置 `exclude_training_cases: false`,
|
||||
并把 `all_cases: true` 替换为 `case_ids: [...]`。留出集为空时会直接报错。
|
||||
|
||||
入口从已有 Home 优化工作区导出指定候选提交,接受完整 SHA 或无歧义的十六进制短 SHA,
|
||||
并复制该实验已准备的固定 runtime。无需原始 ReMe 源码仓库,也不会切换或修改优化工作区。
|
||||
配置内路径均相对仓库根目录。
|
||||
|
||||
先准备测试工作区,再分别在两个终端启动服务和调用评测(替换 `CANDIDATE_SHA`):
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
|
||||
# 终端 1:
|
||||
reme start workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out 'plugins=["home","lme-judge"]'
|
||||
# 终端 2:
|
||||
reme home_evaluate workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out record_history=false
|
||||
```
|
||||
|
||||
### 本地一次性测试评测
|
||||
|
||||
```bash
|
||||
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA
|
||||
# 可选:--config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
|
||||
```
|
||||
|
||||
Python 可调用 `await benchmark.longmemeval.run_home_eval.run(commit="CANDIDATE_SHA")`。
|
||||
入口完整重建记忆并对所选 case 的全部 query 评分,关闭采样与优化历史记录,使用现有 Docker
|
||||
评测环境和宿主 judge 配置;实际评测需要模型凭证。结果保存在测试工作区的 `home/evaluations/`,
|
||||
manifest 的 `test_evaluation` 记录来源候选,导出后的本地提交另记为 `evaluation_commit_sha`。
|
||||
相同请求可复用未改动的准备结果并新增一次评测;更换候选或数据选择时需使用新测试工作区。
|
||||
测试工作区与来源工作区必须互不包含。
|
||||
|
|
|
|||
288
benchmark/longmemeval/config_home.yaml
Normal file
288
benchmark/longmemeval/config_home.yaml
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# Preparation by default; --optimize applies these settings to the prepared workspace.
|
||||
# Paths are relative to the ReMe repository root, not the cwd.
|
||||
# Preserve dev_meta's fixed training split; groups 6-10 remain held out.
|
||||
workspace_dir: benchmark/longmemeval/workspaces/home-groups-1-5
|
||||
# Formal optimizer evaluations; charged once the first sandbox starts, excluding init/debug.
|
||||
max_evaluations: 10
|
||||
# Host agent sandbox memory limit in MiB; null keeps platform defaults, 0 disables the Linux cgroup limit.
|
||||
memory_limit_mib: 0
|
||||
# Policy defaults for Home evaluation; each call still controls sampling=true/false.
|
||||
sampling_policy:
|
||||
max_cases: 5
|
||||
max_queries: null
|
||||
temperature: 1.0
|
||||
ewma_alpha: 0.3
|
||||
uniform_exploration: 0.2
|
||||
preparation:
|
||||
benchmark:
|
||||
name: longmemeval
|
||||
source: benchmark/longmemeval/dataset/longmemeval_s_reme_cleaned.json
|
||||
evidence_source: benchmark/longmemeval/home_evidence_sessions.jsonl
|
||||
# dev_meta b53048e44833937df80a52fccceea78d054790e8: dataset/groups.json, groups 1-5.
|
||||
case_ids:
|
||||
# group_1
|
||||
- "gpt4_1e4a8aec"
|
||||
- "e3038f8c"
|
||||
- "gpt4_70e84552_abs"
|
||||
- "edced276_abs"
|
||||
- "c6853660"
|
||||
- "gpt4_372c3eed"
|
||||
- "gpt4_ec93e27f"
|
||||
- "c8c3f81d"
|
||||
- "dccbc061"
|
||||
- "a06e4cfe"
|
||||
- "a82c026e"
|
||||
- "fca762bc"
|
||||
- "603deb26"
|
||||
- "bb7c3b45"
|
||||
- "4baee567"
|
||||
- "af082822"
|
||||
- "1a1907b4"
|
||||
- "gpt4_468eb064"
|
||||
- "ef9cf60a"
|
||||
- "gpt4_a2d1d1f6"
|
||||
- "8fb83627"
|
||||
- "1c0ddc50"
|
||||
- "2ebe6c92"
|
||||
- "e25c3b8d"
|
||||
- "6aeb4375"
|
||||
- "bbf86515"
|
||||
- "8e91e7d9"
|
||||
- "f0e564bc"
|
||||
- "eaca4986"
|
||||
- "gpt4_4fc4f797"
|
||||
- "gpt4_5501fe77"
|
||||
- "7a8d0b71"
|
||||
- "982b5123"
|
||||
- "f4f1d8a4_abs"
|
||||
- "e831120c"
|
||||
- "0977f2af"
|
||||
- "51c32626"
|
||||
- "gpt4_ab202e7f"
|
||||
- "eace081b"
|
||||
- "d01c6aa8"
|
||||
- "8aef76bc"
|
||||
- "d682f1a2"
|
||||
- "gpt4_468eb063"
|
||||
- "c5e8278d"
|
||||
- "ccb36322"
|
||||
- "affe2881"
|
||||
- "75f70248"
|
||||
- "6e984302"
|
||||
- "ed4ddc30"
|
||||
- "a11281a2"
|
||||
# group_2
|
||||
- "cc6d1ec1"
|
||||
- "6613b389"
|
||||
- "c4f10528"
|
||||
- "1faac195"
|
||||
- "3d86fd0a"
|
||||
- "9d25d4e0"
|
||||
- "b759caee"
|
||||
- "gpt4_b4a80587"
|
||||
- "b3c15d39"
|
||||
- "370a8ff4"
|
||||
- "bf659f65"
|
||||
- "5a7937c8"
|
||||
- "57f827a0"
|
||||
- "545bd2b5"
|
||||
- "a96c20ee"
|
||||
- "caf03d32"
|
||||
- "dad224aa"
|
||||
- "50635ada"
|
||||
- "21436231"
|
||||
- "031748ae"
|
||||
- "41275add"
|
||||
- "51a45a95"
|
||||
- "gpt4_4929293a"
|
||||
- "3e321797"
|
||||
- "0e5e2d1a"
|
||||
- "b01defab"
|
||||
- "gpt4_4ef30696"
|
||||
- "91b15a6e"
|
||||
- "d6233ab6"
|
||||
- "c18a7dc8"
|
||||
- "f0853d11"
|
||||
- "031748ae_abs"
|
||||
- "60d45044"
|
||||
- "bcbe585f"
|
||||
- "41698283"
|
||||
- "0ddfec37"
|
||||
- "46a3abf7"
|
||||
- "dd2973ad"
|
||||
- "129d1232"
|
||||
- "60159905"
|
||||
- "2b8f3739"
|
||||
- "c8090214_abs"
|
||||
- "8464fc84"
|
||||
- "gpt4_93f6379c"
|
||||
- "gpt4_2c50253f"
|
||||
- "gpt4_18c2b244"
|
||||
- "bc8a6e93_abs"
|
||||
- "09ba9854"
|
||||
- "2133c1b5_abs"
|
||||
- "gpt4_4edbafa2"
|
||||
# group_3
|
||||
- "7e00a6cb"
|
||||
- "0e4e4c46"
|
||||
- "gpt4_0b2f1d21"
|
||||
- "55241a1f"
|
||||
- "77eafa52"
|
||||
- "dc439ea3"
|
||||
- "86f00804"
|
||||
- "830ce83f"
|
||||
- "cc5ded98"
|
||||
- "2311e44b_abs"
|
||||
- "gpt4_d12ceb0e"
|
||||
- "71a3fd6b"
|
||||
- "07741c45"
|
||||
- "e6041065"
|
||||
- "gpt4_68e94288"
|
||||
- "32260d93"
|
||||
- "db467c8c"
|
||||
- "4adc0475"
|
||||
- "6cb6f249"
|
||||
- "c9f37c46"
|
||||
- "4388e9dd"
|
||||
- "9ea5eabc"
|
||||
- "gpt4_fe651585_abs"
|
||||
- "9a707b81"
|
||||
- "e9327a54"
|
||||
- "eac54adc"
|
||||
- "gpt4_7fce9456"
|
||||
- "f9e8c073"
|
||||
- "ba358f49_abs"
|
||||
- "561fabcd"
|
||||
- "42ec0761"
|
||||
- "gpt4_4929293b"
|
||||
- "c19f7a0b"
|
||||
- "0bc8ad93"
|
||||
- "e47becba"
|
||||
- "15745da0"
|
||||
- "2e6d26dc"
|
||||
- "efc3f7c2"
|
||||
- "37f165cf"
|
||||
- "gpt4_f420262d"
|
||||
- "66f24dbb"
|
||||
- "gpt4_194be4b3"
|
||||
- "gpt4_c27434e8_abs"
|
||||
- "f4f1d8a4"
|
||||
- "a89d7624"
|
||||
- "19b5f2b3"
|
||||
- "gpt4_8279ba02"
|
||||
- "gpt4_74aed68e"
|
||||
- "1da05512"
|
||||
- "gpt4_f420262c"
|
||||
# group_4
|
||||
- "gpt4_fa19884d"
|
||||
- "6f9b354f"
|
||||
- "a3045048"
|
||||
- "81507db6"
|
||||
- "61f8c8f8"
|
||||
- "a346bb18"
|
||||
- "87f22b4a"
|
||||
- "gpt4_2ba83207"
|
||||
- "01493427"
|
||||
- "08e075c7"
|
||||
- "09d032c9"
|
||||
- "6a27ffc2"
|
||||
- "edced276"
|
||||
- "8550ddae"
|
||||
- "e5ba910e_abs"
|
||||
- "3f1e9474"
|
||||
- "8a2466db"
|
||||
- "gpt4_2f8be40d"
|
||||
- "2698e78f"
|
||||
- "gpt4_1a1dc16d"
|
||||
- "6b168ec8"
|
||||
- "d905b33f"
|
||||
- "60472f9c"
|
||||
- "6e984301"
|
||||
- "gpt4_1e4a8aeb"
|
||||
- "gpt4_98f46fc6"
|
||||
- "e66b632c"
|
||||
- "gpt4_1d4ab0c9"
|
||||
- "gpt4_0a05b494"
|
||||
- "72e3ee87"
|
||||
- "16c90bf4"
|
||||
- "5809eb10"
|
||||
- "e8a79c70"
|
||||
- "gpt4_213fd887"
|
||||
- "0ddfec37_abs"
|
||||
- "gpt4_cd90e484"
|
||||
- "06f04340"
|
||||
- "eeda8a6d"
|
||||
- "9a707b82"
|
||||
- "29f2956b"
|
||||
- "09ba9854_abs"
|
||||
- "gpt4_5dcc0aab"
|
||||
- "gpt4_7bc6cf22"
|
||||
- "5d3d2817"
|
||||
- "4c36ccef"
|
||||
- "e3fc4d6e"
|
||||
- "aae3761f"
|
||||
- "d596882b"
|
||||
- "ce6d2d27"
|
||||
- "ad7109d1"
|
||||
# group_5
|
||||
- "76d63226"
|
||||
- "18dcd5a5"
|
||||
- "e48988bc"
|
||||
- "099778bb"
|
||||
- "2c63a862"
|
||||
- "gpt4_7abb270c"
|
||||
- "f523d9fe"
|
||||
- "06878be2"
|
||||
- "6456829e_abs"
|
||||
- "faba32e5"
|
||||
- "gpt4_15e38248"
|
||||
- "gpt4_8e165409"
|
||||
- "35a27287"
|
||||
- "gpt4_59c863d7"
|
||||
- "gpt4_59149c77"
|
||||
- "8ebdbe50"
|
||||
- "f685340e"
|
||||
- "21d02d0d"
|
||||
- "4fd1909e"
|
||||
- "3249768e"
|
||||
- "gpt4_f49edff3"
|
||||
- "1d4e3b97"
|
||||
- "1192316e"
|
||||
- "e61a7584"
|
||||
- "0100672e"
|
||||
- "8cf51dda"
|
||||
- "95bcc1c8"
|
||||
- "71017277"
|
||||
- "a3838d2b"
|
||||
- "c4ea545c"
|
||||
- "2318644b"
|
||||
- "18bc8abd"
|
||||
- "gpt4_61e13b3c"
|
||||
- "gpt4_e05b82a6"
|
||||
- "b46e15ed"
|
||||
- "3a704032"
|
||||
- "eac54add"
|
||||
- "9ee3ecd6"
|
||||
- "80ec1f4f"
|
||||
- "gpt4_1916e0ea"
|
||||
- "b6019101"
|
||||
- "45dc21b6"
|
||||
- "gpt4_c27434e8"
|
||||
- "bc149d6b"
|
||||
- "6222b6eb"
|
||||
- "06db6396"
|
||||
- "29f2956b_abs"
|
||||
- "gpt4_5438fa52"
|
||||
- "cf22b7bf"
|
||||
- "853b0a1d"
|
||||
runtime:
|
||||
source_repo: .
|
||||
source_ref: HEAD # Committed source only; resolved and saved as a full SHA.
|
||||
config: benchmark.yaml
|
||||
plugins: [lme]
|
||||
candidate:
|
||||
paths: [plugins/lme/src]
|
||||
debug:
|
||||
enabled: true
|
||||
sessions_per_case: 3
|
||||
messages_per_session: 7
|
||||
14
benchmark/longmemeval/config_home_eval.yaml
Normal file
14
benchmark/longmemeval/config_home_eval.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Evaluate the complement of the source experiment's training IDs in the full dataset.
|
||||
# With the default training workspace this selects held-out groups 6-10 (250 cases).
|
||||
source_workspace: benchmark/longmemeval/workspaces/home-groups-1-5
|
||||
test_workspace: benchmark/longmemeval/workspaces/home-test-held-out
|
||||
code_commit: "" # Required: set a candidate SHA/prefix, or pass --commit.
|
||||
exclude_training_cases: true
|
||||
benchmark:
|
||||
name: longmemeval
|
||||
source: benchmark/longmemeval/dataset/longmemeval_s_reme_cleaned.json
|
||||
all_cases: true
|
||||
evaluation:
|
||||
concurrency: 4
|
||||
timeout_seconds: 7200
|
||||
record_history: false
|
||||
500
benchmark/longmemeval/home_evidence_sessions.jsonl
Normal file
500
benchmark/longmemeval/home_evidence_sessions.jsonl
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
{"case_id":"e47becba","evidence_sessions":["answer_280352e9"],"is_certain":true}
|
||||
{"case_id":"118b2229","evidence_sessions":["answer_40a90d51"],"is_certain":true}
|
||||
{"case_id":"51a45a95","evidence_sessions":["answer_d61669c7"],"is_certain":true}
|
||||
{"case_id":"58bf7951","evidence_sessions":["answer_355c48bb"],"is_certain":true}
|
||||
{"case_id":"1e043500","evidence_sessions":["answer_3e012175"],"is_certain":true}
|
||||
{"case_id":"c5e8278d","evidence_sessions":["answer_f6168136"],"is_certain":true}
|
||||
{"case_id":"6ade9755","evidence_sessions":["answer_9398da02"],"is_certain":true}
|
||||
{"case_id":"6f9b354f","evidence_sessions":["answer_feb5200f"],"is_certain":true}
|
||||
{"case_id":"58ef2f1c","evidence_sessions":["answer_59547700"],"is_certain":true}
|
||||
{"case_id":"f8c5f88b","evidence_sessions":["answer_c3567066"],"is_certain":true}
|
||||
{"case_id":"5d3d2817","evidence_sessions":["answer_235eb6fb"],"is_certain":true}
|
||||
{"case_id":"7527f7e2","evidence_sessions":["answer_7cb94507"],"is_certain":true}
|
||||
{"case_id":"c960da58","evidence_sessions":["answer_e05e4612"],"is_certain":true}
|
||||
{"case_id":"3b6f954b","evidence_sessions":["answer_94030872"],"is_certain":true}
|
||||
{"case_id":"726462e0","evidence_sessions":["answer_f38f679b"],"is_certain":true}
|
||||
{"case_id":"94f70d80","evidence_sessions":["answer_c63c0458"],"is_certain":true}
|
||||
{"case_id":"66f24dbb","evidence_sessions":["answer_fea2e4d3"],"is_certain":true}
|
||||
{"case_id":"ad7109d1","evidence_sessions":["answer_679840f8"],"is_certain":true}
|
||||
{"case_id":"af8d2e46","evidence_sessions":["answer_82a04f59"],"is_certain":true}
|
||||
{"case_id":"dccbc061","evidence_sessions":["answer_8f276838"],"is_certain":true}
|
||||
{"case_id":"c8c3f81d","evidence_sessions":["answer_761acef8"],"is_certain":true}
|
||||
{"case_id":"8ebdbe50","evidence_sessions":["answer_8ad8a34f"],"is_certain":true}
|
||||
{"case_id":"6b168ec8","evidence_sessions":["answer_e623ae87"],"is_certain":true}
|
||||
{"case_id":"75499fd8","evidence_sessions":["answer_723bf11f"],"is_certain":true}
|
||||
{"case_id":"21436231","evidence_sessions":["answer_1e6d4567"],"is_certain":true}
|
||||
{"case_id":"95bcc1c8","evidence_sessions":["answer_cb742a61"],"is_certain":true}
|
||||
{"case_id":"0862e8bf","evidence_sessions":["answer_c6fd8ebd"],"is_certain":true}
|
||||
{"case_id":"853b0a1d","evidence_sessions":["answer_69811d4a"],"is_certain":true}
|
||||
{"case_id":"a06e4cfe","evidence_sessions":["answer_6fe9fb49"],"is_certain":true}
|
||||
{"case_id":"37d43f65","evidence_sessions":["answer_55161935"],"is_certain":true}
|
||||
{"case_id":"b86304ba","evidence_sessions":["answer_645b0329"],"is_certain":true}
|
||||
{"case_id":"d52b4f67","evidence_sessions":["answer_0df6aa4b"],"is_certain":true}
|
||||
{"case_id":"25e5aa4f","evidence_sessions":["answer_986de8c3"],"is_certain":true}
|
||||
{"case_id":"caf9ead2","evidence_sessions":["answer_0714183a"],"is_certain":true}
|
||||
{"case_id":"8550ddae","evidence_sessions":["answer_c8354ae9"],"is_certain":true}
|
||||
{"case_id":"60d45044","evidence_sessions":["answer_9cddca88"],"is_certain":true}
|
||||
{"case_id":"3f1e9474","evidence_sessions":["answer_57fc1954"],"is_certain":true}
|
||||
{"case_id":"86b68151","evidence_sessions":["answer_dc11c1eb"],"is_certain":true}
|
||||
{"case_id":"577d4d32","evidence_sessions":["answer_0dd4d99a"],"is_certain":true}
|
||||
{"case_id":"ec81a493","evidence_sessions":["answer_ed1982fc"],"is_certain":true}
|
||||
{"case_id":"15745da0","evidence_sessions":["answer_586de428"],"is_certain":true}
|
||||
{"case_id":"e01b8e2f","evidence_sessions":["answer_5ca6cd28"],"is_certain":true}
|
||||
{"case_id":"bc8a6e93","evidence_sessions":["answer_e6143162"],"is_certain":true}
|
||||
{"case_id":"ccb36322","evidence_sessions":["answer_f1fbb330"],"is_certain":true}
|
||||
{"case_id":"001be529","evidence_sessions":["answer_530960c1"],"is_certain":true}
|
||||
{"case_id":"b320f3f8","evidence_sessions":["answer_5cc9b056"],"is_certain":true}
|
||||
{"case_id":"19b5f2b3","evidence_sessions":["answer_5ff494b9"],"is_certain":true}
|
||||
{"case_id":"4fd1909e","evidence_sessions":["answer_2952aee4"],"is_certain":true}
|
||||
{"case_id":"545bd2b5","evidence_sessions":["answer_47ffab4c"],"is_certain":true}
|
||||
{"case_id":"8a137a7f","evidence_sessions":["answer_15d63a22"],"is_certain":true}
|
||||
{"case_id":"76d63226","evidence_sessions":["answer_bbdc7b0a"],"is_certain":true}
|
||||
{"case_id":"86f00804","evidence_sessions":["answer_96b8c9ee"],"is_certain":true}
|
||||
{"case_id":"8e9d538c","evidence_sessions":["answer_7bdcbd23"],"is_certain":true}
|
||||
{"case_id":"311778f1","evidence_sessions":["answer_e40b054e"],"is_certain":true}
|
||||
{"case_id":"c19f7a0b","evidence_sessions":["answer_f442ccbe"],"is_certain":true}
|
||||
{"case_id":"4100d0a0","evidence_sessions":["answer_83c13ff9"],"is_certain":true}
|
||||
{"case_id":"29f2956b","evidence_sessions":["answer_7cc5362f"],"is_certain":true}
|
||||
{"case_id":"1faac195","evidence_sessions":["answer_d01949bf"],"is_certain":true}
|
||||
{"case_id":"faba32e5","evidence_sessions":["answer_39b12014"],"is_certain":true}
|
||||
{"case_id":"f4f1d8a4","evidence_sessions":["answer_f5b33470"],"is_certain":true}
|
||||
{"case_id":"c14c00dd","evidence_sessions":["answer_304511ce"],"is_certain":true}
|
||||
{"case_id":"36580ce8","evidence_sessions":["answer_93e1bd22"],"is_certain":true}
|
||||
{"case_id":"3d86fd0a","evidence_sessions":["answer_19c24c11"],"is_certain":true}
|
||||
{"case_id":"a82c026e","evidence_sessions":["answer_787e6a6d"],"is_certain":true}
|
||||
{"case_id":"0862e8bf_abs","evidence_sessions":["answer_c6fd8ebd_abs"],"is_certain":true}
|
||||
{"case_id":"15745da0_abs","evidence_sessions":["answer_586de428_abs"],"is_certain":true}
|
||||
{"case_id":"bc8a6e93_abs","evidence_sessions":["answer_e6143162_abs"],"is_certain":true}
|
||||
{"case_id":"19b5f2b3_abs","evidence_sessions":["answer_5ff494b9_abs"],"is_certain":true}
|
||||
{"case_id":"29f2956b_abs","evidence_sessions":["answer_7cc5362f_abs"],"is_certain":true}
|
||||
{"case_id":"f4f1d8a4_abs","evidence_sessions":["answer_f5b33470_abs"],"is_certain":true}
|
||||
{"case_id":"0a995998","evidence_sessions":["answer_afa9873b_2","answer_afa9873b_3"],"is_certain":true}
|
||||
{"case_id":"6d550036","evidence_sessions":["answer_ec904b3c_1","answer_ec904b3c_4","answer_ec904b3c_3","answer_ec904b3c_2"],"is_certain":false}
|
||||
{"case_id":"gpt4_59c863d7","evidence_sessions":["answer_593bdffd_4","answer_593bdffd_1","answer_593bdffd_3","answer_593bdffd_2"],"is_certain":true}
|
||||
{"case_id":"b5ef892d","evidence_sessions":["answer_a8b4290f_3","answer_a8b4290f_1","answer_a8b4290f_2"],"is_certain":true}
|
||||
{"case_id":"e831120c","evidence_sessions":["answer_86c505e7_1","answer_86c505e7_2"],"is_certain":true}
|
||||
{"case_id":"3a704032","evidence_sessions":["answer_c2204106_2","answer_c2204106_3","answer_c2204106_1"],"is_certain":false}
|
||||
{"case_id":"gpt4_d84a3211","evidence_sessions":["answer_2880eb6c_2","answer_2880eb6c_1"],"is_certain":true}
|
||||
{"case_id":"aae3761f","evidence_sessions":["answer_526354c8_1","answer_526354c8_3","answer_526354c8_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_f2262a51","evidence_sessions":["answer_55a6940c_3","answer_55a6940c_1","answer_55a6940c_2"],"is_certain":true}
|
||||
{"case_id":"dd2973ad","evidence_sessions":["answer_f9de4602_2","answer_f9de4602_1"],"is_certain":true}
|
||||
{"case_id":"c4a1ceb8","evidence_sessions":["answer_56d02cab_3","answer_56d02cab_4","answer_56d02cab_2","answer_56d02cab_1"],"is_certain":false}
|
||||
{"case_id":"gpt4_a56e767c","evidence_sessions":["answer_cf9e3940_2","answer_cf9e3940_1","answer_cf9e3940_3"],"is_certain":true}
|
||||
{"case_id":"6cb6f249","evidence_sessions":["answer_a4204937_2","answer_a4204937_1"],"is_certain":true}
|
||||
{"case_id":"46a3abf7","evidence_sessions":["answer_c65042d7_1","answer_c65042d7_2","answer_c65042d7_3"],"is_certain":true}
|
||||
{"case_id":"36b9f61e","evidence_sessions":["answer_ef74281f_2","answer_ef74281f_1","answer_ef74281f_3"],"is_certain":true}
|
||||
{"case_id":"28dc39ac","evidence_sessions":["answer_8d015d9d_3","answer_8d015d9d_5","answer_8d015d9d_2","answer_8d015d9d_4","answer_8d015d9d_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_2f8be40d","evidence_sessions":["answer_e7b0637e_2","answer_e7b0637e_1","answer_e7b0637e_3"],"is_certain":true}
|
||||
{"case_id":"2e6d26dc","evidence_sessions":["answer_fa526fc0_4","answer_fa526fc0_3","answer_fa526fc0_1","answer_fa526fc0_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_15e38248","evidence_sessions":["answer_8858d9dc_3","answer_8858d9dc_1","answer_8858d9dc_4","answer_8858d9dc_2"],"is_certain":true}
|
||||
{"case_id":"88432d0a","evidence_sessions":["answer_733e443a_3","answer_733e443a_4","answer_733e443a_2","answer_733e443a_1"],"is_certain":false}
|
||||
{"case_id":"80ec1f4f","evidence_sessions":["answer_990c8992_2","answer_990c8992_1","answer_990c8992_3"],"is_certain":true}
|
||||
{"case_id":"d23cf73b","evidence_sessions":["answer_5a0d28f8_4","answer_5a0d28f8_2","answer_5a0d28f8_3","answer_5a0d28f8_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_7fce9456","evidence_sessions":["answer_a679a86a_5","answer_a679a86a_4","answer_a679a86a_2","answer_a679a86a_3","answer_a679a86a_1"],"is_certain":true}
|
||||
{"case_id":"d682f1a2","evidence_sessions":["answer_c008e5df_1","answer_c008e5df_2","answer_c008e5df_3"],"is_certain":true}
|
||||
{"case_id":"7024f17c","evidence_sessions":["answer_a21f3697_1","answer_a21f3697_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_5501fe77","evidence_sessions":["answer_203bf3fa_1","answer_203bf3fa_3","answer_203bf3fa_2"],"is_certain":false}
|
||||
{"case_id":"gpt4_2ba83207","evidence_sessions":["answer_6a3b5c13_3","answer_6a3b5c13_1","answer_6a3b5c13_2","answer_6a3b5c13_4"],"is_certain":true}
|
||||
{"case_id":"2318644b","evidence_sessions":["answer_eaa8e3ef_1","answer_eaa8e3ef_2"],"is_certain":true}
|
||||
{"case_id":"2ce6a0f2","evidence_sessions":["answer_901a6763_2","answer_901a6763_4","answer_901a6763_1","answer_901a6763_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_d12ceb0e","evidence_sessions":["answer_2504635e_3","answer_2504635e_2","answer_2504635e_1"],"is_certain":true}
|
||||
{"case_id":"00ca467f","evidence_sessions":["answer_39900a0a_3","answer_39900a0a_2","answer_39900a0a_1"],"is_certain":true}
|
||||
{"case_id":"b3c15d39","evidence_sessions":["answer_05d808e6_1","answer_05d808e6_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_31ff4165","evidence_sessions":["answer_02b63d04_1","answer_02b63d04_5","answer_02b63d04_2","answer_02b63d04_3","answer_02b63d04_4"],"is_certain":true}
|
||||
{"case_id":"eeda8a6d","evidence_sessions":["answer_3e5fea0e_1","answer_3e5fea0e_2"],"is_certain":true}
|
||||
{"case_id":"2788b940","evidence_sessions":["answer_8f6b938d_1","answer_8f6b938d_3","answer_8f6b938d_2"],"is_certain":true}
|
||||
{"case_id":"60bf93ed","evidence_sessions":["answer_e0956e0a_1","answer_e0956e0a_2"],"is_certain":true}
|
||||
{"case_id":"9d25d4e0","evidence_sessions":["answer_fcff2dc4_2","answer_fcff2dc4_1","answer_fcff2dc4_3"],"is_certain":true}
|
||||
{"case_id":"129d1232","evidence_sessions":["answer_1de862d6_1","answer_1de862d6_3","answer_1de862d6_2"],"is_certain":true}
|
||||
{"case_id":"60472f9c","evidence_sessions":["answer_e7fe8c8b_1","answer_e7fe8c8b_2","answer_e7fe8c8b_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_194be4b3","evidence_sessions":["answer_3826dc55_1","answer_3826dc55_3","answer_3826dc55_5","answer_3826dc55_2","answer_3826dc55_4"],"is_certain":true}
|
||||
{"case_id":"a9f6b44c","evidence_sessions":["answer_cc021f81_2","answer_cc021f81_3","answer_cc021f81_1"],"is_certain":true}
|
||||
{"case_id":"d851d5ba","evidence_sessions":["answer_5cdf9bd2_2","answer_5cdf9bd2_1","answer_5cdf9bd2_3","answer_5cdf9bd2_4"],"is_certain":false}
|
||||
{"case_id":"5a7937c8","evidence_sessions":["answer_4cef8a3c_3","answer_4cef8a3c_1","answer_4cef8a3c_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_ab202e7f","evidence_sessions":["answer_728deb4d_5","answer_728deb4d_2","answer_728deb4d_3","answer_728deb4d_1","answer_728deb4d_4"],"is_certain":true}
|
||||
{"case_id":"gpt4_e05b82a6","evidence_sessions":["answer_6350aa4f_1","answer_6350aa4f_2","answer_6350aa4f_3","answer_6350aa4f_4"],"is_certain":true}
|
||||
{"case_id":"gpt4_731e37d7","evidence_sessions":["answer_826d51da_3","answer_826d51da_4","answer_826d51da_2","answer_826d51da_1"],"is_certain":true}
|
||||
{"case_id":"edced276","evidence_sessions":["answer_60e8941a_2","answer_60e8941a_1"],"is_certain":true}
|
||||
{"case_id":"10d9b85a","evidence_sessions":["answer_e0585cb5_2","answer_e0585cb5_1"],"is_certain":true}
|
||||
{"case_id":"e3038f8c","evidence_sessions":["answer_b6018747_2","answer_b6018747_4","answer_b6018747_1","answer_b6018747_3"],"is_certain":true}
|
||||
{"case_id":"2b8f3739","evidence_sessions":["answer_23759615_2","answer_23759615_3","answer_23759615_1"],"is_certain":true}
|
||||
{"case_id":"1a8a66a6","evidence_sessions":["answer_2bd23659_3","answer_2bd23659_2"],"is_certain":true}
|
||||
{"case_id":"c2ac3c61","evidence_sessions":["answer_923c0221_1","answer_923c0221_2"],"is_certain":true}
|
||||
{"case_id":"bf659f65","evidence_sessions":["answer_7726e7e9_1","answer_7726e7e9_3","answer_7726e7e9_2"],"is_certain":false}
|
||||
{"case_id":"gpt4_372c3eed","evidence_sessions":["answer_35c5419d_3","answer_35c5419d_2","answer_35c5419d_1"],"is_certain":false}
|
||||
{"case_id":"gpt4_2f91af09","evidence_sessions":["answer_669318cf_2","answer_669318cf_1","answer_669318cf_3"],"is_certain":true}
|
||||
{"case_id":"81507db6","evidence_sessions":["answer_da3c1266_4","answer_da3c1266_1","answer_da3c1266_5","answer_da3c1266_2"],"is_certain":true}
|
||||
{"case_id":"88432d0a_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"80ec1f4f_abs","evidence_sessions":["answer_990c8992_abs_2","answer_990c8992_abs_3","answer_990c8992_abs_1"],"is_certain":true}
|
||||
{"case_id":"eeda8a6d_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"60bf93ed_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"edced276_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_372c3eed_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"8a2466db","evidence_sessions":["answer_edb03329"],"is_certain":true}
|
||||
{"case_id":"06878be2","evidence_sessions":["answer_555dfb94"],"is_certain":true}
|
||||
{"case_id":"75832dbd","evidence_sessions":["answer_d87a6ef8"],"is_certain":true}
|
||||
{"case_id":"0edc2aef","evidence_sessions":["answer_d586e9cd"],"is_certain":true}
|
||||
{"case_id":"35a27287","evidence_sessions":["answer_9b182436"],"is_certain":true}
|
||||
{"case_id":"32260d93","evidence_sessions":["answer_0250ae1c"],"is_certain":true}
|
||||
{"case_id":"195a1a1b","evidence_sessions":["answer_6dc4305e"],"is_certain":true}
|
||||
{"case_id":"afdc33df","evidence_sessions":["answer_8549e5e0"],"is_certain":true}
|
||||
{"case_id":"caf03d32","evidence_sessions":["answer_2fc6aabb"],"is_certain":true}
|
||||
{"case_id":"54026fce","evidence_sessions":["answer_f7b22c66"],"is_certain":true}
|
||||
{"case_id":"06f04340","evidence_sessions":["answer_92d5f7cd"],"is_certain":true}
|
||||
{"case_id":"6b7dfb22","evidence_sessions":["answer_f6502d0f"],"is_certain":true}
|
||||
{"case_id":"1a1907b4","evidence_sessions":["answer_719502eb"],"is_certain":true}
|
||||
{"case_id":"09d032c9","evidence_sessions":["answer_b10dce5e"],"is_certain":true}
|
||||
{"case_id":"38146c39","evidence_sessions":["answer_772472c8"],"is_certain":true}
|
||||
{"case_id":"d24813b1","evidence_sessions":["answer_7c0ade93"],"is_certain":true}
|
||||
{"case_id":"57f827a0","evidence_sessions":["answer_1bde8d3b"],"is_certain":true}
|
||||
{"case_id":"95228167","evidence_sessions":["answer_5e613445"],"is_certain":true}
|
||||
{"case_id":"505af2f5","evidence_sessions":["answer_f3164f2c"],"is_certain":true}
|
||||
{"case_id":"75f70248","evidence_sessions":["answer_8ee04a2e"],"is_certain":true}
|
||||
{"case_id":"d6233ab6","evidence_sessions":["answer_b0fac439"],"is_certain":true}
|
||||
{"case_id":"1da05512","evidence_sessions":["answer_4d3be2ab"],"is_certain":true}
|
||||
{"case_id":"fca70973","evidence_sessions":["answer_a1e169b1"],"is_certain":true}
|
||||
{"case_id":"b6025781","evidence_sessions":["answer_8414cc57"],"is_certain":true}
|
||||
{"case_id":"a89d7624","evidence_sessions":["answer_8f15ac24"],"is_certain":true}
|
||||
{"case_id":"b0479f84","evidence_sessions":["answer_30f63ddb"],"is_certain":true}
|
||||
{"case_id":"1d4e3b97","evidence_sessions":["answer_e6b6353d"],"is_certain":true}
|
||||
{"case_id":"07b6f563","evidence_sessions":["answer_d03098f9"],"is_certain":true}
|
||||
{"case_id":"1c0ddc50","evidence_sessions":["answer_8da8c7e0"],"is_certain":true}
|
||||
{"case_id":"0a34ad58","evidence_sessions":["answer_cebb7159"],"is_certain":true}
|
||||
{"case_id":"d3ab962e","evidence_sessions":["answer_5237bb0b_2","answer_5237bb0b_1"],"is_certain":true}
|
||||
{"case_id":"2311e44b","evidence_sessions":["answer_bf633415_2","answer_bf633415_1"],"is_certain":true}
|
||||
{"case_id":"cc06de0d","evidence_sessions":["answer_4fb01417_2","answer_4fb01417_1"],"is_certain":true}
|
||||
{"case_id":"a11281a2","evidence_sessions":["answer_c69ee1f9_2","answer_c69ee1f9_1"],"is_certain":true}
|
||||
{"case_id":"4f54b7c9","evidence_sessions":["answer_50940cb7_2","answer_50940cb7_1"],"is_certain":true}
|
||||
{"case_id":"85fa3a3f","evidence_sessions":["answer_dcb18b9b_2","answer_dcb18b9b_1"],"is_certain":true}
|
||||
{"case_id":"9aaed6a3","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"1f2b8d4f","evidence_sessions":["answer_d8454588_1","answer_d8454588_2"],"is_certain":true}
|
||||
{"case_id":"e6041065","evidence_sessions":["answer_4eb6d671_2","answer_4eb6d671_1"],"is_certain":true}
|
||||
{"case_id":"51c32626","evidence_sessions":["answer_58820c75_1","answer_58820c75_2"],"is_certain":false}
|
||||
{"case_id":"d905b33f","evidence_sessions":["answer_85a77c48_2","answer_85a77c48_1"],"is_certain":true}
|
||||
{"case_id":"7405e8b1","evidence_sessions":["answer_80323f3f_1","answer_80323f3f_2"],"is_certain":true}
|
||||
{"case_id":"f35224e0","evidence_sessions":["answer_e9bb9500_2","answer_e9bb9500_1"],"is_certain":true}
|
||||
{"case_id":"6456829e","evidence_sessions":["answer_743f03a1_2","answer_743f03a1_1"],"is_certain":true}
|
||||
{"case_id":"a4996e51","evidence_sessions":["answer_feb5f98a_1","answer_feb5f98a_2"],"is_certain":true}
|
||||
{"case_id":"3c1045c8","evidence_sessions":["answer_c8cc60d6_2","answer_c8cc60d6_1"],"is_certain":true}
|
||||
{"case_id":"60036106","evidence_sessions":["answer_e552e1f9_1","answer_e552e1f9_2"],"is_certain":true}
|
||||
{"case_id":"681a1674","evidence_sessions":["answer_3be95d43_1","answer_3be95d43_2"],"is_certain":true}
|
||||
{"case_id":"e25c3b8d","evidence_sessions":["answer_6702277b_1","answer_6702277b_2"],"is_certain":true}
|
||||
{"case_id":"4adc0475","evidence_sessions":["answer_6efce493_1","answer_6efce493_2"],"is_certain":true}
|
||||
{"case_id":"4bc144e2","evidence_sessions":["answer_9ef115d4_1","answer_9ef115d4_2"],"is_certain":true}
|
||||
{"case_id":"ef66a6e5","evidence_sessions":["answer_f7fd1029_2","answer_f7fd1029_1"],"is_certain":true}
|
||||
{"case_id":"5025383b","evidence_sessions":["answer_688f9a3f_1","answer_688f9a3f_2"],"is_certain":true}
|
||||
{"case_id":"a1cc6108","evidence_sessions":["answer_17dc2f5b_2","answer_17dc2f5b_1"],"is_certain":true}
|
||||
{"case_id":"9ee3ecd6","evidence_sessions":["answer_66c23110_1"],"is_certain":true}
|
||||
{"case_id":"3fdac837","evidence_sessions":["answer_419d21d5_2","answer_419d21d5_1"],"is_certain":true}
|
||||
{"case_id":"91b15a6e","evidence_sessions":["answer_5404a208_1","answer_5404a208_2"],"is_certain":true}
|
||||
{"case_id":"27016adc","evidence_sessions":["answer_a37bdf22_2","answer_a37bdf22_1"],"is_certain":true}
|
||||
{"case_id":"720133ac","evidence_sessions":["answer_c9dfeaea_1","answer_c9dfeaea_2"],"is_certain":true}
|
||||
{"case_id":"77eafa52","evidence_sessions":["answer_33c251f0_2","answer_33c251f0_1"],"is_certain":true}
|
||||
{"case_id":"8979f9ec","evidence_sessions":["answer_35e36341_1","answer_35e36341_2"],"is_certain":true}
|
||||
{"case_id":"0100672e","evidence_sessions":["answer_35c9798c_2","answer_35c9798c_1"],"is_certain":true}
|
||||
{"case_id":"a96c20ee","evidence_sessions":["answer_ef84b994_1","answer_ef84b994_2"],"is_certain":true}
|
||||
{"case_id":"92a0aa75","evidence_sessions":["answer_6cb8f792_1","answer_6cb8f792_2"],"is_certain":true}
|
||||
{"case_id":"3fe836c9","evidence_sessions":["answer_1bb63ea5_2","answer_1bb63ea5_1"],"is_certain":true}
|
||||
{"case_id":"1c549ce4","evidence_sessions":["answer_4cb841a8_1","answer_4cb841a8_2"],"is_certain":true}
|
||||
{"case_id":"6c49646a","evidence_sessions":["answer_cc1ced21_2","answer_cc1ced21_1"],"is_certain":true}
|
||||
{"case_id":"1192316e","evidence_sessions":["answer_e184e4c3_2","answer_e184e4c3_1"],"is_certain":true}
|
||||
{"case_id":"0ea62687","evidence_sessions":["answer_dc5e537d_1","answer_dc5e537d_2"],"is_certain":true}
|
||||
{"case_id":"67e0d0f2","evidence_sessions":["answer_3a5010af_2","answer_3a5010af_1"],"is_certain":true}
|
||||
{"case_id":"bb7c3b45","evidence_sessions":["answer_de64539a_1","answer_de64539a_2"],"is_certain":true}
|
||||
{"case_id":"ba358f49","evidence_sessions":["answer_cbd08e3c_1","answer_cbd08e3c_2"],"is_certain":true}
|
||||
{"case_id":"61f8c8f8","evidence_sessions":["answer_872e8da2_2","answer_872e8da2_1"],"is_certain":true}
|
||||
{"case_id":"60159905","evidence_sessions":["answer_75eca223_2","answer_75eca223_1"],"is_certain":true}
|
||||
{"case_id":"ef9cf60a","evidence_sessions":["answer_87e3a1cb_1","answer_87e3a1cb_2"],"is_certain":true}
|
||||
{"case_id":"73d42213","evidence_sessions":["answer_1881e7db_1","answer_1881e7db_2"],"is_certain":true}
|
||||
{"case_id":"bc149d6b","evidence_sessions":["answer_92147866_1","answer_92147866_2"],"is_certain":true}
|
||||
{"case_id":"099778bb","evidence_sessions":["answer_80d6d664_2","answer_80d6d664_1"],"is_certain":true}
|
||||
{"case_id":"09ba9854","evidence_sessions":["answer_96c743d0_2","answer_96c743d0_1"],"is_certain":true}
|
||||
{"case_id":"d6062bb9","evidence_sessions":["answer_23f3a657_2","answer_23f3a657_1"],"is_certain":true}
|
||||
{"case_id":"157a136e","evidence_sessions":["answer_8de18468_2","answer_8de18468_1"],"is_certain":true}
|
||||
{"case_id":"c18a7dc8","evidence_sessions":["answer_2e2085fa_2","answer_2e2085fa_1"],"is_certain":true}
|
||||
{"case_id":"a3332713","evidence_sessions":["answer_16ece55f_2","answer_16ece55f_1"],"is_certain":true}
|
||||
{"case_id":"55241a1f","evidence_sessions":["answer_fa08bf49_1","answer_fa08bf49_2"],"is_certain":true}
|
||||
{"case_id":"a08a253f","evidence_sessions":["answer_47152166_2","answer_47152166_1"],"is_certain":true}
|
||||
{"case_id":"f0e564bc","evidence_sessions":["answer_cfcf5340_1","answer_cfcf5340_2"],"is_certain":true}
|
||||
{"case_id":"078150f1","evidence_sessions":["answer_254d8b09_1","answer_254d8b09_2"],"is_certain":true}
|
||||
{"case_id":"8cf4d046","evidence_sessions":["answer_e2278b24_1","answer_e2278b24_2"],"is_certain":true}
|
||||
{"case_id":"a346bb18","evidence_sessions":["answer_4934b2d7_2","answer_4934b2d7_1"],"is_certain":true}
|
||||
{"case_id":"37f165cf","evidence_sessions":["answer_6b9b2b1e_2","answer_6b9b2b1e_1"],"is_certain":false}
|
||||
{"case_id":"8e91e7d9","evidence_sessions":["answer_477ae455_1","answer_477ae455_2"],"is_certain":true}
|
||||
{"case_id":"87f22b4a","evidence_sessions":["answer_f56e6152_2","answer_f56e6152_1"],"is_certain":true}
|
||||
{"case_id":"e56a43b9","evidence_sessions":["answer_430d0a87_1","answer_430d0a87_2"],"is_certain":true}
|
||||
{"case_id":"efc3f7c2","evidence_sessions":["answer_fc81d1a2_1","answer_fc81d1a2_2"],"is_certain":true}
|
||||
{"case_id":"21d02d0d","evidence_sessions":["answer_2c637141_2","answer_2c637141_1"],"is_certain":true}
|
||||
{"case_id":"2311e44b_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"6456829e_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"e5ba910e_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"a96c20ee_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"ba358f49_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"09ba9854_abs","evidence_sessions":["answer_96c743d0_abs_1","answer_96c743d0_abs_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_59149c77","evidence_sessions":["answer_d00ba6d0_1","answer_d00ba6d0_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_f49edff3","evidence_sessions":["answer_3e9fce53_1","answer_3e9fce53_2","answer_3e9fce53_3"],"is_certain":true}
|
||||
{"case_id":"71017276","evidence_sessions":["answer_0b4a8adc_1"],"is_certain":true}
|
||||
{"case_id":"b46e15ed","evidence_sessions":["answer_4bfcc250_4","answer_4bfcc250_3","answer_4bfcc250_2","answer_4bfcc250_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_fa19884c","evidence_sessions":["answer_ff201786_1","answer_ff201786_2"],"is_certain":true}
|
||||
{"case_id":"0bc8ad92","evidence_sessions":["answer_f4ea84fb_3","answer_f4ea84fb_2","answer_f4ea84fb_1"],"is_certain":true}
|
||||
{"case_id":"af082822","evidence_sessions":["answer_b51b6115_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_4929293a","evidence_sessions":["answer_add9b012_2","answer_add9b012_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_b5700ca9","evidence_sessions":["answer_a17423e7_1"],"is_certain":true}
|
||||
{"case_id":"9a707b81","evidence_sessions":["answer_dba89487_2","answer_dba89487_1"],"is_certain":false}
|
||||
{"case_id":"gpt4_1d4ab0c9","evidence_sessions":["answer_febde667_1","answer_febde667_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_e072b769","evidence_sessions":["answer_c19bd2bf_1"],"is_certain":true}
|
||||
{"case_id":"0db4c65d","evidence_sessions":["answer_b9e32ff8_1","answer_b9e32ff8_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_1d80365e","evidence_sessions":["answer_661b711f_1","answer_661b711f_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_7f6b06db","evidence_sessions":["answer_5d8c99d3_1","answer_5d8c99d3_2","answer_5d8c99d3_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_6dc9b45b","evidence_sessions":["answer_c4df007f_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_8279ba02","evidence_sessions":["answer_56521e65_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_18c2b244","evidence_sessions":["answer_c862f65a_2","answer_c862f65a_3","answer_c862f65a_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_a1b77f9c","evidence_sessions":["answer_e9ad5914_1","answer_e9ad5914_2","answer_e9ad5914_3","answer_e9ad5914_4","answer_e9ad5914_5","answer_e9ad5914_6"],"is_certain":false}
|
||||
{"case_id":"gpt4_1916e0ea","evidence_sessions":["answer_447052a5_2","answer_447052a5_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_7a0daae1","evidence_sessions":["answer_4d5490f1_1","answer_4d5490f1_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_468eb063","evidence_sessions":["answer_9b09d95b_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_7abb270c","evidence_sessions":["answer_7093d898_1","answer_7093d898_2","answer_7093d898_3","answer_7093d898_4","answer_7093d898_5","answer_7093d898_6"],"is_certain":false}
|
||||
{"case_id":"gpt4_1e4a8aeb","evidence_sessions":["answer_16bd5ea5_1","answer_16bd5ea5_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_4fc4f797","evidence_sessions":["answer_be07688f_1","answer_be07688f_2"],"is_certain":true}
|
||||
{"case_id":"4dfccbf7","evidence_sessions":["answer_4bebc782_1","answer_4bebc782_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_61e13b3c","evidence_sessions":["answer_e831a29f_1","answer_e831a29f_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_45189cb4","evidence_sessions":["answer_e6c20e52_3","answer_e6c20e52_2","answer_e6c20e52_1"],"is_certain":true}
|
||||
{"case_id":"2ebe6c90","evidence_sessions":["answer_c9d35c00_1","answer_c9d35c00_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_e061b84f","evidence_sessions":["answer_8c64ce25_2","answer_8c64ce25_1","answer_8c64ce25_3"],"is_certain":true}
|
||||
{"case_id":"370a8ff4","evidence_sessions":["answer_61d1be50_1","answer_61d1be50_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_d6585ce8","evidence_sessions":["answer_f999b05b_1","answer_f999b05b_4","answer_f999b05b_2","answer_f999b05b_5","answer_f999b05b_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_4ef30696","evidence_sessions":["answer_f964cea3_1","answer_f964cea3_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_ec93e27f","evidence_sessions":["answer_e3aa84c4_2","answer_e3aa84c4_1"],"is_certain":true}
|
||||
{"case_id":"6e984301","evidence_sessions":["answer_88841f26_1","answer_88841f26_2"],"is_certain":true}
|
||||
{"case_id":"8077ef71","evidence_sessions":["answer_0dd54b7c_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_f420262c","evidence_sessions":["answer_d8a1af6b_1","answer_d8a1af6b_2","answer_d8a1af6b_3","answer_d8a1af6b_4","answer_d8a1af6b_5"],"is_certain":true}
|
||||
{"case_id":"gpt4_8e165409","evidence_sessions":["answer_d97db962_1","answer_d97db962_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_74aed68e","evidence_sessions":["answer_aed8cf17_1","answer_aed8cf17_2"],"is_certain":true}
|
||||
{"case_id":"bcbe585f","evidence_sessions":["answer_43ba3965_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_21adecb5","evidence_sessions":["answer_1e2369c9_1","answer_1e2369c9_2"],"is_certain":true}
|
||||
{"case_id":"5e1b23de","evidence_sessions":["answer_c18d480b_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_98f46fc6","evidence_sessions":["answer_5850de18_2","answer_5850de18_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_af6db32f","evidence_sessions":["answer_184c8f56_1"],"is_certain":true}
|
||||
{"case_id":"eac54adc","evidence_sessions":["answer_0d4d0347_1","answer_0d4d0347_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_7ddcf75f","evidence_sessions":["answer_ad8a76cb_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_a2d1d1f6","evidence_sessions":["answer_f6d6e33f_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_85da3956","evidence_sessions":["answer_581ab834_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_b0863698","evidence_sessions":["answer_550bb2d1_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_68e94287","evidence_sessions":["answer_9793daa3_2","answer_9793daa3_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_e414231e","evidence_sessions":["answer_e28c1f0d_1","answer_e28c1f0d_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_7ca326fa","evidence_sessions":["answer_cf021b36_1","answer_cf021b36_2","answer_cf021b36_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_7bc6cf22","evidence_sessions":["answer_e22d6aef_1"],"is_certain":true}
|
||||
{"case_id":"2ebe6c92","evidence_sessions":["answer_c9d35c00_1","answer_c9d35c00_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_e061b84g","evidence_sessions":["answer_8c64ce26_2","answer_8c64ce26_1","answer_8c64ce26_3"],"is_certain":true}
|
||||
{"case_id":"71017277","evidence_sessions":["answer_0b4a8adc_1"],"is_certain":true}
|
||||
{"case_id":"b46e15ee","evidence_sessions":["answer_4bfcc251_4","answer_4bfcc251_3","answer_4bfcc251_2","answer_4bfcc251_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_d6585ce9","evidence_sessions":["answer_f999b05c_1","answer_f999b05c_4","answer_f999b05c_2","answer_f999b05c_5","answer_f999b05c_3"],"is_certain":true}
|
||||
{"case_id":"gpt4_1e4a8aec","evidence_sessions":["answer_16bd5ea6_1","answer_16bd5ea6_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_f420262d","evidence_sessions":["answer_d8a1af6c_1","answer_d8a1af6c_2","answer_d8a1af6c_5","answer_d8a1af6c_4","answer_d8a1af6c_3"],"is_certain":false}
|
||||
{"case_id":"gpt4_59149c78","evidence_sessions":["answer_d00ba6d1_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_e414231f","evidence_sessions":["answer_e28c1f0e_1","answer_e28c1f0e_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_4929293b","evidence_sessions":["answer_add9b013_2","answer_add9b013_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_468eb064","evidence_sessions":["answer_9b09d95b_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_fa19884d","evidence_sessions":["answer_ff201787_1","answer_ff201787_2"],"is_certain":true}
|
||||
{"case_id":"9a707b82","evidence_sessions":["answer_dba89488_2","answer_dba89488_1"],"is_certain":true}
|
||||
{"case_id":"eac54add","evidence_sessions":["answer_0d4d0348_1","answer_0d4d0348_2"],"is_certain":true}
|
||||
{"case_id":"4dfccbf8","evidence_sessions":["answer_4bebc783_1","answer_4bebc783_2"],"is_certain":true}
|
||||
{"case_id":"0bc8ad93","evidence_sessions":["answer_f4ea84fc_3","answer_f4ea84fc_2","answer_f4ea84fc_1"],"is_certain":false}
|
||||
{"case_id":"6e984302","evidence_sessions":["answer_88841f27_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_8279ba03","evidence_sessions":["answer_56521e66_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_b5700ca0","evidence_sessions":["answer_a17423e8_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_68e94288","evidence_sessions":["answer_9793daa4_2","answer_9793daa4_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_2655b836","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_2487a7cb","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_76048e76","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_2312f94c","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"0bb5a684","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"08f4fc43","evidence_sessions":["answer_6ea1541e_2","answer_6ea1541e_1"],"is_certain":true}
|
||||
{"case_id":"2c63a862","evidence_sessions":["answer_d39b7977_1","answer_d39b7977_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_385a5000","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"2a1811e2","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"bbf86515","evidence_sessions":["answer_b3763b6b_1","answer_b3763b6b_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_5dcc0aab","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_0b2f1d21","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"f0853d11","evidence_sessions":["answer_932ea3bf_2","answer_932ea3bf_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_6ed717ea","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_70e84552","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"a3838d2b","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_93159ced","evidence_sessions":["answer_e5131a1b_2","answer_e5131a1b_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_2d58bcd6","evidence_sessions":["answer_3e11e0ae_1","answer_3e11e0ae_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_65aabe59","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"982b5123","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"b9cfe692","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_4edbafa2","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"c8090214","evidence_sessions":["answer_70dc7d08_2","answer_70dc7d08_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_483dd43c","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"e4e14d04","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"c9f37c46","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_2c50253f","evidence_sessions":["answer_9af4e346_1","answer_9af4e346_2"],"is_certain":true}
|
||||
{"case_id":"dcfa8644","evidence_sessions":["answer_5e3eeb12_2","answer_5e3eeb12_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_b4a80587","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_9a159967","evidence_sessions":["answer_8a42fedf_2"],"is_certain":true}
|
||||
{"case_id":"cc6d1ec1","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_8c8961ae","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_d9af6064","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_7de946e7","evidence_sessions":["answer_6a78e959_2","answer_6a78e959_1"],"is_certain":true}
|
||||
{"case_id":"d01c6aa8","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"993da5e2","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"a3045048","evidence_sessions":["answer_016f6bd4_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_d31cdae3","evidence_sessions":["answer_8464304d_1","answer_8464304d_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_cd90e484","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_88806d6e","evidence_sessions":["answer_e60a93ff_1","answer_e60a93ff_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_4cd9eba1","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_93f6379c","evidence_sessions":["answer_544fe66c_2","answer_544fe66c_3"],"is_certain":true}
|
||||
{"case_id":"b29f3365","evidence_sessions":["answer_436d4309_2","answer_436d4309_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_2f56ae70","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"6613b389","evidence_sessions":["answer_aaf71ce2_2","answer_aaf71ce2_1"],"is_certain":true}
|
||||
{"case_id":"gpt4_78cf46a3","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_0a05b494","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_1a1dc16d","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_2f584639","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_213fd887","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_5438fa52","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_c27434e8","evidence_sessions":["answer_d8e33f5c_1","answer_d8e33f5c_2"],"is_certain":true}
|
||||
{"case_id":"gpt4_fe651585","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"8c18457d","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_70e84552_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_93159ced_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"982b5123_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"c8090214_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_c27434e8_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"gpt4_fe651585_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"6a1eabeb","evidence_sessions":["answer_a25d4a91_1","answer_a25d4a91_2"],"is_certain":true}
|
||||
{"case_id":"6aeb4375","evidence_sessions":["answer_3f9693b7_1","answer_3f9693b7_2"],"is_certain":true}
|
||||
{"case_id":"830ce83f","evidence_sessions":["answer_0b1a0942_2"],"is_certain":true}
|
||||
{"case_id":"852ce960","evidence_sessions":["answer_3a6f1e82_2"],"is_certain":true}
|
||||
{"case_id":"945e3d21","evidence_sessions":["answer_6a4f8626_1","answer_6a4f8626_2"],"is_certain":true}
|
||||
{"case_id":"d7c942c3","evidence_sessions":["answer_eecb10d9_1","answer_eecb10d9_2"],"is_certain":true}
|
||||
{"case_id":"71315a70","evidence_sessions":["answer_c44b9df4_1","answer_c44b9df4_2"],"is_certain":true}
|
||||
{"case_id":"89941a93","evidence_sessions":["answer_e1403127_1","answer_e1403127_2"],"is_certain":true}
|
||||
{"case_id":"ce6d2d27","evidence_sessions":["answer_73540165_2"],"is_certain":true}
|
||||
{"case_id":"9ea5eabc","evidence_sessions":["answer_02e66dec_1","answer_02e66dec_2"],"is_certain":true}
|
||||
{"case_id":"07741c44","evidence_sessions":["answer_7e9ad7b4_1","answer_7e9ad7b4_2"],"is_certain":true}
|
||||
{"case_id":"a1eacc2a","evidence_sessions":["answer_0eb23770_1","answer_0eb23770_2"],"is_certain":true}
|
||||
{"case_id":"184da446","evidence_sessions":["answer_e2f4f947_1","answer_e2f4f947_2"],"is_certain":true}
|
||||
{"case_id":"031748ae","evidence_sessions":["answer_8748f791_1","answer_8748f791_2"],"is_certain":true}
|
||||
{"case_id":"4d6b87c8","evidence_sessions":["answer_766ab8da_1","answer_766ab8da_2"],"is_certain":true}
|
||||
{"case_id":"0f05491a","evidence_sessions":["answer_d6d2eba8_2"],"is_certain":true}
|
||||
{"case_id":"08e075c7","evidence_sessions":["answer_cdbe2250_1","answer_cdbe2250_2"],"is_certain":true}
|
||||
{"case_id":"f9e8c073","evidence_sessions":["answer_b191df5b_1","answer_b191df5b_2"],"is_certain":true}
|
||||
{"case_id":"41698283","evidence_sessions":["answer_c7ddc051_1","answer_c7ddc051_2"],"is_certain":true}
|
||||
{"case_id":"2698e78f","evidence_sessions":["answer_9282283d_1","answer_9282283d_2"],"is_certain":true}
|
||||
{"case_id":"b6019101","evidence_sessions":["answer_67074b4b_1","answer_67074b4b_2"],"is_certain":true}
|
||||
{"case_id":"45dc21b6","evidence_sessions":["answer_07664d43_1","answer_07664d43_2"],"is_certain":true}
|
||||
{"case_id":"5a4f22c0","evidence_sessions":["answer_b0f3dfff_1","answer_b0f3dfff_2"],"is_certain":true}
|
||||
{"case_id":"6071bd76","evidence_sessions":["answer_4dac77cb_1","answer_4dac77cb_2"],"is_certain":true}
|
||||
{"case_id":"e493bb7c","evidence_sessions":["answer_1a374afa_1","answer_1a374afa_2"],"is_certain":true}
|
||||
{"case_id":"618f13b2","evidence_sessions":["answer_caf5b52e_1","answer_caf5b52e_2"],"is_certain":true}
|
||||
{"case_id":"72e3ee87","evidence_sessions":["answer_d7de9a6a_1","answer_d7de9a6a_2"],"is_certain":true}
|
||||
{"case_id":"c4ea545c","evidence_sessions":["answer_d3bf812b_1","answer_d3bf812b_2"],"is_certain":true}
|
||||
{"case_id":"01493427","evidence_sessions":["answer_a7b44747_1","answer_a7b44747_2"],"is_certain":true}
|
||||
{"case_id":"6a27ffc2","evidence_sessions":["answer_77f32504_1","answer_77f32504_2"],"is_certain":true}
|
||||
{"case_id":"2133c1b5","evidence_sessions":["answer_52382508_1"],"is_certain":true}
|
||||
{"case_id":"18bc8abd","evidence_sessions":["answer_fff743f5_1","answer_fff743f5_2"],"is_certain":true}
|
||||
{"case_id":"db467c8c","evidence_sessions":["answer_611b6e83_1","answer_611b6e83_2"],"is_certain":true}
|
||||
{"case_id":"7a87bd0c","evidence_sessions":["answer_d08a934d_1","answer_d08a934d_2"],"is_certain":true}
|
||||
{"case_id":"e61a7584","evidence_sessions":["answer_f25c32f5_1","answer_f25c32f5_2"],"is_certain":true}
|
||||
{"case_id":"1cea1afa","evidence_sessions":["answer_79c395a9_1","answer_79c395a9_2"],"is_certain":true}
|
||||
{"case_id":"ed4ddc30","evidence_sessions":["answer_babbaccb_1","answer_babbaccb_2"],"is_certain":true}
|
||||
{"case_id":"8fb83627","evidence_sessions":["answer_966cecbb_1","answer_966cecbb_2"],"is_certain":true}
|
||||
{"case_id":"b01defab","evidence_sessions":["answer_8c0712af_1","answer_8c0712af_2"],"is_certain":true}
|
||||
{"case_id":"22d2cb42","evidence_sessions":["answer_bcce0b73_1","answer_bcce0b73_2"],"is_certain":true}
|
||||
{"case_id":"0e4e4c46","evidence_sessions":["answer_f2f998c7_1","answer_f2f998c7_2"],"is_certain":true}
|
||||
{"case_id":"4b24c848","evidence_sessions":["answer_2cec623b_1","answer_2cec623b_2"],"is_certain":true}
|
||||
{"case_id":"7e974930","evidence_sessions":["answer_c9f5693c_1","answer_c9f5693c_2"],"is_certain":true}
|
||||
{"case_id":"603deb26","evidence_sessions":["answer_8afdebac_1","answer_8afdebac_2"],"is_certain":true}
|
||||
{"case_id":"59524333","evidence_sessions":["answer_b28f2c7a_2"],"is_certain":true}
|
||||
{"case_id":"5831f84d","evidence_sessions":["answer_8d63a897_1","answer_8d63a897_2"],"is_certain":true}
|
||||
{"case_id":"eace081b","evidence_sessions":["answer_8a791264_2"],"is_certain":true}
|
||||
{"case_id":"affe2881","evidence_sessions":["answer_90de9b4d_1","answer_90de9b4d_2"],"is_certain":true}
|
||||
{"case_id":"50635ada","evidence_sessions":["answer_dcd74827_1","answer_dcd74827_2"],"is_certain":true}
|
||||
{"case_id":"e66b632c","evidence_sessions":["answer_ac0140ce_1","answer_ac0140ce_2"],"is_certain":true}
|
||||
{"case_id":"0ddfec37","evidence_sessions":["answer_a22b654d_1","answer_a22b654d_2"],"is_certain":true}
|
||||
{"case_id":"f685340e","evidence_sessions":["answer_25df025b_1","answer_25df025b_2"],"is_certain":true}
|
||||
{"case_id":"cc5ded98","evidence_sessions":["answer_a5b68517_1","answer_a5b68517_2"],"is_certain":true}
|
||||
{"case_id":"dfde3500","evidence_sessions":["answer_35d6c0be_1","answer_35d6c0be_2"],"is_certain":true}
|
||||
{"case_id":"69fee5aa","evidence_sessions":["answer_d6028d6e_1","answer_d6028d6e_2"],"is_certain":true}
|
||||
{"case_id":"7401057b","evidence_sessions":["answer_94650bfa_1","answer_94650bfa_2"],"is_certain":true}
|
||||
{"case_id":"cf22b7bf","evidence_sessions":["answer_ae3a122b_1","answer_ae3a122b_2"],"is_certain":true}
|
||||
{"case_id":"a2f3aa27","evidence_sessions":["answer_5126c02d_1","answer_5126c02d_2"],"is_certain":true}
|
||||
{"case_id":"c7dc5443","evidence_sessions":["answer_0cdbca92_1","answer_0cdbca92_2"],"is_certain":true}
|
||||
{"case_id":"06db6396","evidence_sessions":["answer_da72b1b4_1","answer_da72b1b4_2"],"is_certain":true}
|
||||
{"case_id":"3ba21379","evidence_sessions":["answer_cd345582_1","answer_cd345582_2"],"is_certain":true}
|
||||
{"case_id":"9bbe84a2","evidence_sessions":["answer_c6a0c6c2_1","answer_c6a0c6c2_2"],"is_certain":true}
|
||||
{"case_id":"10e09553","evidence_sessions":["answer_67be2c38_1","answer_67be2c38_2"],"is_certain":true}
|
||||
{"case_id":"dad224aa","evidence_sessions":["answer_4a97ae40_2"],"is_certain":true}
|
||||
{"case_id":"ba61f0b9","evidence_sessions":["answer_f377cda7_2"],"is_certain":true}
|
||||
{"case_id":"42ec0761","evidence_sessions":["answer_e3892371_1","answer_e3892371_2"],"is_certain":true}
|
||||
{"case_id":"5c40ec5b","evidence_sessions":["answer_1cb52d0a_1","answer_1cb52d0a_2"],"is_certain":true}
|
||||
{"case_id":"c6853660","evidence_sessions":["answer_626e93c4_1","answer_626e93c4_2"],"is_certain":true}
|
||||
{"case_id":"26bdc477","evidence_sessions":["answer_f762ad8d_1","answer_f762ad8d_2"],"is_certain":true}
|
||||
{"case_id":"0977f2af","evidence_sessions":["answer_3bf5b73b_1","answer_3bf5b73b_2"],"is_certain":true}
|
||||
{"case_id":"6aeb4375_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"031748ae_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"2698e78f_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"2133c1b5_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"0ddfec37_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"f685340e_abs","evidence_sessions":[],"is_certain":true}
|
||||
{"case_id":"89941a94","evidence_sessions":["answer_e1403127_1","answer_e1403127_2"],"is_certain":true}
|
||||
{"case_id":"07741c45","evidence_sessions":["answer_7e9ad7b4_1","answer_7e9ad7b4_2"],"is_certain":true}
|
||||
{"case_id":"7161e7e2","evidence_sessions":["answer_sharegpt_5Lzox6N_0"],"is_certain":true}
|
||||
{"case_id":"c4f10528","evidence_sessions":["answer_ultrachat_234453"],"is_certain":true}
|
||||
{"case_id":"89527b6b","evidence_sessions":["answer_sharegpt_YkWn1Ne_0"],"is_certain":true}
|
||||
{"case_id":"e9327a54","evidence_sessions":["answer_ultrachat_480665"],"is_certain":true}
|
||||
{"case_id":"4c36ccef","evidence_sessions":["answer_ultrachat_448704"],"is_certain":true}
|
||||
{"case_id":"6ae235be","evidence_sessions":["answer_sharegpt_IUWQYGQ_0"],"is_certain":true}
|
||||
{"case_id":"7e00a6cb","evidence_sessions":["answer_ultrachat_370515"],"is_certain":true}
|
||||
{"case_id":"1903aded","evidence_sessions":["answer_sharegpt_hA7AkP3_0"],"is_certain":true}
|
||||
{"case_id":"ceb54acb","evidence_sessions":["answer_sharegpt_cGdjmYo_0"],"is_certain":true}
|
||||
{"case_id":"f523d9fe","evidence_sessions":["answer_sharegpt_m2xJfjo_0"],"is_certain":true}
|
||||
{"case_id":"0e5e2d1a","evidence_sessions":["answer_ultrachat_113156"],"is_certain":true}
|
||||
{"case_id":"fea54f57","evidence_sessions":["answer_ultrachat_187684"],"is_certain":true}
|
||||
{"case_id":"cc539528","evidence_sessions":["answer_ultrachat_374124"],"is_certain":true}
|
||||
{"case_id":"dc439ea3","evidence_sessions":["answer_ultrachat_459954"],"is_certain":true}
|
||||
{"case_id":"18dcd5a5","evidence_sessions":["answer_sharegpt_hn3IS1q_0"],"is_certain":true}
|
||||
{"case_id":"488d3006","evidence_sessions":["answer_ultrachat_275993"],"is_certain":true}
|
||||
{"case_id":"58470ed2","evidence_sessions":["answer_sharegpt_U4oCSfU_7"],"is_certain":true}
|
||||
{"case_id":"8cf51dda","evidence_sessions":["answer_sharegpt_HFMn2ZX_0"],"is_certain":true}
|
||||
{"case_id":"1d4da289","evidence_sessions":["answer_ultrachat_348449"],"is_certain":true}
|
||||
{"case_id":"8464fc84","evidence_sessions":["answer_ultrachat_467053"],"is_certain":true}
|
||||
{"case_id":"8aef76bc","evidence_sessions":["answer_ultrachat_563222"],"is_certain":true}
|
||||
{"case_id":"71a3fd6b","evidence_sessions":["answer_ultrachat_417348"],"is_certain":true}
|
||||
{"case_id":"2bf43736","evidence_sessions":["answer_sharegpt_2kpncbX_13"],"is_certain":true}
|
||||
{"case_id":"70b3e69b","evidence_sessions":["answer_ultrachat_334948"],"is_certain":true}
|
||||
{"case_id":"8752c811","evidence_sessions":["answer_sharegpt_6pWK9yx_0"],"is_certain":true}
|
||||
{"case_id":"3249768e","evidence_sessions":["answer_sharegpt_CaxTGYP_0"],"is_certain":true}
|
||||
{"case_id":"1b9b7252","evidence_sessions":["answer_ultrachat_115151"],"is_certain":true}
|
||||
{"case_id":"1568498a","evidence_sessions":["answer_sharegpt_d6JJiqH_76"],"is_certain":true}
|
||||
{"case_id":"6222b6eb","evidence_sessions":["answer_sharegpt_H9PiM5G_0"],"is_certain":true}
|
||||
{"case_id":"e8a79c70","evidence_sessions":["answer_ultrachat_13075"],"is_certain":true}
|
||||
{"case_id":"d596882b","evidence_sessions":["answer_ultrachat_252214"],"is_certain":true}
|
||||
{"case_id":"e3fc4d6e","evidence_sessions":["answer_sharegpt_5m7gg5F_0"],"is_certain":true}
|
||||
{"case_id":"51b23612","evidence_sessions":["answer_ultrachat_427265"],"is_certain":true}
|
||||
{"case_id":"3e321797","evidence_sessions":["answer_ultrachat_94624"],"is_certain":true}
|
||||
{"case_id":"e982271f","evidence_sessions":["answer_ultrachat_195444"],"is_certain":true}
|
||||
{"case_id":"352ab8bd","evidence_sessions":["answer_sharegpt_NoDZzot_7"],"is_certain":true}
|
||||
{"case_id":"fca762bc","evidence_sessions":["answer_ultrachat_39395"],"is_certain":true}
|
||||
{"case_id":"7a8d0b71","evidence_sessions":["answer_sharegpt_i0tMT9q_9"],"is_certain":true}
|
||||
{"case_id":"a40e080f","evidence_sessions":["answer_ultrachat_269020"],"is_certain":true}
|
||||
{"case_id":"8b9d4367","evidence_sessions":["answer_ultrachat_289157"],"is_certain":true}
|
||||
{"case_id":"5809eb10","evidence_sessions":["answer_sharegpt_4aJsGCH_0"],"is_certain":true}
|
||||
{"case_id":"41275add","evidence_sessions":["answer_sharegpt_81riySf_0"],"is_certain":true}
|
||||
{"case_id":"4388e9dd","evidence_sessions":["answer_sharegpt_qTi81nS_0"],"is_certain":true}
|
||||
{"case_id":"4baee567","evidence_sessions":["answer_sharegpt_i9adwQn_0"],"is_certain":true}
|
||||
{"case_id":"561fabcd","evidence_sessions":["answer_sharegpt_hChsWOp_97"],"is_certain":true}
|
||||
{"case_id":"b759caee","evidence_sessions":["answer_sharegpt_2BSXlAr_0"],"is_certain":true}
|
||||
{"case_id":"ac031881","evidence_sessions":["answer_sharegpt_GYqnAhC_190"],"is_certain":true}
|
||||
{"case_id":"28bcfaac","evidence_sessions":["answer_ultrachat_446979"],"is_certain":true}
|
||||
{"case_id":"16c90bf4","evidence_sessions":["answer_ultrachat_294807"],"is_certain":true}
|
||||
{"case_id":"c8f1aeed","evidence_sessions":["answer_ultrachat_519486"],"is_certain":true}
|
||||
{"case_id":"eaca4986","evidence_sessions":["answer_sharegpt_SS141vi_0"],"is_certain":true}
|
||||
{"case_id":"c7cf7dfd","evidence_sessions":["answer_ultrachat_456407"],"is_certain":true}
|
||||
{"case_id":"e48988bc","evidence_sessions":["answer_ultrachat_174360"],"is_certain":true}
|
||||
{"case_id":"1de5cff2","evidence_sessions":["answer_ultrachat_440262"],"is_certain":true}
|
||||
{"case_id":"65240037","evidence_sessions":["answer_ultrachat_403752"],"is_certain":true}
|
||||
{"case_id":"778164c6","evidence_sessions":["answer_ultrachat_399000"],"is_certain":true}
|
||||
173
benchmark/longmemeval/run_home.py
Normal file
173
benchmark/longmemeval/run_home.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Prepare the LongMemEval Home workspace, or optimize it with --optimize after preparation."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
import yaml
|
||||
|
||||
# Support direct execution from any working directory as well as module imports.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from reme_home.preparation.spec import PreparationSpec # noqa: E402
|
||||
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
|
||||
from reme_home.sampling import SamplingPolicy # noqa: E402
|
||||
|
||||
from reme.application import Application # noqa: E402
|
||||
from reme.utils.env_utils import load_env # noqa: E402
|
||||
|
||||
# pylint: enable=wrong-import-position
|
||||
|
||||
DEFAULT_CONFIG = Path(__file__).with_name("config_home.yaml")
|
||||
|
||||
|
||||
class RunHomeConfig(BaseModel):
|
||||
"""Runner settings kept separate from Home's public preparation contract."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
workspace_dir: str = Field(min_length=1)
|
||||
preparation: dict
|
||||
sampling_policy: SamplingPolicy = Field(default_factory=SamplingPolicy)
|
||||
validation_cycle_time_budget_minutes: float | None = Field(default=150, gt=0, allow_inf_nan=False)
|
||||
max_evaluations: int = Field(default=10, ge=1)
|
||||
memory_limit_mib: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
def project_path(value: str, project_root: Path) -> Path:
|
||||
"""Use repository-relative paths consistently regardless of the caller's cwd."""
|
||||
if value.startswith("~"):
|
||||
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
|
||||
return (project_root / value).resolve()
|
||||
|
||||
|
||||
def _load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
|
||||
"""Resolve source paths and validate explicit Home preparation parameters."""
|
||||
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
||||
config = RunHomeConfig.model_validate(raw)
|
||||
config.preparation.setdefault("candidate", {"paths": ["plugins/lme/src"]})
|
||||
spec = PreparationSpec.model_validate(config.preparation)
|
||||
spec.benchmark.source = str(project_path(spec.benchmark.source, project_root))
|
||||
if spec.benchmark.evidence_source is not None:
|
||||
spec.benchmark.evidence_source = str(project_path(spec.benchmark.evidence_source, project_root))
|
||||
spec.runtime.source_repo = str(project_path(spec.runtime.source_repo, project_root))
|
||||
workspace = project_path(workspace_dir if workspace_dir is not None else config.workspace_dir, project_root)
|
||||
return workspace, spec.model_dump(mode="json"), config
|
||||
|
||||
|
||||
def load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
|
||||
"""Return resolved preparation inputs, preserving the preparation-only caller contract."""
|
||||
workspace, spec, _ = _load_config(config_path, workspace_dir=workspace_dir, project_root=project_root)
|
||||
return workspace, spec
|
||||
|
||||
|
||||
async def _run(config_path, *, workspace_dir=None, optimize_workspace=False, instruction=None):
|
||||
"""Apply the same runner configuration to each preparation or optimization lifecycle."""
|
||||
workspace, spec, config = _load_config(config_path, workspace_dir=workspace_dir)
|
||||
load_env(PROJECT_ROOT / ".env", override=False)
|
||||
memory_options = {}
|
||||
jobs = {}
|
||||
if config.memory_limit_mib is not None:
|
||||
memory_options = {
|
||||
"candidate_gate_memory_limit_mib": config.memory_limit_mib,
|
||||
"construction_equivalence_memory_limit_mib": config.memory_limit_mib,
|
||||
}
|
||||
for name, backend, wrapper in (
|
||||
("home_optimize", "home_optimize_step", "home_optimizer"),
|
||||
("home_diagnose", "home_diagnose_step", "home_diagnostic"),
|
||||
("home_case_study", "home_case_study_step", "home_case_study"),
|
||||
):
|
||||
jobs[name] = {
|
||||
"steps": [
|
||||
{
|
||||
"backend": backend,
|
||||
"agent_wrapper": wrapper,
|
||||
"memory_limit_mib": config.memory_limit_mib,
|
||||
**({"concurrency": 4, "output_token_limit": 2000} if name == "home_case_study" else {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
app = Application(
|
||||
workspace_dir=str(workspace),
|
||||
plugins=["home", JUDGE_PLUGINS[spec["benchmark"]["name"]]],
|
||||
components={
|
||||
"home.evaluation": {
|
||||
"default": {"sampling_policy": config.sampling_policy.model_dump(mode="json"), **memory_options},
|
||||
},
|
||||
"agent_wrapper": {
|
||||
"home_optimizer": {
|
||||
"validation_cycle_time_budget_minutes": config.validation_cycle_time_budget_minutes,
|
||||
"max_evaluations": config.max_evaluations,
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs=jobs,
|
||||
service={"backend": "cli"},
|
||||
enable_logo=False,
|
||||
log_to_file=False,
|
||||
)
|
||||
try:
|
||||
await app.start()
|
||||
if optimize_workspace:
|
||||
arguments = {} if instruction is None else {"instruction": instruction}
|
||||
response = await app.run_job("home_optimize", **arguments)
|
||||
else:
|
||||
response = await app.run_job("home_prepare", spec=spec)
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
return {"workspace_dir": str(workspace), **response.answer}
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
|
||||
async def prepare(config_path: str | Path, *, workspace_dir: str | None = None) -> dict:
|
||||
"""Prepare once without running evaluations or optimization."""
|
||||
return await _run(config_path, workspace_dir=workspace_dir)
|
||||
|
||||
|
||||
async def optimize(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir=None, instruction=None) -> dict:
|
||||
"""Optimize an already prepared workspace using this config's actual model budgets."""
|
||||
return await _run(config_path, workspace_dir=workspace_dir, optimize_workspace=True, instruction=instruction)
|
||||
|
||||
|
||||
async def run(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir: str | None = None) -> dict:
|
||||
"""Prepare through the same Home Job as the CLI, then close the application."""
|
||||
return await prepare(config_path, workspace_dir=workspace_dir)
|
||||
|
||||
|
||||
def main(default_config: Path = DEFAULT_CONFIG) -> None:
|
||||
"""Run preparation or explicit local one-shot optimization with the benchmark config."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=default_config, help="Home runner YAML configuration")
|
||||
parser.add_argument("--workspace-dir", help="Override workspace directory (absolute or project-relative)")
|
||||
parser.add_argument("--optimize", action="store_true", help="Optimize an already prepared workspace")
|
||||
parser.add_argument("--instruction", help="Optimization focus; requires --optimize")
|
||||
args = parser.parse_args()
|
||||
if args.instruction is not None and not args.optimize:
|
||||
parser.error("--instruction requires --optimize")
|
||||
if args.optimize:
|
||||
result = asyncio.run(optimize(args.config, workspace_dir=args.workspace_dir, instruction=args.instruction))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return
|
||||
result = asyncio.run(prepare(args.config, workspace_dir=args.workspace_dir))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"workspace_dir": result["workspace_dir"],
|
||||
"case_count": result["dataset"]["case_count"],
|
||||
"query_count": result["dataset"]["query_count"],
|
||||
"source_commit": result["code"]["source_commit"],
|
||||
"initial_commit": result["code"]["initial_commit"],
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
226
benchmark/longmemeval/run_home_eval.py
Normal file
226
benchmark/longmemeval/run_home_eval.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""Evaluate a LongMemEval Home candidate in an independent test workspace."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
import yaml
|
||||
|
||||
# Support direct execution from any working directory as well as module imports.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from reme_home.code.repository import resolve_revision, validate_repository # noqa: E402
|
||||
from reme_home.evaluation.artifacts import contained, read_json # noqa: E402
|
||||
from reme_home.evaluation.component import blocking # noqa: E402
|
||||
from reme_home.evaluation.inputs import EvaluationSpec, load_inputs # noqa: E402
|
||||
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
|
||||
from reme_home.preparation.code import tree_fingerprint # noqa: E402
|
||||
from reme_home.preparation.data.models import fingerprint # noqa: E402
|
||||
from reme_home.preparation.evaluation_source import EvaluationSource # noqa: E402
|
||||
from reme_home.preparation.spec import BenchmarkSpec, DebugSpec, SpecModel # noqa: E402
|
||||
|
||||
from reme.application import Application # noqa: E402
|
||||
from reme.utils.env_utils import load_env # noqa: E402
|
||||
|
||||
# pylint: enable=wrong-import-position
|
||||
|
||||
DEFAULT_CONFIG = Path(__file__).with_name("config_home_eval.yaml")
|
||||
|
||||
|
||||
class TestRunConfig(SpecModel):
|
||||
"""Explicit dataset selection and execution options for one pinned candidate."""
|
||||
|
||||
source_workspace: str = Field(min_length=1)
|
||||
test_workspace: str = Field(min_length=1)
|
||||
code_commit: str = Field(min_length=4)
|
||||
benchmark: BenchmarkSpec
|
||||
exclude_training_cases: bool = False
|
||||
evaluation: EvaluationSpec = Field(default_factory=lambda: EvaluationSpec(record_history=False))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_test_mode(self):
|
||||
"""Test runs score every prepared query and never consume optimizer budgets."""
|
||||
options = self.evaluation
|
||||
if any(
|
||||
(
|
||||
options.debug,
|
||||
options.sampling,
|
||||
options.record_history,
|
||||
options.max_evaluations is not None,
|
||||
options.sampling_policy is not None,
|
||||
options.case_ids is not None,
|
||||
options.query_ids is not None,
|
||||
options.reuse_construction_from is not None,
|
||||
),
|
||||
):
|
||||
raise ValueError(
|
||||
"Test evaluation requires full scoring with record_history=false; select cases in benchmark",
|
||||
)
|
||||
if self.exclude_training_cases and (self.benchmark.name != "longmemeval" or not self.benchmark.all_cases):
|
||||
raise ValueError("exclude_training_cases requires LongMemEval with all_cases=true")
|
||||
return self
|
||||
|
||||
|
||||
def project_path(value: str, project_root: Path) -> Path:
|
||||
"""Resolve runner paths independently of the launching working directory."""
|
||||
if value.startswith("~"):
|
||||
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
|
||||
return (project_root / value).resolve()
|
||||
|
||||
|
||||
def load_request(config_path, *, commit=None, source_workspace=None, test_workspace=None, project_root=PROJECT_ROOT):
|
||||
"""Validate all source inputs before starting an application or writing test data."""
|
||||
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
||||
for key, value in (
|
||||
("code_commit", commit),
|
||||
("source_workspace", source_workspace),
|
||||
("test_workspace", test_workspace),
|
||||
):
|
||||
if value is not None:
|
||||
raw[key] = value
|
||||
# Execution settings can be partial while retaining the test-only history default.
|
||||
raw["evaluation"] = {"record_history": False, **raw.get("evaluation", {})}
|
||||
config = TestRunConfig.model_validate(raw)
|
||||
source_path = project_path(config.source_workspace, project_root)
|
||||
target = project_path(config.test_workspace, project_root)
|
||||
if target.is_relative_to(source_path) or source_path.is_relative_to(target):
|
||||
raise ValueError("Test and source workspaces must be separate, non-nested directories")
|
||||
if target.exists() and (
|
||||
not target.is_dir() or (any(target.iterdir()) and not (target / "home/metadata/workspace.json").is_file())
|
||||
):
|
||||
raise ValueError("Use an empty test workspace or a compatible prepared Home test workspace")
|
||||
source = EvaluationSource.load(source_path, config.code_commit)
|
||||
benchmark = config.benchmark.model_copy(deep=True)
|
||||
if benchmark.name != source.spec.benchmark.name:
|
||||
raise ValueError("Test benchmark must match the source experiment")
|
||||
benchmark.source = str(project_path(benchmark.source, project_root).resolve(strict=True))
|
||||
if benchmark.evidence_source is not None:
|
||||
benchmark.evidence_source = str(project_path(benchmark.evidence_source, project_root).resolve(strict=True))
|
||||
if config.exclude_training_cases:
|
||||
items = json.loads(Path(benchmark.source).read_text(encoding="utf-8"))
|
||||
ids = [str(item["question_id"]) for item in items]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Duplicate LongMemEval question IDs")
|
||||
training = set(source.record["dataset"]["case_ids"])
|
||||
if training - set(ids):
|
||||
raise ValueError("Test dataset is missing training IDs; provide the original full dataset")
|
||||
benchmark.case_ids = [case_id for case_id in ids if case_id not in training]
|
||||
benchmark.all_cases = False
|
||||
benchmark = BenchmarkSpec.model_validate(benchmark.model_dump())
|
||||
spec = source.spec.model_copy(deep=True)
|
||||
spec.benchmark = benchmark
|
||||
spec.debug = DebugSpec(enabled=False)
|
||||
return config, target, source, spec
|
||||
|
||||
|
||||
def existing_preparation(home, source, spec):
|
||||
"""Reopen only the same request with unchanged code, runtime and normalized data."""
|
||||
marker = contained(home, "metadata/preparation.json")
|
||||
if not marker.exists():
|
||||
return None
|
||||
record = read_json(marker)
|
||||
expected = {**source.provenance, "request_fingerprint": fingerprint(spec.model_dump(mode="json"))}
|
||||
if record.get("test_evaluation") != expected:
|
||||
raise ValueError("Test workspace belongs to a different candidate or dataset; use a new workspace")
|
||||
domain = yaml.safe_load(contained(home, "domain_spec.yaml").read_text(encoding="utf-8"))
|
||||
if fingerprint(domain) != record["config_fingerprint"]:
|
||||
raise ValueError("Test preparation configuration has changed")
|
||||
repository, _ = validate_repository(home, record)
|
||||
if (
|
||||
resolve_revision(repository, "HEAD") != record["code"]["initial_commit"]
|
||||
or tree_fingerprint(repository) != record["code"]["candidate_fingerprint"]
|
||||
or tree_fingerprint(contained(home, "code/runtime", directory=True)) != source.provenance["runtime_fingerprint"]
|
||||
):
|
||||
raise ValueError("Prepared test code or runtime has changed")
|
||||
load_inputs(home, EvaluationSpec(record_history=False))
|
||||
return record
|
||||
|
||||
|
||||
async def run_evaluation(
|
||||
config_path,
|
||||
*,
|
||||
commit=None,
|
||||
source_workspace=None,
|
||||
test_workspace=None,
|
||||
prepare_only=False,
|
||||
expected_benchmark=None,
|
||||
):
|
||||
"""Prepare transactionally, then score through the normal application lifecycle."""
|
||||
config, target, source, spec = await blocking(
|
||||
load_request,
|
||||
config_path,
|
||||
commit=commit,
|
||||
source_workspace=source_workspace,
|
||||
test_workspace=test_workspace,
|
||||
)
|
||||
if expected_benchmark is not None and config.benchmark.name != expected_benchmark:
|
||||
raise ValueError(f"This entry point requires benchmark.name={expected_benchmark}")
|
||||
load_env(PROJECT_ROOT / ".env", override=False)
|
||||
app = Application(
|
||||
workspace_dir=str(target),
|
||||
plugins=["home", JUDGE_PLUGINS[config.benchmark.name]],
|
||||
service={"backend": "cli"},
|
||||
enable_logo=False,
|
||||
log_to_file=False,
|
||||
)
|
||||
try:
|
||||
await app.start()
|
||||
home = target / "home"
|
||||
prepared = await blocking(existing_preparation, home, source, spec)
|
||||
if prepared is None:
|
||||
preparation = app.context.components["home.preparation"]["default"]
|
||||
prepared = await preparation.prepare(spec=spec.model_dump(mode="json"), code_source=source)
|
||||
result = {
|
||||
"test_workspace": str(target),
|
||||
**source.provenance,
|
||||
"evaluation_commit_sha": prepared["code"]["initial_commit"],
|
||||
"case_count": prepared["dataset"]["case_count"],
|
||||
"query_count": prepared["dataset"]["query_count"],
|
||||
}
|
||||
if not prepare_only:
|
||||
response = await app.run_job(
|
||||
"home_evaluate",
|
||||
**config.evaluation.model_dump(mode="json", exclude_unset=True),
|
||||
)
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
result["evaluation"] = response.answer
|
||||
return result
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
|
||||
async def run(config_path=DEFAULT_CONFIG, **kwargs):
|
||||
"""Score the selected commit in its own Home workspace."""
|
||||
return await run_evaluation(config_path, expected_benchmark="longmemeval", **kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
"""Explicit local one-shot entry point, with preparation-only service setup."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--commit", help="Candidate commit SHA or unambiguous hexadecimal prefix")
|
||||
parser.add_argument("--source-workspace", help="Prepared optimization workspace")
|
||||
parser.add_argument("--test-workspace", help="Independent test output workspace")
|
||||
parser.add_argument("--prepare-only", action="store_true", help="Prepare for a later home_evaluate service call")
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(
|
||||
run(
|
||||
args.config,
|
||||
commit=args.commit,
|
||||
source_workspace=args.source_workspace,
|
||||
test_workspace=args.test_workspace,
|
||||
prepare_only=args.prepare_only,
|
||||
),
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -265,15 +265,14 @@ def collect_usage(target: list | None = None, receipt: Path | None = None):
|
|||
|
||||
|
||||
def observe_usage(app) -> None:
|
||||
"""Observe owned clients and distinguish native interrupted replies from answers."""
|
||||
"""Observe owned clients and reject incomplete agent replies."""
|
||||
for wrapper in app.context.components.get("agent_wrapper", {}).values():
|
||||
original_reply = wrapper.reply
|
||||
|
||||
async def reply(*args, _reply=original_reply, **kwargs):
|
||||
result = await _reply(*args, **kwargs)
|
||||
last = result.get("last_message") or {}
|
||||
if last.get("finished_reason") == "interrupted" or last.get("error") is not None:
|
||||
raise RuntimeError("Native Agent was interrupted; see the application log.")
|
||||
if result["status"] != "completed":
|
||||
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
|
||||
return result
|
||||
|
||||
wrapper.reply = reply
|
||||
|
|
|
|||
|
|
@ -291,15 +291,14 @@ def observe_usage(app) -> None:
|
|||
|
||||
|
||||
def observe_completion(app) -> None:
|
||||
"""Keep native interrupted replies unscored; successful replies are unchanged."""
|
||||
"""Keep incomplete native replies unscored; completed replies are unchanged."""
|
||||
for wrapper in app.context.components.get("agent_wrapper", {}).values():
|
||||
original = wrapper.reply
|
||||
|
||||
async def reply(inputs, *args, _reply=original, **kwargs):
|
||||
result = await _reply(inputs, *args, **kwargs)
|
||||
last = result.get("last_message") or {}
|
||||
if str(last.get("finished_reason")) == "interrupted" or last.get("error") is not None:
|
||||
raise RuntimeError("Native Agent was interrupted; its state remains saved.")
|
||||
if result["status"] != "completed":
|
||||
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
|
||||
return result
|
||||
|
||||
wrapper.reply = reply
|
||||
|
|
|
|||
|
|
@ -787,8 +787,10 @@ class ReMeBridge:
|
|||
|
||||
result = await self.agent_wrapper.reply(user_message, **reply_kwargs)
|
||||
|
||||
reply_text = result.get("result", "")
|
||||
session_id = result.get("session_id", "")
|
||||
from reme.steps.evolve._evolve import agent_reply_result_text
|
||||
|
||||
reply_text = agent_reply_result_text(result)
|
||||
session_id = result["session_id"]
|
||||
|
||||
if session_id:
|
||||
self.agent_session_id = session_id
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Build from repository root:
|
||||
# docker build -f deploy/benchmark_docker/Dockerfile.base -t reme-benchmark-base:agentscope-2.0.9 .
|
||||
# docker build -f deploy/benchmark_docker/Dockerfile.base -t reme-benchmark-base:agentscope-2.0.11 .
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
|
|
@ -18,7 +18,7 @@ RUN mkdir reme \
|
|||
&& printf '__version__ = "0.0.0"\n' > reme/__init__.py \
|
||||
&& python -m pip install '.[core]' \
|
||||
&& python -m pip uninstall -y reme-ai \
|
||||
&& python -c 'import agentscope; assert agentscope.__version__ == "2.0.9"' \
|
||||
&& python -c 'import agentscope; assert agentscope.__version__ == "2.0.11"' \
|
||||
&& rm -rf /tmp/reme-dependencies
|
||||
|
||||
WORKDIR /workspace
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Build from repository root with this Dockerfile-specific ignore file.
|
||||
ARG BASE_IMAGE=reme-benchmark-base:agentscope-2.0.9
|
||||
ARG BASE_IMAGE=reme-benchmark-base:agentscope-2.0.11
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
COPY . /opt/reme
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@
|
|||
|
||||
This package runs ReMe benchmark jobs directly inside isolated AgentScope
|
||||
Docker workspaces. It does not start the ReMe HTTP service. Use AgentScope
|
||||
**2.0.9** on the host and in the image.
|
||||
**2.0.11** on the host and in the image.
|
||||
|
||||
Rebuild the base image when upgrading from an older AgentScope image;
|
||||
existing images must also use 2.0.11 to pass candidate validation. New runs use
|
||||
the `reme-benchmark-base:agentscope-2.0.11` tag by default, including Home evaluations.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pip install -e '.[benchmark-docker]'
|
||||
docker build -f deploy/benchmark_docker/Dockerfile.base \
|
||||
-t reme-benchmark-base:agentscope-2.0.9 .
|
||||
-t reme-benchmark-base:agentscope-2.0.11 .
|
||||
```
|
||||
|
||||
`SourceCandidate` uploads an immutable source snapshot and installs the root
|
||||
|
|
@ -57,6 +61,18 @@ case's workspace **as it stood immediately after construction**, or provide
|
|||
`workspace_source` pointing at a host workspace directory or a saved archive.
|
||||
A reused workspace may be queried without additional build jobs.
|
||||
`export_workspace_to` persists a portable snapshot beyond the pool run.
|
||||
`run(..., on_case_finished=callback)` awaits an async `(plan, result)` callback
|
||||
after each case releases its worker, allowing incremental host-side persistence
|
||||
and judging. Final worker-close errors can still be attached afterward; reconcile
|
||||
returned results before publishing a completed summary. Callback errors abort the
|
||||
batch and trigger worker cleanup. `fail_fast=True` stops the batch on an execution
|
||||
failure. An optional `accept_build` predicate allows trusted callers to define
|
||||
partial-build acceptance; the default requires successful construction.
|
||||
`CasePlan.export_diagnostics_to` requests a best-effort full archive on execution
|
||||
failure before resetting a still-accessible worker. Interrupted containers may
|
||||
only retain a cached build log; failed diagnostic export never replaces the
|
||||
original error. `CaseResult.failure_stage` and `exception` identify execution
|
||||
failures separately from ordinary structured build/query results.
|
||||
To build memory once and query it in later runs using the same factory:
|
||||
|
||||
```python
|
||||
|
|
@ -137,6 +153,6 @@ explicitly enabled:
|
|||
REME_DOCKER_INTEGRATION=1 pytest tests/integration/test_benchmark_docker.py -v
|
||||
```
|
||||
|
||||
The host must have AgentScope 2.0.9 and a working Docker daemon. To use an
|
||||
The host must have AgentScope 2.0.11 and a working Docker daemon. To use an
|
||||
already built base image, set `REME_BENCHMARK_TEST_IMAGE` to its tag; the tests
|
||||
do not remove a caller-supplied image.
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class SourceCandidate:
|
|||
"""
|
||||
|
||||
snapshot: SourceSnapshot
|
||||
base_image: str = "reme-benchmark-base:agentscope-2.0.9"
|
||||
base_image: str = "reme-benchmark-base:agentscope-2.0.11"
|
||||
install_projects: tuple[str, ...] = (".",)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Minimal AgentScope 2.0.9 DockerWorkspace for direct ReMe jobs.
|
||||
"""Minimal AgentScope 2.0.11 DockerWorkspace for direct ReMe jobs.
|
||||
|
||||
The stock workspace builds and starts an MCP gateway. The benchmark harness
|
||||
uses only DockerBackend's exec/read/write primitives, so building that gateway
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ from dataclasses import asdict
|
|||
from datetime import datetime, timezone
|
||||
import gzip
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
import tarfile
|
||||
from typing import Any, BinaryIO, Callable, Literal, Protocol
|
||||
from typing import Any, Awaitable, BinaryIO, Callable, Literal, Protocol
|
||||
import uuid
|
||||
|
||||
from ._cleanup import cleanup_on_error
|
||||
|
|
@ -239,13 +240,13 @@ def _archive_workspace(source: str | Path) -> bytes:
|
|||
|
||||
|
||||
def _default_workspace_builder(workspace_id: str, image: str, env: dict[str, str]) -> Workspace:
|
||||
"""Create the AgentScope 2.0.9 Docker backend lazily."""
|
||||
"""Create the AgentScope 2.0.11 Docker backend lazily."""
|
||||
import agentscope
|
||||
|
||||
from .direct_docker_workspace import DirectDockerWorkspace
|
||||
|
||||
if agentscope.__version__ != "2.0.9":
|
||||
raise RuntimeError(f"benchmark Docker host requires agentscope 2.0.9, got {agentscope.__version__}")
|
||||
if agentscope.__version__ != "2.0.11":
|
||||
raise RuntimeError(f"benchmark Docker host requires agentscope 2.0.11, got {agentscope.__version__}")
|
||||
return DirectDockerWorkspace(
|
||||
workspace_id=workspace_id,
|
||||
base_image=image,
|
||||
|
|
@ -272,8 +273,12 @@ class DockerReMeSandboxFactory:
|
|||
plugins: tuple[str, ...] | list[str] = (),
|
||||
command_timeout: float = 1800.0,
|
||||
workspace_builder: WorkspaceBuilder | None = None,
|
||||
on_sandbox_started: Callable[[], None | Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
self.candidate = candidate
|
||||
self._on_sandbox_started = on_sandbox_started
|
||||
self._sandbox_started_reported = False
|
||||
self._sandbox_started_lock = asyncio.Lock()
|
||||
self.env = dict(env or {})
|
||||
self.config = config
|
||||
self.plugins = tuple(plugins)
|
||||
|
|
@ -309,6 +314,13 @@ class DockerReMeSandboxFactory:
|
|||
)
|
||||
try:
|
||||
await case.initialize()
|
||||
if self._on_sandbox_started is not None:
|
||||
async with self._sandbox_started_lock:
|
||||
if not self._sandbox_started_reported:
|
||||
result = self._on_sandbox_started()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
self._sandbox_started_reported = True
|
||||
except BaseException as exc:
|
||||
await cleanup_on_error([(f"workspace {container_id!r}", workspace.close())], exc)
|
||||
raise
|
||||
|
|
@ -489,9 +501,9 @@ class DockerReMeSandbox:
|
|||
)
|
||||
result = await self._exec_checked("validate_candidate", [python, "-c", script])
|
||||
validation = json.loads(_decode(result.stdout))
|
||||
if validation.get("agentscope") != "2.0.9":
|
||||
if validation.get("agentscope") != "2.0.11":
|
||||
raise SandboxCommandError(
|
||||
f"candidate environment requires agentscope 2.0.9, got {validation.get('agentscope')!r}",
|
||||
f"candidate environment requires agentscope 2.0.11, got {validation.get('agentscope')!r}",
|
||||
)
|
||||
candidate_root = PurePosixPath(CANDIDATE_ROOT)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
|
|
@ -16,6 +17,19 @@ from typing import Any
|
|||
from ._cleanup import finish_cleanup, format_error
|
||||
from .docker_sandbox import DockerReMeSandbox, DockerReMeSandboxFactory
|
||||
from .models import EvaluationQuery, JobRequest
|
||||
from .worker import _exception_details
|
||||
|
||||
|
||||
def _exception_classification(exc: BaseException) -> dict[str, Any]:
|
||||
"""Retain only classification fields, omitting absent status codes and causes."""
|
||||
details = _exception_details(exc)
|
||||
result = {key: details[key] for key in ("type", "module")}
|
||||
status = details.get("status_code")
|
||||
if isinstance(status, int) and not isinstance(status, bool):
|
||||
result["status_code"] = status
|
||||
if details["causes"]:
|
||||
result["causes"] = details["causes"]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -30,6 +44,7 @@ class CasePlan:
|
|||
export_workspace_to: str | Path | None = None
|
||||
export_build_log_to: str | Path | None = None
|
||||
export_evaluation_to: str | Path | None = None
|
||||
export_diagnostics_to: str | Path | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -42,6 +57,8 @@ class CaseResult:
|
|||
workspace_snapshot: Path | None = None
|
||||
build_log: str | None = None
|
||||
error: str | None = None
|
||||
failure_stage: str | None = None
|
||||
exception: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
|
|
@ -79,7 +96,14 @@ class BenchmarkDockerPool:
|
|||
self.factory = factory
|
||||
self.concurrency = concurrency
|
||||
|
||||
async def run(self, plans: list[CasePlan]) -> list[CaseResult]:
|
||||
async def run(
|
||||
self,
|
||||
plans: list[CasePlan],
|
||||
*,
|
||||
on_case_finished: Callable[[CasePlan, CaseResult], Awaitable[None]] | None = None,
|
||||
accept_build: Callable[[dict[str, Any]], bool] | None = None,
|
||||
fail_fast: bool = False,
|
||||
) -> list[CaseResult]:
|
||||
"""Return results in input order, closing every container on exit."""
|
||||
if not plans:
|
||||
return []
|
||||
|
|
@ -105,8 +129,7 @@ class BenchmarkDockerPool:
|
|||
# a container. The graph check above rules out deadlocks.
|
||||
tasks: dict[str, asyncio.Task[CaseResult]] = {}
|
||||
|
||||
async def execute(plan: CasePlan) -> CaseResult:
|
||||
result = CaseResult(plan.case_id)
|
||||
async def execute(plan: CasePlan, result: CaseResult) -> CaseResult:
|
||||
source = plan.workspace_source
|
||||
if plan.reuse_workspace_from is not None:
|
||||
parent = await tasks[plan.reuse_workspace_from]
|
||||
|
|
@ -121,43 +144,83 @@ class BenchmarkDockerPool:
|
|||
return CaseResult(plan.case_id, error=slots.exhaustion_error)
|
||||
worker_results.setdefault(id(worker), []).append(result)
|
||||
|
||||
operation = "prepare"
|
||||
try:
|
||||
await worker.reset_case(plan.case_id)
|
||||
if source is not None:
|
||||
operation = "restore"
|
||||
await worker.upload_workspace(source)
|
||||
if plan.build_jobs:
|
||||
operation = "construction"
|
||||
try:
|
||||
result.build = await worker.run_build(list(plan.build_jobs))
|
||||
except Exception as exc:
|
||||
result.error = format_error(exc)
|
||||
result.failure_stage = operation
|
||||
result.exception = _exception_classification(exc)
|
||||
await self._retain_failed_build_log(worker, plan, result)
|
||||
return result
|
||||
if not result.build.get("success"):
|
||||
accepted = accept_build(result.build) if accept_build else result.build.get("success")
|
||||
if not accepted:
|
||||
result.error = "build failed"
|
||||
result.failure_stage = operation
|
||||
await self._retain_failed_build_log(worker, plan, result)
|
||||
return result
|
||||
if plan.export_build_log_to is not None:
|
||||
operation = "artifacts"
|
||||
await worker.export_build_log(plan.export_build_log_to)
|
||||
# Capture before queries, since answering may change the
|
||||
# workspace. Dependents receive construction state.
|
||||
if plan.export_workspace_to is not None or plan.case_id in reused_case_ids:
|
||||
operation = "artifacts"
|
||||
destination = plan.export_workspace_to or snapshot_root / f"{plan.case_id}.tar.gz"
|
||||
result.workspace_snapshot = await worker.export_workspace(destination)
|
||||
if plan.queries:
|
||||
operation = "answer"
|
||||
result.queries = await worker.run_queries(list(plan.queries))
|
||||
if not result.queries.get("success"):
|
||||
result.error = "query failed"
|
||||
result.failure_stage = operation
|
||||
if plan.export_evaluation_to is not None:
|
||||
operation = "artifacts"
|
||||
await worker.export_evaluation(plan.export_evaluation_to)
|
||||
return result
|
||||
except Exception as exc: # Keep other cases running and release this worker.
|
||||
result.error = format_error(exc)
|
||||
result.failure_stage = operation
|
||||
result.exception = _exception_classification(exc)
|
||||
return result
|
||||
finally:
|
||||
if result.error and plan.export_diagnostics_to is not None and worker.reusable:
|
||||
try:
|
||||
await worker.export_full(plan.export_diagnostics_to)
|
||||
except Exception as exc:
|
||||
result.error += f"; diagnostic export failed: {format_error(exc)}"
|
||||
await self._release_worker(worker, result, slots)
|
||||
|
||||
async def run_plan(plan: CasePlan) -> CaseResult:
|
||||
result = CaseResult(plan.case_id)
|
||||
try:
|
||||
result = await execute(plan, result)
|
||||
except asyncio.CancelledError:
|
||||
result.error = result.error or "interrupted"
|
||||
raise
|
||||
finally:
|
||||
if on_case_finished is not None:
|
||||
await on_case_finished(plan, result)
|
||||
failed = not result.success
|
||||
if accept_build is not None and result.build is not None:
|
||||
failed = (
|
||||
result.error is not None
|
||||
or not accept_build(result.build)
|
||||
or (result.queries is not None and not result.queries.get("success"))
|
||||
)
|
||||
if fail_fast and failed:
|
||||
raise RuntimeError(f"case {plan.case_id!r} failed: {result.error or 'execution failed'}")
|
||||
return result
|
||||
|
||||
for plan in plans:
|
||||
tasks[plan.case_id] = asyncio.create_task(execute(plan))
|
||||
tasks[plan.case_id] = asyncio.create_task(run_plan(plan))
|
||||
try:
|
||||
results = await asyncio.gather(*(tasks[plan.case_id] for plan in plans))
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -40,12 +40,30 @@ def _exception_details(exc: BaseException) -> dict[str, Any]:
|
|||
"message": str(exc),
|
||||
}
|
||||
response = getattr(exc, "response", None)
|
||||
status_code = getattr(response, "status_code", None)
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if not isinstance(status_code, int):
|
||||
status_code = getattr(response, "status_code", None)
|
||||
if isinstance(status_code, int):
|
||||
details["status_code"] = status_code
|
||||
error_number = getattr(exc, "errno", None)
|
||||
if isinstance(error_number, int):
|
||||
details["errno"] = error_number
|
||||
causes = []
|
||||
seen = {id(exc)}
|
||||
current = exc
|
||||
while True:
|
||||
current = current.__cause__ or (None if current.__suppress_context__ else current.__context__)
|
||||
if current is None or id(current) in seen:
|
||||
break
|
||||
seen.add(id(current))
|
||||
cause = {"type": type(current).__name__, "module": type(current).__module__}
|
||||
status = getattr(current, "status_code", None)
|
||||
if not isinstance(status, int):
|
||||
status = getattr(getattr(current, "response", None), "status_code", None)
|
||||
if isinstance(status, int) and not isinstance(status, bool):
|
||||
cause["status_code"] = status
|
||||
causes.append(cause)
|
||||
details["causes"] = causes
|
||||
return details
|
||||
|
||||
|
||||
|
|
@ -158,6 +176,9 @@ async def _run_job_on_app(app: Any, job: str, arguments: dict[str, Any]) -> dict
|
|||
"metadata": response.metadata,
|
||||
"error": None if response.success else str(response.answer),
|
||||
}
|
||||
exception = response.metadata.get("reme.job_error")
|
||||
if not response.success and isinstance(exception, dict):
|
||||
result["exception"] = exception
|
||||
result["token_usage"] = token_usage
|
||||
result["job_call_counts"] = {name: count for name, count in job_call_counts.items() if count}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -557,6 +557,29 @@ Current default components in `reme/config/default.yaml`:
|
|||
| `keyword_index` | `default` | `bm25` | BM25 keyword index. |
|
||||
| `file_store` | `default` | `local` | Combines file_graph and keyword_index; defaults to `embedding_store: ""`. |
|
||||
|
||||
All three agent backends execute through `reply_stream()`. `reply()` collects that same stream into an
|
||||
[`AgentReply`](../../reme/schema/agent_reply.py) dictionary: `session_id`, final text in `result`,
|
||||
`structured_output`, invocation-scoped `usage`, `status`, `error`, `raw`, and the complete ordered JSON `events`.
|
||||
Both modes accept `output_schema`. Only `completed` is successful; `failed`, `interrupted`, and `waiting` preserve
|
||||
incomplete outcomes. Setup/transport errors and caller cancellation propagate. Both modes record reported token
|
||||
usage once per invocation; unavailable usage remains null.
|
||||
|
||||
[`StreamChunk`](../../reme/schema/stream_chunk.py) uses `start`, append-only `delta`, and `end` operations.
|
||||
Non-null start/end payloads replace a block's accumulated content; null leaves it unchanged, and an empty string
|
||||
clears it. Session, turn, message, block, and tool-call IDs correlate updates. `metadata.native` retains complete
|
||||
provider events, including unknown fields and diagnostic notifications. `reply_end.chunk` carries the outcome
|
||||
fields above, excluding `events`; `reply_start`/`reply_end` delimit one invocation, while DONE only closes transport.
|
||||
`collect_reply(stream)` and `replay_reply(reply)` convert between the async stream and JSON result without losing
|
||||
payloads, order, native metadata, or terminal state. Collection fails if the terminal outcome is missing or repeated.
|
||||
|
||||
`raw` preserves native results: AgentScope's final message in `raw.message` plus an optional
|
||||
`raw.structured_response`, Claude's `ResultMessage`, or Codex's `TurnResult`. Persist the full AgentReply for exact
|
||||
replay; native session history supplies item snapshots rather than the original stream's deltas.
|
||||
Studio reconciles block snapshots, displays structured output and terminal states, and retains live events through
|
||||
history refreshes. Raw HTTP `stream_chunks()` includes errors and DONE; CLI/text helpers handle errors separately.
|
||||
Migration: replace `last_message`/`turn` with `result`/`raw`, and consume the terminal outcome instead of concatenating
|
||||
all stream payloads. Existing text-only producers use the default `delta` operation.
|
||||
|
||||
Codex wrappers reference `app_server: default` or `app_server: oauth`. Multiple wrappers can share one server while
|
||||
keeping their own models, instructions and conversations. Omit `session_id` to start a new thread; pass a returned
|
||||
`session_id` to continue it. Different threads can execute concurrently; operations on one thread are serialized.
|
||||
|
|
|
|||
|
|
@ -538,6 +538,26 @@ flowchart LR
|
|||
| `keyword_index` | `default` | `bm25` | BM25 关键词索引 |
|
||||
| `file_store` | `default` | `local` | 组合 file_graph、keyword_index;默认 `embedding_store: ""` |
|
||||
|
||||
三个 agent 后端统一通过 `reply_stream()` 执行,`reply()` 收集同一条流,返回
|
||||
[`AgentReply`](../../reme/schema/agent_reply.py) 字典:`session_id`、最终文本 `result`、`structured_output`、
|
||||
本次调用的 `usage`、`status`、`error`、`raw` 和完整、有序的 JSON `events`。两种模式均支持 `output_schema`。
|
||||
只有 `completed` 表示成功;`failed`、`interrupted` 和 `waiting` 保留未完成的结果。启动、传输异常和调用方的
|
||||
取消继续传播。两种模式均按调用记录一次已报告的 token 用量;无法获取用量时为 null。
|
||||
|
||||
[`StreamChunk`](../../reme/schema/stream_chunk.py) 使用 `start`、追加内容的 `delta` 和 `end` 操作。
|
||||
start/end 的非 null 内容替换对应块的累计内容;null 表示不变,空字符串表示清空。会话、调用、消息、内容块和
|
||||
工具调用 ID 用于关联更新。`metadata.native` 保留完整原生事件,包括未知字段和诊断通知。
|
||||
`reply_end.chunk` 携带上述结果字段,不含 `events`;`reply_start`/`reply_end` 包含一次 agent 调用内的模型与工具
|
||||
循环,DONE 仅关闭传输。`collect_reply(stream)` 与 `replay_reply(reply)` 在异步流和 JSON 结果间无损转换,保留
|
||||
payload、顺序、原生元数据和终止状态;缺少或重复的终止结果会明确报错。
|
||||
|
||||
`raw` 保留原生结果:AgentScope 的最终消息在 `raw.message`,额外结构化结果在 `raw.structured_response`;
|
||||
Claude 保留 `ResultMessage`,Codex 保留 `TurnResult`。需要原样重放时应保存完整 AgentReply;原生会话历史提供
|
||||
item 快照,不含原始流的逐次增量。Studio 合并内容快照,展示结构化输出和终止状态,历史刷新保留已有实时事件。
|
||||
HTTP `stream_chunks()` 完整返回错误事件和 DONE,CLI 与文本辅助函数单独处理报错。
|
||||
迁移:用 `result`/`raw` 替代 `last_message`/`turn`,消费终止结果,不再拼接所有流式 payload。
|
||||
纯文本生产者使用默认 `delta` 操作。
|
||||
|
||||
Codex wrapper 通过 `app_server: default` 或 `app_server: oauth` 引用共享组件。多个 wrapper 可以共享同一个进程,
|
||||
并分别配置模型、指令和会话。不传 `session_id` 时创建新 thread;传回已有 `session_id` 时续聊。
|
||||
不同 thread 可以并发,同一 thread 的操作串行。
|
||||
|
|
|
|||
|
|
@ -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** 可以直接检查内容与来源元数据。
|
||||
|
|
|
|||
272
integrations/dsh/docs/environment.md
Normal file
272
integrations/dsh/docs/environment.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# 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. Supported ReMe releases import AgentScope while loading the
|
||||
CLI, so it is required to start the service. Install or reinstall `reme-ai[core]` when it is missing.
|
||||
- `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 and the POSIX process group are gone. A child that outlives its parent still receives the force-kill.
|
||||
If the group cannot be confirmed empty, its record is retained; a later unverifiable group is never signalled again.
|
||||
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. The selected interpreter's
|
||||
dependencies are checked before stopping the previous service.
|
||||
Status reads before spawn acknowledgement keep the launch in flight. If a newer service record appears during
|
||||
probing or spawning, it is preserved and any unrecorded new child is stopped before reporting failure.
|
||||
Service-record read errors abort the operation instead of being treated as an absent record.
|
||||
- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running.
|
||||
A refused launch is reported in the DSH Host log.
|
||||
|
||||
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. Only a missing file is treated as a first save; permission or I/O read errors abort the save without
|
||||
replacing the active file or its backup. Names must start with a letter or underscore and contain only letters, digits and underscores; values must be single-line strings;
|
||||
invalid updates leave both files 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.
|
||||
217
integrations/dsh/docs/environment_ZH.md
Normal file
217
integrations/dsh/docs/environment_ZH.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# 环境管理
|
||||
|
||||
**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` 是否可用。受支持的 ReMe 版本在加载 CLI 时就会导入 AgentScope,因此缺失时
|
||||
无法启动服务;请安装或重装 `reme-ai[core]`。
|
||||
- `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 及 POSIX 进程组真正消失之后**才清除记录。
|
||||
父进程先退出时,仍会强制结束存活的子进程。无法确认进程组已清空就保留记录;后续无法验证身份的进程组不会再次被发信号。
|
||||
别处启动的服务不受影响,只会被观察。
|
||||
- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并同时给出**停止**与**启动**:停止负责清记录,启动则让 Host
|
||||
停掉可证明归属的旧启动、或丢弃已证明属于外部的记录。只有本标签页发起的启动仍在进行时才会显示“启动中”。
|
||||
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
|
||||
归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。停止旧服务前会先检查所选解释器的启动依赖。
|
||||
在操作系统确认启动之前,状态查询不会清除启动标记。探测或启动过程中出现更新的服务记录时,会保留新记录,
|
||||
并在报告失败前停止尚未登记的新子进程;读取记录失败会中止操作,不会按“没有记录”继续启动。
|
||||
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起;被拒绝的启动会记入 DSH Host 日志。
|
||||
|
||||
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 别名。
|
||||
|
||||
**所选解释器不在列表里。** 标签页会用“不在探测结果中”标注它,但仍然使用它。重新探测,或改选探测到的解释器。
|
||||
|
||||
**保存 `.env` 时读取失败。** 只有文件不存在才按首次保存处理;权限或 I/O 错误会中止保存,不会替换原文件或备份。变量名须以字母或下划线开头,其余只接受字母、数字和下划线;值必须是单行字符串;无效更新不会改动原文件或备份。
|
||||
|
||||
**安装失败。** 先看日志尾部:缺少 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);
|
||||
|
|
@ -154,10 +195,11 @@ try {
|
|||
.map((file) => path.join("tests", file));
|
||||
const tested = await run(
|
||||
process.execPath,
|
||||
["--test", ...testFiles],
|
||||
["--test", "--test-reporter=tap", ...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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1219,7 +1224,8 @@ function componentGroupLabel(group: string, t: StatusTranslator): string {
|
|||
file_store: "fileStore",
|
||||
keyword_index: "keywordIndex",
|
||||
};
|
||||
return labels[group] === undefined ? readableName(group) : t(labels[group]);
|
||||
const key = Object.hasOwn(labels, group) ? labels[group] : undefined;
|
||||
return key === undefined ? readableName(group) : t(key);
|
||||
}
|
||||
|
||||
function componentFieldLabel(field: string, t: StatusTranslator): string {
|
||||
|
|
@ -1237,7 +1243,8 @@ function componentFieldLabel(field: string, t: StatusTranslator): string {
|
|||
vocab_size: "vocabulary",
|
||||
memory: "memoryUsage",
|
||||
};
|
||||
return labels[field] === undefined ? readableName(field) : t(labels[field]);
|
||||
const key = Object.hasOwn(labels, field) ? labels[field] : undefined;
|
||||
return key === undefined ? readableName(field) : t(key);
|
||||
}
|
||||
|
||||
function readableName(value: string): string {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
627
integrations/dsh/src/environment/client/dependency.tsx
Normal file
627
integrations/dsh/src/environment/client/dependency.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { canHostService } from "../dependency.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") }
|
||||
: canHostService(checkData)
|
||||
? { 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>
|
||||
);
|
||||
}
|
||||
407
integrations/dsh/src/environment/client/locales.ts
Normal file
407
integrations/dsh/src/environment/client/locales.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
/** 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 (required to start the service)",
|
||||
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.",
|
||||
startErrorCoreMissing:
|
||||
"AgentScope is missing; install reme-ai[core] before starting the service.",
|
||||
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: "缺失(启动服务所必需)",
|
||||
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 版本过低,请先升级。",
|
||||
startErrorCoreMissing: "缺少 AgentScope,请先安装 reme-ai[core] 再启动服务。",
|
||||
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;
|
||||
}
|
||||
858
integrations/dsh/src/environment/client/service.tsx
Normal file
858
integrations/dsh/src/environment/client/service.tsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
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",
|
||||
"core-missing": "startErrorCoreMissing",
|
||||
"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 && Object.hasOwn(START_ERROR_KEYS, 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 && Object.hasOwn(STOP_REASON_KEYS, 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 that names
|
||||
* no record cannot safely offer Forget for the endpoint on screen.
|
||||
*/
|
||||
function recoveryFor(result: {
|
||||
code?: string;
|
||||
record?: ServiceRecordRef;
|
||||
}): ServiceRecordRef | undefined {
|
||||
if (!result.code || !RECORD_ONLY_CODES.has(result.code)) return undefined;
|
||||
return result.record;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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%}}
|
||||
`;
|
||||
343
integrations/dsh/src/environment/client/tab.tsx
Normal file
343
integrations/dsh/src/environment/client/tab.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
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 { canHostService } 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 && Object.hasOwn(HEALTH_ERROR_KEYS, 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: canHostService(check) ? "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 || canHostService(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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
integrations/dsh/src/environment/dependency.ts
Normal file
14
integrations/dsh/src/environment/dependency.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** Shared, browser-safe readiness rule for the Host and setup cards. */
|
||||
import type { RemeCheck } from "./types.js";
|
||||
|
||||
/**
|
||||
* Supported ReMe releases import AgentScope while loading the CLI, so a bare
|
||||
* installation cannot start the service. Install `reme-ai[core]` first.
|
||||
*/
|
||||
export function canHostService(check: RemeCheck | undefined): boolean {
|
||||
return (
|
||||
check?.installed === true &&
|
||||
check.versionOk === true &&
|
||||
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;
|
||||
551
integrations/dsh/src/environment/host/install.ts
Normal file
551
integrations/dsh/src/environment/host/install.ts
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
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";
|
||||
import { processGroupAlive } from "./runner.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() {
|
||||
return processGroupAlive(child.pid);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
703
integrations/dsh/src/environment/host/manager.ts
Normal file
703
integrations/dsh/src/environment/host/manager.ts
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
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,
|
||||
sameServiceRecord,
|
||||
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; pid?: 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 (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
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);
|
||||
const starting = this.starting;
|
||||
const acknowledged = starting?.pid !== undefined;
|
||||
// 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 (
|
||||
starting &&
|
||||
this.starting === starting &&
|
||||
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" ||
|
||||
(acknowledged && 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;
|
||||
const result = await this.serviceStart(
|
||||
options.endpoint,
|
||||
pythonPath,
|
||||
options.workspaceDir,
|
||||
options.authToken ?? "",
|
||||
);
|
||||
if (!result.started && !result.alreadyRunning)
|
||||
throw new Error(result.error || "Could not auto-start the ReMe service");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!sameServiceRecord(current, record)) return;
|
||||
try {
|
||||
writeServiceState(this.serviceStatePath, {
|
||||
...record,
|
||||
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 } : {}),
|
||||
};
|
||||
const { record, ownership, alive } = await this.recordedService();
|
||||
const replace =
|
||||
record !== undefined &&
|
||||
ownership === "owned" &&
|
||||
isLocalEndpoint(endpointValue) &&
|
||||
this.sameOrigin(record.endpoint, endpointValue);
|
||||
// A port conflict is replaceable only when this record proves that our
|
||||
// own service holds it. An unverified related record retains Forget.
|
||||
if (
|
||||
current.phase === "conflict" &&
|
||||
!replace &&
|
||||
!(
|
||||
record &&
|
||||
ownership === "unknown" &&
|
||||
this.relatedRecord(record.endpoint, endpointValue)
|
||||
)
|
||||
)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"conflict",
|
||||
"Another process is already listening on this endpoint",
|
||||
);
|
||||
if (record && alive) {
|
||||
if (ownership === "unknown")
|
||||
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) },
|
||||
);
|
||||
if (ownership === "owned" && !replace)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"other-endpoint-running",
|
||||
`A service for ${record.endpoint} is still running; stop it from that endpoint first`,
|
||||
);
|
||||
}
|
||||
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)) {
|
||||
const code = !check.installed
|
||||
? "not-installed"
|
||||
: !check.versionOk
|
||||
? "version-too-old"
|
||||
: "core-missing";
|
||||
const errors = {
|
||||
"not-installed": "reme-ai is not installed in the selected interpreter",
|
||||
"version-too-old": `reme-ai ${
|
||||
check.version || "(unknown version)"
|
||||
} is older than ${MINIMUM_REME}; upgrade it first`,
|
||||
"core-missing":
|
||||
"AgentScope is required to start ReMe; install reme-ai[core] in the selected interpreter",
|
||||
};
|
||||
return this.refused(logPath, now, code, errors[code]);
|
||||
}
|
||||
const inspected = readServiceState(this.serviceStatePath);
|
||||
if (!sameServiceRecord(inspected, record))
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"previous-not-stopped",
|
||||
"The service record changed during the dependency check; retry with its current state",
|
||||
{ record: serviceRecordRef(inspected) },
|
||||
);
|
||||
// All replacement cases share this path. Validate the new interpreter
|
||||
// before stopping anything, then re-verify ownership at the stop boundary.
|
||||
if (replace && record) {
|
||||
const previous = await stopService(this.serviceIO, {
|
||||
statePath: this.serviceStatePath,
|
||||
endpoint: record.endpoint,
|
||||
expectPid: record.pid,
|
||||
});
|
||||
const remaining = readServiceState(this.serviceStatePath);
|
||||
if (!previous.stopped || remaining)
|
||||
return this.refused(
|
||||
logPath,
|
||||
now,
|
||||
"previous-not-stopped",
|
||||
`A previous launch could not be stopped: ${
|
||||
previous.reason ?? "the service record changed while stopping"
|
||||
}`,
|
||||
{ record: previous.record ?? serviceRecordRef(remaining) },
|
||||
);
|
||||
} else if (record) {
|
||||
// Only dead or provably foreign records reach here.
|
||||
removeServiceState(this.serviceStatePath);
|
||||
}
|
||||
// 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() };
|
||||
const launch = this.starting;
|
||||
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;
|
||||
else if (result.pid !== undefined) launch.pid = result.pid;
|
||||
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: "",
|
||||
};
|
||||
}
|
||||
129
integrations/dsh/src/environment/host/runner.ts
Normal file
129
integrations/dsh/src/environment/host/runner.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
/** Only ESRCH proves a detached POSIX process group is empty. */
|
||||
export function processGroupAlive(pid: number | undefined): boolean {
|
||||
if (
|
||||
process.platform === "win32" ||
|
||||
pid === undefined ||
|
||||
!Number.isInteger(pid) ||
|
||||
pid <= 1
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
process.kill(-pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
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` }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
992
integrations/dsh/src/environment/host/service.ts
Normal file
992
integrations/dsh/src/environment/host/service.ts
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
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";
|
||||
import { processGroupAlive } from "./runner.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;
|
||||
/** Whether the detached POSIX process group still exists after its leader exits. */
|
||||
groupAlive?(pid: number): boolean;
|
||||
/** 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 text: string;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} 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 : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Compare observed records before changing ownership metadata after an await. */
|
||||
export function sameServiceRecord(
|
||||
left: PersistedService | undefined,
|
||||
right: PersistedService | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left?.pid === right?.pid &&
|
||||
left?.endpoint === right?.endpoint &&
|
||||
left?.startedAt === right?.startedAt &&
|
||||
left?.workspaceDir === right?.workspaceDir &&
|
||||
left?.pythonPath === right?.pythonPath &&
|
||||
left?.command === right?.command
|
||||
);
|
||||
}
|
||||
|
||||
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 the recorded pid or its detached process group still exists. */
|
||||
export function processAlive(io: ServiceIO, pid?: number): boolean {
|
||||
return isValidPid(pid) && (alive(io, pid) || io.groupAlive?.(pid) === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
// A failed stop may leave children after the verified leader exited. Keep
|
||||
// the record, but do not claim ownership we can no longer verify.
|
||||
if (!alive(io, persisted.pid))
|
||||
return processAlive(io, persisted.pid) ? "unknown" : "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 = processAlive(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 {
|
||||
// Another manager may have published a launch while spawn acknowledgement
|
||||
// was pending. Keep its record and terminate only our unrecorded child.
|
||||
if (readServiceState(options.statePath))
|
||||
throw new Error(
|
||||
"Another service was recorded while this launch was starting",
|
||||
);
|
||||
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.
|
||||
let exited = child.pid === undefined;
|
||||
if (child.pid !== undefined) {
|
||||
try {
|
||||
io.killTree(child.pid, true);
|
||||
} catch {
|
||||
// Still confirm whether it exited before reporting the failure.
|
||||
}
|
||||
exited = await waitForExit(
|
||||
io,
|
||||
child.pid,
|
||||
STOP_FORCE_TIMEOUT_MS,
|
||||
STOP_POLL_MS,
|
||||
);
|
||||
}
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
command: command.display,
|
||||
logPath: options.logPath,
|
||||
startedAt,
|
||||
error: `Could not record the ReMe service: ${safeError(error)}${
|
||||
exited ? "" : `; process ${child.pid} could not be confirmed stopped`
|
||||
}`,
|
||||
...(!exited && child.pid !== undefined ? { pid: child.pid } : {}),
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for both the leader and its detached process group to disappear. */
|
||||
async function waitForExit(
|
||||
io: ServiceIO,
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
pollMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (!processAlive(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 group 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;
|
||||
/** Pin a replacement to the process inspected before dependency checks. */
|
||||
expectPid?: number;
|
||||
/** Grace period before escalating to SIGKILL (tests shorten it). */
|
||||
timeoutMs?: number;
|
||||
forceTimeoutMs?: number;
|
||||
pollMs?: number;
|
||||
},
|
||||
): 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",
|
||||
};
|
||||
}
|
||||
if (options.expectPid !== undefined && persisted.pid !== options.expectPid)
|
||||
return {
|
||||
stopped: false,
|
||||
reason: "The recorded service changed since it was inspected",
|
||||
code: "record-changed",
|
||||
record: serviceRecordRef(persisted),
|
||||
};
|
||||
const 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 isCurrentRecord = (): boolean => {
|
||||
const current = readServiceState(options.statePath);
|
||||
return sameServiceRecord(current, persisted);
|
||||
};
|
||||
// Identity reads yield to the event loop. Never signal a record that was
|
||||
// replaced while those reads were in flight, or erase a newer launch later.
|
||||
if (!isCurrentRecord())
|
||||
return {
|
||||
stopped: false,
|
||||
pid,
|
||||
reason: "The recorded service changed since it was inspected",
|
||||
code: "record-changed",
|
||||
record: serviceRecordRef(readServiceState(options.statePath)),
|
||||
};
|
||||
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)
|
||||
) {
|
||||
if (isCurrentRecord()) 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,
|
||||
)
|
||||
) {
|
||||
if (isCurrentRecord()) 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);
|
||||
},
|
||||
groupAlive: processGroupAlive,
|
||||
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,
|
||||
};
|
||||
}
|
||||
504
integrations/dsh/src/environment/host/workspace.ts
Normal file
504
integrations/dsh/src/environment/host/workspace.ts
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
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));
|
||||
for (const [key, value] of pending) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
||||
throw new TypeError("Invalid environment variable name");
|
||||
if (typeof value !== "string" || /[\r\n\0]/.test(value))
|
||||
throw new TypeError("Environment values must be single-line strings");
|
||||
}
|
||||
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;
|
||||
}
|
||||
239
integrations/dsh/src/environment/types.ts
Normal file
239
integrations/dsh/src/environment/types.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* 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"
|
||||
| "core-missing"
|
||||
| "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);
|
||||
});
|
||||
1219
integrations/dsh/tests/env-manager.test.mjs
Normal file
1219
integrations/dsh/tests/env-manager.test.mjs
Normal file
File diff suppressed because it is too large
Load diff
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);
|
||||
});
|
||||
163
integrations/dsh/tests/env-rules.test.mjs
Normal file
163
integrations/dsh/tests/env-rules.test.mjs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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 } 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", () => {
|
||||
// Supported CLI releases need AgentScope even before running any jobs.
|
||||
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);
|
||||
|
||||
assert.equal(canHostService({ ...INSTALLED, hasCore: 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);
|
||||
});
|
||||
952
integrations/dsh/tests/env-service.test.mjs
Normal file
952
integrations/dsh/tests/env-service.test.mjs
Normal file
|
|
@ -0,0 +1,952 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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("missing and malformed service records remain rebuildable", (t) => {
|
||||
const { statePath } = tempState(t, "reme-malformed-record-");
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
for (const text of ["{", "{}", '{"endpoint":7}']) {
|
||||
writeFileSync(statePath, text);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a launch never overwrites a service recorded before its spawn acknowledgement", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-publish-race-");
|
||||
let running = true;
|
||||
let group = true;
|
||||
const { io, calls } = fakeIO({
|
||||
async spawnService() {
|
||||
record(statePath, { pid: 777 });
|
||||
return { pid: 4321 };
|
||||
},
|
||||
killTree(pid, force) {
|
||||
calls.killTree.push(pid);
|
||||
assert.equal(force, true);
|
||||
running = false;
|
||||
setTimeout(() => {
|
||||
group = false;
|
||||
}, 5);
|
||||
},
|
||||
probePid() {
|
||||
if (!running) throw Object.assign(new Error("gone"), { code: "ESRCH" });
|
||||
},
|
||||
groupAlive: () => group,
|
||||
});
|
||||
const result = await startService(io, {
|
||||
endpoint: ENDPOINT,
|
||||
workspaceDir: directory,
|
||||
pythonPath: "/env/bin/python",
|
||||
cliPath: "",
|
||||
disableDreamCron: false,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
env: {},
|
||||
});
|
||||
assert.equal(result.started, false);
|
||||
assert.equal(result.code, "start-failed");
|
||||
assert.equal(
|
||||
group,
|
||||
false,
|
||||
"the unrecorded process group must exit before failure is reported",
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.killTree,
|
||||
[4321],
|
||||
"only the new unrecorded launch is stopped",
|
||||
);
|
||||
assert.equal(readServiceState(statePath)?.pid, 777);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
for (const exits of [true, false]) {
|
||||
test(`a parent-first exit ${
|
||||
exits ? "waits for" : "retains"
|
||||
} its process group`, async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-stop-group-");
|
||||
record(statePath, { pid: 999, workspaceDir: directory });
|
||||
let parent = true;
|
||||
let group = true;
|
||||
const { io, calls } = fakeIO({
|
||||
platform: "linux",
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
probePid() {
|
||||
if (!parent) throw Object.assign(new Error("gone"), { code: "ESRCH" });
|
||||
},
|
||||
groupAlive: () => group,
|
||||
killTree(pid, force = false) {
|
||||
calls.killTree.push(pid);
|
||||
parent = false;
|
||||
if (force) {
|
||||
calls.force.push(pid);
|
||||
group = !exits;
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = await stopService(io, {
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
timeoutMs: 10,
|
||||
forceTimeoutMs: 10,
|
||||
pollMs: 2,
|
||||
});
|
||||
assert.deepEqual(calls.force, [999], "the surviving child gets SIGKILL");
|
||||
assert.equal(result.stopped, exits);
|
||||
if (exits) {
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
} else {
|
||||
assert.equal(result.code, "not-exited");
|
||||
const status = await serviceStatus(io, {
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
assert.equal(readServiceState(statePath)?.pid, 999);
|
||||
assert.equal(status.pid, 999);
|
||||
assert.equal(
|
||||
await serviceOwnership(io, readServiceState(statePath), ENDPOINT),
|
||||
"unknown",
|
||||
);
|
||||
// A later stop no longer has a leader whose identity it can verify.
|
||||
const later = await stopService(io, { statePath, endpoint: ENDPOINT });
|
||||
assert.equal(later.code, "unverified");
|
||||
assert.deepEqual(
|
||||
calls.force,
|
||||
[999],
|
||||
"an unverified group is not signalled again",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a stop never signals a record replaced during the ownership probe", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-stop-race-");
|
||||
record(statePath);
|
||||
const command = commandFor(statePath);
|
||||
const { io, calls } = fakeIO({
|
||||
processCommandLine: async () => {
|
||||
record(statePath, { pid: 888, startedAt: "new" });
|
||||
return command;
|
||||
},
|
||||
});
|
||||
const result = await stopService(io, { statePath, endpoint: ENDPOINT });
|
||||
assert.equal(result.code, "record-changed");
|
||||
assert.deepEqual(calls.killTree, []);
|
||||
assert.equal(readServiceState(statePath)?.pid, 888);
|
||||
});
|
||||
|
||||
test("finishing an old stop does not erase a newer service record", async (t) => {
|
||||
const { statePath } = tempState(t, "reme-stop-new-record-");
|
||||
record(statePath);
|
||||
let running = true;
|
||||
const { io } = fakeIO({
|
||||
processCommandLine: async () => commandFor(statePath),
|
||||
probePid() {
|
||||
if (!running) throw Object.assign(new Error("gone"), { code: "ESRCH" });
|
||||
},
|
||||
killTree() {
|
||||
running = false;
|
||||
record(statePath, { pid: 888, startedAt: "new" });
|
||||
},
|
||||
});
|
||||
const result = await stopService(io, { statePath, endpoint: ENDPOINT });
|
||||
assert.equal(result.stopped, true);
|
||||
assert.equal(readServiceState(statePath)?.pid, 888);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test(
|
||||
"real POSIX stop kills a listening child after its parent exits",
|
||||
{
|
||||
skip: process.platform === "win32",
|
||||
timeout: 15_000,
|
||||
},
|
||||
async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-real-group-");
|
||||
const io = createServiceIO();
|
||||
const logPath = join(directory, "service.log");
|
||||
const childCode = `
|
||||
process.on('SIGTERM', () => {});
|
||||
const server = require('node:net').createServer(socket => socket.end());
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
console.log(JSON.stringify({ port: server.address().port, pid: process.pid }));
|
||||
});
|
||||
`;
|
||||
const parentCode = `
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
const child = require('node:child_process').spawn(process.execPath,
|
||||
['-e', ${JSON.stringify(
|
||||
childCode,
|
||||
)}], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
child.stdout.pipe(process.stdout);
|
||||
`;
|
||||
const { pid } = await io.spawnService({
|
||||
exe: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
parentCode,
|
||||
`workspace_dir=${directory}`,
|
||||
"service.port=2334",
|
||||
],
|
||||
cwd: directory,
|
||||
logPath,
|
||||
env: process.env,
|
||||
});
|
||||
const kill = io.killTree;
|
||||
t.after(() => kill(pid, true));
|
||||
let ready;
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!ready && Date.now() < deadline) {
|
||||
try {
|
||||
ready = JSON.parse(readFileSync(logPath, "utf8").trim());
|
||||
} catch {
|
||||
/* not listening yet */
|
||||
}
|
||||
if (!ready) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.ok(ready, "the child listener must be ready before signalling");
|
||||
assert.equal(await io.portOpen("127.0.0.1", ready.port), true);
|
||||
record(statePath, { pid, workspaceDir: directory });
|
||||
// The fixture uses Node instead of Python; spawn, command line, signals,
|
||||
// group liveness and socket checks still use the real service adapter.
|
||||
io.processImage = async () => "python";
|
||||
let escalated = false;
|
||||
io.killTree = (target, force) => {
|
||||
if (force) {
|
||||
escalated = true;
|
||||
assert.throws(() => io.probePid(pid), { code: "ESRCH" });
|
||||
assert.doesNotThrow(() => io.probePid(ready.pid));
|
||||
}
|
||||
kill(target, force);
|
||||
};
|
||||
const result = await stopService(io, {
|
||||
statePath,
|
||||
endpoint: ENDPOINT,
|
||||
timeoutMs: 200,
|
||||
forceTimeoutMs: 2_000,
|
||||
pollMs: 20,
|
||||
});
|
||||
assert.equal(
|
||||
escalated,
|
||||
true,
|
||||
"parent exit must not bypass child termination",
|
||||
);
|
||||
assert.equal(await io.portOpen("127.0.0.1", ready.port), false);
|
||||
// Some container init processes leave orphaned zombies unreaped. A group
|
||||
// still visible to signal 0 conservatively retains its record.
|
||||
if (result.stopped) {
|
||||
assert.equal(io.groupAlive(pid), false);
|
||||
assert.equal(readServiceState(statePath), undefined);
|
||||
} else {
|
||||
assert.equal(result.code, "not-exited");
|
||||
assert.equal(readServiceState(statePath)?.pid, pid);
|
||||
}
|
||||
},
|
||||
);
|
||||
2014
integrations/dsh/tests/env-tab.test.mjs
Normal file
2014
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: "" });
|
||||
});
|
||||
|
|
@ -325,10 +325,9 @@ class AutoFinStep(BaseStep):
|
|||
if tool_context_id:
|
||||
kwargs["tool_context_id"] = tool_context_id
|
||||
result = await self.agent_wrapper.reply(prompt, **kwargs)
|
||||
if not isinstance(result, dict) or result.get("structured_output") is None:
|
||||
raise ValueError(f"Auto Fin Agent returned no structured output: {self._preview(result)}")
|
||||
value = result["structured_output"]
|
||||
output = value if isinstance(value, model) else model.model_validate(value)
|
||||
if result["status"] != "completed":
|
||||
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
|
||||
output = model.model_validate(result["structured_output"])
|
||||
rendered = self._preview(output.model_dump(), AGENT_OUTPUT_LOG_LIMIT)
|
||||
self.logger.info(
|
||||
f"[{self.name}] agent output prompt={prompt_name} schema={model.__name__} "
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import json
|
|||
import re
|
||||
from time import perf_counter
|
||||
|
||||
from reme.steps.evolve._evolve import agent_reply_result_text
|
||||
|
||||
from .base import AGENT_INPUT_LOG_LIMIT, AGENT_OUTPUT_LOG_LIMIT, AutoFinStep
|
||||
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ class AutoFinTopicStep(AutoFinStep):
|
|||
started_at = perf_counter()
|
||||
result = await self.agent_wrapper.reply(prompt)
|
||||
try:
|
||||
ids = self._parse_news_ids(result.get("result") if isinstance(result, dict) else None, topics)
|
||||
ids = self._parse_news_ids(agent_reply_result_text(result), topics)
|
||||
except ValueError as exc:
|
||||
if attempt:
|
||||
raise ValueError(f"Auto Fin topic Agent returned invalid news IDs: {exc}") from exc
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ class _TopicAgent(BaseAgentWrapper):
|
|||
|
||||
async def reply(self, inputs, **kwargs):
|
||||
self.calls.append((str(inputs), kwargs))
|
||||
return {"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。"}
|
||||
return {
|
||||
"status": "completed",
|
||||
"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -221,7 +224,10 @@ async def test_topic_step_retries_invalid_json_once(tmp_path: Path):
|
|||
|
||||
async def reply(self, inputs, **kwargs):
|
||||
self.calls.append((str(inputs), kwargs))
|
||||
return {"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```')}
|
||||
return {
|
||||
"status": "completed",
|
||||
"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```'),
|
||||
}
|
||||
|
||||
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
|
||||
agent = RetryAgent({"黄金": []}, app_context=app_context)
|
||||
|
|
@ -293,6 +299,7 @@ class _ReportAgent(BaseAgentWrapper):
|
|||
topic = re.search(r"当前主题:(\S+)", prompt)
|
||||
title = f"{topic.group(1)}观察" if topic else "主题新闻观察"
|
||||
return {
|
||||
"status": "completed",
|
||||
"structured_output": AutoFinReportOutput(
|
||||
title=f"# {title}",
|
||||
description="关注政策变化。",
|
||||
|
|
@ -363,6 +370,7 @@ async def test_research_names_the_note_after_the_topic_not_the_agent_title(tmp_p
|
|||
class _VerboseAgent(BaseAgentWrapper):
|
||||
async def reply(self, _prompt, **_kwargs):
|
||||
return {
|
||||
"status": "completed",
|
||||
"structured_output": AutoFinReportOutput(
|
||||
title="机器人主题 9-18 研究:" + "量产与政策" * 60,
|
||||
description="关注政策变化。",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import numpy as np
|
|||
from json_repair import repair_json
|
||||
|
||||
from reme.steps.base_step import BaseStep, Ref
|
||||
from reme.steps.evolve._evolve import agent_reply_result_text
|
||||
from reme.components.as_embedding import BaseAsEmbedding
|
||||
from reme.enumeration import ComponentEnum
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ async def _llm_equivalence(agent_wrapper, reference: str, system: str) -> bool:
|
|||
user_prompt = f"First snippet: {reference}\n\nSecond snippet: {system}"
|
||||
|
||||
result = await agent_wrapper.reply(user_prompt, system_prompt=system_prompt)
|
||||
raw = (result.get("result") or "").strip().lower()
|
||||
raw = agent_reply_result_text(result).lower()
|
||||
return "yes" in raw
|
||||
|
||||
|
||||
|
|
@ -277,7 +278,7 @@ class BeamRubricJudgeStep(BaseStep):
|
|||
prompt = judge_template.replace("<rubric_item>", item).replace("<llm_response>", llm_response)
|
||||
|
||||
result = await self.agent_wrapper.reply(prompt)
|
||||
raw = (result.get("result") or "").strip()
|
||||
raw = agent_reply_result_text(result)
|
||||
|
||||
try:
|
||||
parsed = _parse_json_response(raw)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import os
|
||||
|
||||
from reme.enumeration import ChunkEnum
|
||||
from reme.schema import collect_reply
|
||||
from reme.steps.base_step import BaseStep
|
||||
from reme.steps.index._dedup import _ToolContextDedupMixin
|
||||
from reme.utils.counter import global_counter_inc
|
||||
|
|
@ -61,16 +61,24 @@ class BeamAgenticAnswerStep(BaseStep):
|
|||
if injected_job_kwargs := self._injected_job_kwargs(f"{query}(query time: {query_time})"):
|
||||
wrapper_kwargs["injected_job_kwargs"] = injected_job_kwargs
|
||||
|
||||
if self.context.stream:
|
||||
text = await self._stream_reply(query, **wrapper_kwargs)
|
||||
else:
|
||||
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
|
||||
text = (result.get("result") or "").strip()
|
||||
try:
|
||||
if self.context.stream:
|
||||
result = await collect_reply(
|
||||
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
|
||||
on_chunk=self.context.add_stream_chunk,
|
||||
)
|
||||
else:
|
||||
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
|
||||
finally:
|
||||
if self.app_context is not None:
|
||||
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
|
||||
text = result["result"].strip()
|
||||
self.context.response.metadata.update(result)
|
||||
|
||||
self.logger.debug(f"[{self.name}] response: {text!r}")
|
||||
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = text
|
||||
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
|
||||
self.context.response.answer = result["error"] or text
|
||||
self.context.response.metadata.update(
|
||||
{
|
||||
"query": query,
|
||||
|
|
@ -79,23 +87,4 @@ class BeamAgenticAnswerStep(BaseStep):
|
|||
"response": text,
|
||||
},
|
||||
)
|
||||
|
||||
if self.app_context is not None:
|
||||
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
|
||||
return self.context.response
|
||||
|
||||
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
|
||||
"""Stream unified chunks to the context stream queue."""
|
||||
assert self.context is not None
|
||||
text_parts: list[str] = []
|
||||
|
||||
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
|
||||
await self.context.add_stream_string(chunk.chunk, chunk.chunk_type)
|
||||
|
||||
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
|
||||
text_parts.append(chunk.chunk)
|
||||
|
||||
if chunk.session_id:
|
||||
self.context.response.metadata["session_id"] = chunk.session_id
|
||||
|
||||
return "".join(text_parts).strip()
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ _OutputT = TypeVar("_OutputT", bound=BaseModel)
|
|||
|
||||
def structured_output(result: dict[str, Any], model: type[_OutputT]) -> _OutputT:
|
||||
"""Validate an agent wrapper's structured output."""
|
||||
value = result.get("structured_output")
|
||||
return value if isinstance(value, model) else model.model_validate(value)
|
||||
if result["status"] != "completed":
|
||||
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
|
||||
return model.model_validate(result["structured_output"])
|
||||
|
||||
|
||||
def strip_frontmatter(body: str) -> str:
|
||||
|
|
|
|||
|
|
@ -82,7 +82,12 @@ class _QueuedAgentWrapper(BaseAgentWrapper):
|
|||
async def reply(self, inputs, **kwargs) -> dict:
|
||||
"""Record the request and pop the next structured fixture."""
|
||||
self.calls.append({"inputs": inputs, "kwargs": kwargs})
|
||||
return {"structured_output": self.outputs.pop(0), "result": "ok"}
|
||||
return self._reply_result(
|
||||
session_id="session",
|
||||
result="ok",
|
||||
raw={},
|
||||
structured_output=self.outputs.pop(0),
|
||||
)
|
||||
|
||||
|
||||
def _paper(arxiv_id: str, *, title: str = "Paper", upvotes: int = 10) -> PaperInfo:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from urllib.parse import quote_plus
|
|||
|
||||
from reme.components.agent_wrapper import handle_session_command
|
||||
from reme.steps.base_step import BaseStep
|
||||
from reme.steps.evolve._evolve import agent_reply_result_text
|
||||
|
||||
|
||||
def _session_key(message: Any) -> str:
|
||||
|
|
@ -151,17 +152,11 @@ class DingTalkWaitStep(BaseStep):
|
|||
builtin_tools=self.builtin_tools,
|
||||
job_tools=self.job_tools,
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise TypeError("Agent reply must be a dictionary")
|
||||
if session_id := result.get("session_id"):
|
||||
if session_id := result["session_id"]:
|
||||
sessions[key] = session_id
|
||||
|
||||
last_message = result.get("last_message")
|
||||
if isinstance(last_message, dict) and last_message.get("is_error"):
|
||||
raise RuntimeError("Agent 执行失败")
|
||||
|
||||
answer = result.get("result")
|
||||
if not isinstance(answer, str) or not (answer := answer.strip()):
|
||||
answer = agent_reply_result_text(result)
|
||||
if not answer:
|
||||
raise ValueError("Agent 返回了空回复")
|
||||
response = await asyncio.to_thread(handler.reply_markdown, "ReMe Agent", answer, message)
|
||||
if response is None:
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ class _AgentWrapper(BaseAgentWrapper):
|
|||
self.reply_calls = []
|
||||
self.compact_calls = []
|
||||
self.result_text = "回答"
|
||||
self.is_error = False
|
||||
self.status = "completed"
|
||||
self.error = None
|
||||
|
||||
async def compact_session(self, session_id):
|
||||
self.compact_calls.append(session_id)
|
||||
|
|
@ -163,11 +164,13 @@ class _AgentWrapper(BaseAgentWrapper):
|
|||
async def reply(self, inputs, **kwargs):
|
||||
self.reply_calls.append((inputs, kwargs))
|
||||
session_id = kwargs.get("resume") or "session-1"
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"last_message": {"is_error": self.is_error},
|
||||
"result": self.result_text,
|
||||
}
|
||||
return self._reply_result(
|
||||
session_id=session_id,
|
||||
result=self.result_text,
|
||||
raw={},
|
||||
status=self.status,
|
||||
error=self.error,
|
||||
)
|
||||
|
||||
|
||||
class _Handler:
|
||||
|
|
@ -403,3 +406,23 @@ async def test_stream_client_reconnects_after_server_request(monkeypatch, tmp_pa
|
|||
step.logger.info.assert_called_once_with(
|
||||
"[DingTalkWaitStep] DingTalk server requested reconnect reason='connection is expired'; reconnecting in 1.0s",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
|
||||
@pytest.mark.parametrize("error", [None, "provider failed"])
|
||||
async def test_incomplete_reply_is_not_sent_as_a_success(tmp_path, status, error):
|
||||
"""Partial text cannot turn a failed/interrupted/waiting AgentReply into a Markdown reply."""
|
||||
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
||||
wrapper = _AgentWrapper(app_context=app_context)
|
||||
wrapper.status, wrapper.error = status, error
|
||||
wrapper.result_text = "partial answer"
|
||||
step = DingTalkWaitStep(app_context=app_context, agent_wrapper=wrapper)
|
||||
step.logger = MagicMock()
|
||||
handler, sessions, message = _Handler(), {}, _message()
|
||||
key = _session_key(message)
|
||||
with pytest.raises(RuntimeError, match=error or status):
|
||||
await step._handle_message(message, key, sessions, handler)
|
||||
assert not handler.markdown_replies
|
||||
assert sessions[key] == "session-1"
|
||||
assert not any("completed DingTalk reply" in call.args[0] for call in step.logger.info.call_args_list)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,78 @@
|
|||
# Home plugin
|
||||
|
||||
Provides the `auto_bash` job through an independently enabled ReMe plugin.
|
||||
Adds a self-evolution workspace, the one-time `home_prepare` job, `home_evaluate`, and
|
||||
`auto_bash` through an independently enabled ReMe plugin.
|
||||
|
||||
Source code is grouped by workflow under `src/reme_home/preparation/` and
|
||||
`src/reme_home/evaluation/`. Preparation contains its component and Step, config,
|
||||
code export, transaction recovery, and benchmark data adapters. Evaluation contains
|
||||
its component, Step, input validation, Docker request adapters, and artifacts with
|
||||
failure classification. Shared workspace and host judge components live at the
|
||||
package root.
|
||||
|
||||
Install it into the same Python environment as ReMe:
|
||||
|
||||
```bash
|
||||
reme plugins install plugins/home
|
||||
reme plugins install plugins/beam-judge # Use plugins/lme-judge for LongMemEval.
|
||||
```
|
||||
|
||||
Enable it in an application config with `plugins: [home]`. The plugin then
|
||||
contributes the `auto_bash` job. Start `reme start config=<your-config.yaml>`
|
||||
in one terminal, then call `reme auto_bash command=pwd` from another.
|
||||
Enable it with the matching judge, e.g. `plugins: [home, beam-judge]`, then start the
|
||||
service and call its jobs from another terminal:
|
||||
|
||||
```bash
|
||||
reme start config=<your-config.yaml>
|
||||
# In another terminal:
|
||||
reme auto_bash command=pwd
|
||||
```
|
||||
|
||||
At application startup, Home automatically adds these directories inside the
|
||||
configured ReMe `workspace_dir/home/`:
|
||||
|
||||
```text
|
||||
code/repo/
|
||||
code/runtime/
|
||||
datasets/training-set/cases/
|
||||
datasets/debug-cases/cases/
|
||||
docs/
|
||||
evaluations/
|
||||
sampling/plans/
|
||||
tmp/
|
||||
logs/
|
||||
metadata/
|
||||
```
|
||||
|
||||
ReMe's existing memory, resource, and session directories remain in place.
|
||||
On first initialization, each managed directory under `home/` must be absent or
|
||||
completely empty (an empty child directory still counts as content). File
|
||||
conflicts, symbolic links in managed paths, and overlap with configured ReMe
|
||||
directories cause startup to fail without overwriting their contents.
|
||||
|
||||
Home atomically publishes `home/metadata/workspace.json` after creating
|
||||
the layout. It records layout schema version 1, a workspace UUID, and an aware
|
||||
creation timestamp. Subsequent starts validate this marker, preserve its identity
|
||||
and existing contents, and recreate missing directories. Recreating directories
|
||||
does not recover missing data. Invalid or unsupported markers fail explicitly.
|
||||
A process lock at `home/metadata/workspace.lock` prevents concurrent
|
||||
initialization; the lock file remains on disk, but process exit releases the
|
||||
lock. A competing initializer can retry after the first finishes.
|
||||
|
||||
Failed initialization removes only newly created empty layout directories and
|
||||
temporary manifest files. Existing directories and files are preserved. After
|
||||
an abrupt process termination before the marker is published, inspect any
|
||||
nonempty Home directories before retrying; they are not automatically adopted.
|
||||
Older flat Home workspaces are rejected without moving or deleting files; use a new
|
||||
workspace for this layout. Relative input paths and `spec_path` still resolve against
|
||||
the outer ReMe workspace, not `home/`.
|
||||
Initialization creates the layout only: it does not install datasets, copy
|
||||
candidate code or documentation, initialize Git, or generate experiment/domain
|
||||
configuration, sampling state, or evaluation results.
|
||||
|
||||
For explicitly one-shot CLI execution, use:
|
||||
|
||||
```bash
|
||||
reme start config=<your-config.yaml> job=auto_bash command=pwd
|
||||
```
|
||||
|
||||
The job always uses a file sandbox (Bubblewrap on Linux, Seatbelt on macOS),
|
||||
with `workspace_dir` read-only by default, network access blocked, and a minimal
|
||||
|
|
@ -40,3 +102,856 @@ trusted callers may inject a different backend.
|
|||
Standard output and standard error each have a 1 MiB (1,048,576-byte) limit on
|
||||
all backends. Exceeding either limit terminates the command and returns a
|
||||
failure with bounded partial output and a diagnostic in standard error.
|
||||
|
||||
## Prepare an experiment once
|
||||
|
||||
`home_prepare` runs explicitly after startup. It converts BEAM or LongMemEval
|
||||
inputs, exports a committed ReMe runtime and selected plugins, and initializes a
|
||||
candidate Git repository on branch `init`. It does not install dependencies, run
|
||||
source code, build Docker images, call models, or evaluate the baseline.
|
||||
|
||||
Copy [examples/prepare.yaml](examples/prepare.yaml) into your workspace as
|
||||
`prepare.yaml` and edit the source paths, revision and case IDs. For LongMemEval,
|
||||
set `benchmark.name: longmemeval`, point `source` at its JSON file, remove
|
||||
`variant`, and choose `runtime.plugins: [lme]` and `candidate.paths:
|
||||
[plugins/lme/src]`. Optional `benchmark.evidence_source` accepts reviewed JSONL
|
||||
session evidence. Choose nonempty, unique string `case_ids`, or explicitly set
|
||||
`all_cases: true` without `case_ids`. Relative input paths are workspace-relative;
|
||||
explicit absolute dataset and repository paths are read-only source inputs.
|
||||
`spec_path` must resolve to a file inside the workspace. Home-relative paths and
|
||||
relative traversal are rejected.
|
||||
|
||||
Start a service configured with `plugins: [home]` and your `workspace_dir`, then
|
||||
call it from another terminal using the same service selection as other jobs:
|
||||
|
||||
```bash
|
||||
reme start config=<your-config.yaml>
|
||||
# In another terminal:
|
||||
reme home_prepare spec_path=prepare.yaml
|
||||
```
|
||||
|
||||
For explicitly one-shot local execution:
|
||||
|
||||
```bash
|
||||
reme start config=<your-config.yaml> job=home_prepare spec_path=prepare.yaml
|
||||
```
|
||||
|
||||
Python uses the same Job and normal Application lifecycle; see
|
||||
[examples/prepare.py](examples/prepare.py). Pass exactly one of `spec_path` or
|
||||
`spec` (a configuration dictionary):
|
||||
|
||||
```python
|
||||
from reme.application import Application
|
||||
|
||||
app = Application(workspace_dir="/experiments/run-1", plugins=["home", "beam-judge"], service={"backend": "cli"})
|
||||
try:
|
||||
await app.start()
|
||||
response = await app.run_job("home_prepare", spec=preparation_config)
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
finally:
|
||||
await app.close()
|
||||
```
|
||||
|
||||
All prepared outputs live under `workspace_dir/home/` (paths below are relative to `home/`):
|
||||
|
||||
- `domain_spec.yaml`: resolved configuration, including actual selected case IDs
|
||||
and the full source commit SHA.
|
||||
- `datasets/training-set/`: normalized Case/Session/Query JSON and a
|
||||
manifest with case IDs and content fingerprints.
|
||||
- `datasets/debug-cases/`: deterministic small smoke cases with source case IDs
|
||||
in their metadata, or an empty `cases/` directory when debug derivation is disabled.
|
||||
- `code/runtime/` and `code/repo/`: fixed committed runtime and candidate Git
|
||||
repository. See the [workspace and modification boundary](src/reme_home/runtime_docs/architecture-guide.md#workspace-and-modification-boundary)
|
||||
for their roles and editable scope. Source exports exclude uncommitted edits;
|
||||
`install_projects` paths are relative to the assembled evaluation tree.
|
||||
- `docs/architecture-guide.md` and `docs/validation-artifact-guide.md`: guides
|
||||
shipped with the installed Home plugin, covering candidate development and
|
||||
investigation of evaluation artifacts.
|
||||
- `metadata/preparation.json`: completion record, config/data/code
|
||||
fingerprints, initial candidate commit, plugin and installation plan.
|
||||
|
||||
Before preparation, code and docs directories must be empty, dataset directories may
|
||||
contain only their empty `cases/` skeleton, and `domain_spec.yaml` must be absent.
|
||||
Candidate paths must be present in the selected source, cannot overlap, and may
|
||||
select ReMe core or plugin files. Runtime plugins are selected explicitly; the host plugin list is not copied. All selected source files must be regular Git blobs; symlinks and
|
||||
submodules are rejected. Source references are explicit Git revisions; this
|
||||
version does not support uncommitted working-tree snapshots or remote fetching.
|
||||
|
||||
Preparation holds the same process lock as layout initialization. Inputs are
|
||||
staged beneath `home/metadata/preparation-transaction/`, flushed, and
|
||||
published with a durable rollback journal. Reported preparation failures restore
|
||||
pre-existing files and directories (filesystem timestamps are not part of
|
||||
the contract); the corrected request can be retried. Interrupted transactions
|
||||
are recovered at startup or before another preparation. A completed preparation
|
||||
rejects **every** later call, including identical parameters. There is no reset or
|
||||
force-overwrite option: use a new workspace for another experiment.
|
||||
|
||||
## Evaluate prepared candidates
|
||||
|
||||
`home_evaluate` runs committed candidate code through
|
||||
[the shared Docker pool](../../deploy/benchmark_docker/README.md), then scores
|
||||
answers through the host Application's `answer_judge` Job. Enable `beam-judge`
|
||||
for BEAM or `lme-judge` for LongMemEval. Home requires exactly one supported judge
|
||||
plugin; enabling additional benchmark judge plugins is an error. On a prepared
|
||||
workspace, startup checks that the judge matches the recorded benchmark. On a
|
||||
new workspace, `home_prepare` checks the match before preparing data or code.
|
||||
A disabled or replaced `answer_judge` Job is rejected.
|
||||
Install ReMe's `benchmark-docker` extra and build the documented base image first.
|
||||
The host and containers need the configured LLM/embedding environment; the host
|
||||
judge additionally uses `JUDGE_MODEL_NAME`. Install the corresponding judge plugin
|
||||
on the host. Its code and prompts come from the host installation, never the
|
||||
candidate repo; runs record a fingerprint of the judge code and configuration. Credentials are never written into
|
||||
the result manifest. No initial evaluation is triggered by `home_prepare`.
|
||||
|
||||
Start a service with Home enabled, then call from another terminal:
|
||||
|
||||
```bash
|
||||
reme start config=<your-config.yaml>
|
||||
# In another terminal: all prepared training cases, all their queries
|
||||
reme home_evaluate
|
||||
```
|
||||
|
||||
Programs inject selections directly, without putting case IDs in the Job YAML:
|
||||
|
||||
```python
|
||||
# app is already started with plugins=["home", "beam-judge"].
|
||||
result = await app.run_job("home_evaluate", case_ids=["1", "2"], concurrency=4)
|
||||
|
||||
# Recover a prior pre-query memory snapshot, skip construction, answer and score again.
|
||||
replayed = await app.run_job(
|
||||
"home_evaluate",
|
||||
case_ids=["1"],
|
||||
reuse_construction_from="evaluations/init/<commit-prefix>/<run-id>",
|
||||
)
|
||||
```
|
||||
|
||||
Omitted/null `case_ids` selects the entire installed dataset, including when a
|
||||
reuse source is supplied. Empty, duplicate and unknown selections fail before
|
||||
Docker starts. Optional `query_ids` maps case IDs to nonempty query-ID lists;
|
||||
omitted entries run all queries for that case. Evaluation always uses the current
|
||||
HEAD of `home/code/repo` and records its commit in the results. Run the baseline
|
||||
before modifying the prepared candidate. Uncommitted modifications, staged changes
|
||||
and untracked files reject evaluation before Docker starts, as do changed fixed
|
||||
runtimes/datasets.
|
||||
The source snapshot combines fixed ReMe with complete plugins from the selected
|
||||
candidate commit; candidate core roots replace their baseline roots, including
|
||||
deleted files. Source snapshots exclude `.git` and credentials.
|
||||
|
||||
`reuse_construction_from` must identify a completed training-set run inside this
|
||||
Home's `evaluations` directory, either absolutely or relative to `home/`. There
|
||||
is no `eval_only` toggle. Every selected case must have a successful construction
|
||||
and a compatible, checksum-verified tar; any missing/invalid case rejects the
|
||||
entire request without silently skipping or rebuilding it. Query-only replay
|
||||
uses current candidate code over the donor's memory, not donor answers/scores.
|
||||
For explicit `reuse_construction_from`, the caller must establish construction
|
||||
equivalence. Automatic sampled/budgeted selection instead invokes the host's
|
||||
equivalence reviewer and 60% replay policy. Runs record both commits.
|
||||
|
||||
Results use `home/evaluations/<branch>/<commit-prefix>/<ascending-run-id>/`.
|
||||
The [artifact guide](src/reme_home/runtime_docs/validation-artifact-guide.md#saved-files)
|
||||
is the reference for saved files, authoritative query records, compaction and
|
||||
failure evidence. Its [summary reference](src/reme_home/runtime_docs/validation-artifact-guide.md#summary-fields)
|
||||
covers score denominators, token accounting, debug fields and summary rebuilding.
|
||||
Before comparing runs, verify completed scored execution, infrastructure outcomes,
|
||||
candidate code and complete case/query coverage.
|
||||
|
||||
The Job returns `success=false` for infrastructure/judge/artifact failures while
|
||||
preserving its structured answer and result directory; candidate failures remain
|
||||
ordinary evaluation observations. Successful Jobs returning empty answers count
|
||||
as candidate failures. Construction failure-rate tolerance applies only to
|
||||
candidate errors; infrastructure or unclassified failures always reject construction.
|
||||
See [failure interpretation](src/reme_home/runtime_docs/validation-artifact-guide.md#investigating-a-result)
|
||||
for structured error classification.
|
||||
|
||||
Run/case/query `failure.json` files preserve diagnostics. Interrupted/fail-fast
|
||||
runs publish `partial_summary.json`, not a completed summary. New executions
|
||||
always allocate a new run; they never overwrite previous results. For scored runs, default
|
||||
`fail_fast=false` keeps independent cases running. The shared pool executes each
|
||||
case's build followed by queries, rather than Meta-ReMe's global construction
|
||||
barrier/query-stealing schedule. `max_construction_failure_rate` defaults to the
|
||||
old strict-below-3% rule; other pool callers retain strict build-success behavior.
|
||||
|
||||
`debug=true` selects prepared debug cases and runs smoke inputs without a judge.
|
||||
It defaults to `fail_fast=true`; scored runs (`debug=false`, the default) use
|
||||
`fail_fast=false`. An explicitly supplied `fail_fast` overrides either default.
|
||||
Debug requires successful construction and nonempty, error-free answers, and
|
||||
returns `success=false` for candidate failures as well as infrastructure failures.
|
||||
It cannot reuse construction. Case/query selections refer to the selected mode's
|
||||
dataset; omitted selections run all its cases/queries.
|
||||
Debug results retain execution counts and artifacts, with null mean scores.
|
||||
See the [summary reference](src/reme_home/runtime_docs/validation-artifact-guide.md#summary-fields)
|
||||
for coverage fields and manifest mode.
|
||||
|
||||
The former `dataset_kind` input is rejected; use `debug` instead. Debug results
|
||||
must not be used to assess answer correctness or candidate quality. No real
|
||||
Docker/model evaluation is required by Home's isolated unit tests.
|
||||
|
||||
Only Home's writers cooperate with this lock. Keep the preparation target
|
||||
locations exclusively owned by this task while preparing and recovering; other
|
||||
processes must not write there. Recovery requires a writable, functioning
|
||||
filesystem; if rollback fails, its journal remains for the next attempt and the
|
||||
Job reports the error. Calls with `_allowed_paths` restrictions are rejected
|
||||
because preparation owns multiple workspace output locations.
|
||||
|
||||
## Bounded agent foundation
|
||||
|
||||
`reme_home.agent.BoundedAgentWrapper` inherits ReMe's `AsAgentWrapper` and is
|
||||
registered as the `home_bounded` agent-wrapper backend when Home is enabled.
|
||||
It reuses ReMe's Job tools, hidden `injected_job_kwargs`, `tool_context_id`,
|
||||
built-in tools, permissions, model configuration, skills, token accounting and
|
||||
persisted sessions. Home configures a wrapper for the single-case investigation
|
||||
Job below; no investigation or optimization runs automatically. Configure other
|
||||
agents explicitly, alongside the required model and Home plugins:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
agent_wrapper:
|
||||
investigator:
|
||||
backend: home_bounded
|
||||
as_llm: default
|
||||
react_config:
|
||||
max_iters: 40
|
||||
time_budget_minutes: 30 # Optional advisory budget; null disables it.
|
||||
job_tools: [home_evaluate]
|
||||
injected_job_kwargs:
|
||||
case_ids: ["1"] # Hidden from the model and enforced by the host.
|
||||
```
|
||||
|
||||
Call `reply()` or `reply_stream()` through the normal ReMe wrapper interface;
|
||||
call-time options override component defaults. Each built agent owns its own
|
||||
timer and request state. The internal `BoundedReActAgent` still subclasses the AS
|
||||
runtime to implement loop hooks; callers use `BoundedAgentWrapper` for ReMe wiring.
|
||||
The lower-level `BoundedReActAgent` and `RunTimeBudgetTimer` exports remain available.
|
||||
|
||||
Each reply invocation starts and stops its advisory timer, including on errors and
|
||||
cancellation. Iteration reminders occur at 50%, 75%, 90% and 95%; coincident or
|
||||
skipped thresholds produce one reminder. Time reminders occur at 60%, 75%, 90%,
|
||||
100%, then each additional 10%, alternating temporary and retained messages. Time
|
||||
budgets do not cancel model calls or tools. Completed context replacements are
|
||||
counted in time reminders. Reminders use native AS hint blocks, preserving the
|
||||
reply's cumulative token usage across reasoning and tool calls. Use separate
|
||||
agent instances for concurrent work.
|
||||
|
||||
The AgentScope loop owns the iteration cap and its single finalization call.
|
||||
For text reports, Home removes all tools from that call, retains the original
|
||||
task, and trims oldest messages if needed to fit the context budget. The final
|
||||
report is still attempted if its preceding context compression fails; ordinary
|
||||
compression errors outside finalization and cancellation continue to propagate.
|
||||
The final response is buffered until complete, including for streaming models; unsolicited
|
||||
tool calls are discarded. If finalization fails or returns no text, the reply
|
||||
explicitly identifies any retained prior model output as incomplete. Cancellation
|
||||
and provider interruption keep AgentScope's interruption behavior. Native
|
||||
structured-output requests retain the SDK's schema and grace-iteration behavior.
|
||||
Events and final-message token usage retain AgentScope's existing semantics.
|
||||
|
||||
## Candidate versions and code inspection
|
||||
|
||||
Home registers the `home.code` Component and ten ordinary Step-backed Jobs. They
|
||||
operate only on the prepared `home/code/repo`, sharing the preparation/evaluation
|
||||
process lock. Enable Home and prepare the workspace before calling them.
|
||||
|
||||
Branch names must be valid local Git branch names of 1–100 characters and serve as
|
||||
single evaluation-directory names, so `/` and `\` are forbidden.
|
||||
|
||||
| Job | Inputs and behavior |
|
||||
| --- | --- |
|
||||
| `home_code_status` | Current branch, full commit SHA, exact changed paths and pending merge state. |
|
||||
| `home_list_candidate_branches` | Local branches and candidates, excluding protected `init` from the candidate count. |
|
||||
| `home_create_candidate_branch` | `branch_name`: fork current HEAD and switch to a new candidate. |
|
||||
| `home_switch_candidate_branch` | `branch_name`: switch to an existing local branch, including `init`. |
|
||||
| `home_merge_candidate_branch` | `branch_name`: merge another candidate without committing; conflicts are aborted. |
|
||||
| `home_commit_candidate` | `message`: stage and commit all visible changes within prepared `candidate_paths`. |
|
||||
| `home_code_diff` | Omit inputs for tracked changes against HEAD; supply `base_revision` and `revision` to compare commits. With only `base_revision`, compare the working tree against that commit. |
|
||||
| `home_list_code_tree` | Optional `revision`, literal `path`, and `max_results` (default 500, maximum 2000). |
|
||||
| `home_read_code_file` | Required `path`; optional `revision`, one-based `start_line` and inclusive `end_line`. |
|
||||
| `home_search_code` | Required Python regex `pattern`; optional `revision`, literal `path`, and `max_results` (maximum 30). |
|
||||
|
||||
Start the configured service in one terminal, then use a second terminal:
|
||||
|
||||
```bash
|
||||
reme start config=/absolute/path/to/home.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
reme home_code_status
|
||||
reme home_create_candidate_branch branch_name=retrieval-routing
|
||||
# Edit files under the prepared candidate_paths, then inspect and commit.
|
||||
reme home_code_diff
|
||||
reme home_commit_candidate message='Improve evidence routing'
|
||||
reme home_evaluate debug=true
|
||||
reme home_evaluate
|
||||
```
|
||||
|
||||
Branch names must be single path-safe names without slashes. Creation, switching
|
||||
and merging require a clean checkout, including untracked files and pending
|
||||
merges. `init` must continue to point to the preparation's initial commit; commits
|
||||
and merges into it are rejected. Detached candidates, symlinks, submodules and
|
||||
changes outside `candidate_paths` are rejected by the managed mutation tools.
|
||||
Merges require an explicit later commit. No remote operations, branch deletion,
|
||||
amend, reset or rebase are exposed. Empty ordinary commits fail through Git.
|
||||
These guarantees apply to cooperating Home Jobs; do not grant an optimizer
|
||||
unrestricted shell writes or direct access to `.git` if it must obey this boundary.
|
||||
Cancellation waits for already-started Git work to finish before returning.
|
||||
|
||||
Code-navigation Jobs resolve `revision` (default `HEAD`) to a full SHA once per
|
||||
call, read raw committed objects and never execute candidate code. Dirty working
|
||||
files and Git archive attributes do not affect these reads. Pin the same SHA
|
||||
across a diagnostic session through ReMe's hidden injection:
|
||||
|
||||
```yaml
|
||||
job_tools: [home_list_code_tree, home_read_code_file, home_search_code]
|
||||
injected_job_kwargs:
|
||||
revision: "<full-commit-sha>"
|
||||
```
|
||||
|
||||
The wrapper hides injected fields from the model and rejects attempts to supply
|
||||
them. For comparisons, configure two agent invocations or two named Job aliases
|
||||
with separately pinned revisions. `HomeCodeStep` takes a fixed `operation` in
|
||||
Step configuration; repository location and operation are never model-selected.
|
||||
The Python entry point is `await home_code.run(operation, **arguments)`.
|
||||
|
||||
Reads are limited to 300 lines and 100,000 content characters, with explicit
|
||||
truncation flags. Search accepts Python multiline regex, merges adjacent match
|
||||
contexts, and displays at most five lines per result block and 100,000 content
|
||||
characters. Files above 5 MB and searches above 100 MB are rejected; regex
|
||||
matching runs in a separate process with a ten-second timeout. Use narrower
|
||||
paths or line ranges to continue inspecting. These tool-operation timeouts are
|
||||
independent of the bounded agent's advisory time budget.
|
||||
|
||||
Evaluation also checks the standalone repository identity and protected baseline
|
||||
before taking its existing clean-HEAD snapshot. Formal evaluation deduplication,
|
||||
automatic ancestor/donor selection and optimization scheduling remain separate
|
||||
policies; these Jobs do not enable an optimizer or change evaluation selection.
|
||||
|
||||
## Evaluation sampling and history
|
||||
|
||||
`home_evaluate` is the single evaluation Job. Set `sampling=true` for adaptive
|
||||
case/query selection, or leave it false for a full baseline or an explicit
|
||||
host-provided `case_ids`/`query_ids` selection. `sampling=true` conflicts with
|
||||
explicit selections in scored mode. `debug=true` ignores the sampling flag,
|
||||
uses debug cases and never reads, writes or registers evaluation history.
|
||||
The former `home_validate` Job is removed; use `home_evaluate sampling=true`.
|
||||
|
||||
Scored evaluations reject a Git tree already evaluated anywhere in this workspace,
|
||||
including another branch or an empty commit. This applies to full, sampled and
|
||||
construction-replay runs, even with `record_history=false`. Changing selections or
|
||||
evaluation options does not bypass the check. The error points to the previous
|
||||
result; inspect it and commit a code change before evaluating again. The Job returns
|
||||
`success=false` with an answer containing `status="rejected"`,
|
||||
`error_code="UNCHANGED_CANDIDATE"`, `error` and the previous `result_dir`.
|
||||
`metadata.result_dir` also points to that result. Debug runs neither trigger nor
|
||||
participate in this check.
|
||||
|
||||
An infrastructure failure or interrupted attempt can retry the exact same commit
|
||||
with a new run ID; the previous artifacts remain intact. Recovery uses exactly the
|
||||
failed attempt's case/query IDs and order, even if the caller changes sampling,
|
||||
budgets or explicit selections. It reruns the selected batch rather than only
|
||||
failed queries, and records an explicit selection plan with `recovery_from`
|
||||
pointing to the prior run. Other execution options are not frozen; construction
|
||||
reuse still follows `reuse_construction_from`. Candidate-code failures
|
||||
and valid low scores require a code change. A completed summary with infrastructure
|
||||
errors allows retry even when other cases succeeded; an incomplete attempt with
|
||||
candidate-failure evidence and no infrastructure failure does not. An empty commit
|
||||
cannot convert a recoverable attempt into a new candidate. The guard reads saved
|
||||
artifacts under the formal evaluation lock and does not depend on sampling caches
|
||||
or successful-history registration.
|
||||
|
||||
Selection and optimization-history recording are independent. `record_history`
|
||||
defaults to `true` for compatibility: a first full optimization baseline contributes
|
||||
observations even with `sampling=false`. Set `record_history=false` to score code
|
||||
without feeding optimization history. This still saves the manifest, scores and
|
||||
result artifacts, but never registers a history entry, increments either counter
|
||||
or updates sampling observations. Rebuilding history preserves that exclusion.
|
||||
Debug always skips history regardless of either flag.
|
||||
|
||||
Evaluation parameters have distinct names and lifetimes:
|
||||
|
||||
| Parameter | Where it belongs | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `sampling` | Job input, default `false` | Enable adaptive selection for this invocation. |
|
||||
| `record_history` | Job input, default `true` | Record started rounds and successful results for optimization. |
|
||||
| `sampling_policy` | Component defaults or host Job input | Case/query limits and weighting settings. |
|
||||
|
||||
Home registers these Component defaults automatically; override the policy in
|
||||
application configuration when needed:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
home.evaluation:
|
||||
default:
|
||||
backend: home_evaluation
|
||||
sampling_policy:
|
||||
max_cases: 5
|
||||
max_queries: null
|
||||
temperature: 1.0
|
||||
ewma_alpha: 0.3
|
||||
uniform_exploration: 0.2
|
||||
```
|
||||
|
||||
The Component policy is immutable. Each invocation validates its own policy,
|
||||
merging a partial `sampling_policy` over Component defaults and persisting the
|
||||
effective values with that run. Explicit null `max_queries` removes the query
|
||||
budget. The previous Component configuration key `sampling` is now
|
||||
`sampling_policy`; passing a policy object as the Job's boolean `sampling` fails.
|
||||
Normal ReMe Job defaults also work: set `sampling: true` and optionally
|
||||
`sampling_policy` directly under `jobs.home_evaluate`. Call-time values take
|
||||
precedence over same-named Job defaults; a call-time policy object replaces the
|
||||
Job-default policy object, then merges with Component defaults.
|
||||
|
||||
With the configured service running, use a second terminal. These are alternative
|
||||
invocations; commit a code change between scored evaluations:
|
||||
|
||||
```bash
|
||||
reme home_evaluate sampling=false # Full baseline.
|
||||
reme home_evaluate sampling=false record_history=false # Standalone full evaluation.
|
||||
reme home_evaluate sampling=true # Adaptive formal evaluation.
|
||||
reme home_evaluate debug=true sampling=true # Debug only; no history feedback.
|
||||
```
|
||||
|
||||
Host Python callers can override the policy for one invocation without changing
|
||||
the next invocation's defaults:
|
||||
|
||||
```python
|
||||
result = await app.run_job(
|
||||
"home_evaluate",
|
||||
sampling=True,
|
||||
sampling_policy={"max_cases": 3, "max_queries": 12},
|
||||
)
|
||||
|
||||
# After committing a code change, score specified prepared cases without
|
||||
# affecting the optimizer's history.
|
||||
standalone = await app.run_job(
|
||||
"home_evaluate",
|
||||
sampling=False,
|
||||
record_history=False,
|
||||
case_ids=["case-a", "case-b"], # Omit to run the full prepared dataset.
|
||||
)
|
||||
```
|
||||
|
||||
`sampling_policy` and explicit case/query IDs stay out of the default agent tool
|
||||
schema. An optimizer that must obey a fixed budget should receive hidden,
|
||||
enforced values through the normal wrapper injection:
|
||||
|
||||
```yaml
|
||||
job_tools: [home_evaluate]
|
||||
injected_job_kwargs:
|
||||
sampling: true
|
||||
record_history: true
|
||||
sampling_policy:
|
||||
max_cases: 3
|
||||
max_queries: 12
|
||||
```
|
||||
|
||||
The wrapper hides injected parameters and rejects model attempts to provide
|
||||
those keys. Use ordinary call arguments when the host should decide the mode
|
||||
per invocation; use injection when that decision must remain fixed for an agent.
|
||||
|
||||
Case/query selection retains dev_meta b53048e4's EWMA loss, staleness, observation
|
||||
uncertainty, un-rechecked error priority, normalized adaptive/uniform mixture,
|
||||
weighted drawing without replacement and fair-share query budget allocation.
|
||||
Policy defaults are unchanged. Positive integer case/query limits, positive
|
||||
finite temperature, alpha in `(0, 1]` and uniform exploration in `[0, 1]` are
|
||||
validated. Seeds, draw weights, eligibility and exact selections are saved.
|
||||
An explicit construction donor uses the existing dev_meta eligibility rules and
|
||||
checksum checks. A later full rebuild considers the full training set again.
|
||||
|
||||
### Successful evaluation history
|
||||
|
||||
The `home.evaluation_history` Component discovers successful scored training
|
||||
results and rebuilds sampling observations from saved evidence. Low scores remain
|
||||
valid observations; infrastructure failures, debug and incomplete runs are excluded.
|
||||
See [history and sampling evidence](src/reme_home/runtime_docs/validation-artifact-guide.md#evaluation-history-and-adaptive-sampling-evidence)
|
||||
for eligibility, started/successful rounds, saved plans and cache recovery.
|
||||
The full summary is the durable completion boundary: cancellation during later
|
||||
history bookkeeping does not relabel the completed evaluation. Failed registration
|
||||
discards only derived caches, which can rebuild from saved run evidence.
|
||||
|
||||
```python
|
||||
history = app.context.components["home.evaluation_history"]["default"]
|
||||
results = history.list_results() # Typed EvaluationRecord objects, chronological.
|
||||
result = history.get_result(results[-1].run_key)
|
||||
history.rebuild() # Rebuild sampling observations from saved artifacts.
|
||||
```
|
||||
|
||||
These synchronous filesystem methods own the workspace lock; async host code can
|
||||
run them in a worker thread. Formal runs remain serialized by
|
||||
`home/metadata/validation.lock`; debug bypasses that lock. Rebuilding changes only
|
||||
derived state; preserve source manifests, plans and result artifacts.
|
||||
|
||||
## Evaluation result inspection
|
||||
|
||||
Read-only inspection includes debug, failed, cancelled, running and standalone
|
||||
(`record_history=false`) runs, independently of successful optimization history.
|
||||
It never registers results, updates sampling, rebuilds summaries or writes artifacts.
|
||||
|
||||
| Job | Use |
|
||||
| --- | --- |
|
||||
| `home_list_validation_results` | Find runs, optionally filtered by `branch_name` and `commit_sha`. |
|
||||
| `home_inspect_validation` | Inspect a `validation_path`, then narrow with `case_id` and `query_id`. |
|
||||
| `home_trace_query_evidence` | Link a query to source sessions, memory checkpoints and saved evidence without a model call. |
|
||||
| `home_inspect_memory_history` | Page through one case's exported memory Git checkpoints. |
|
||||
| `home_compare_memory` | Compare committed memory within a run or across evaluations of the same case. |
|
||||
|
||||
With the configured service running, use a second terminal:
|
||||
|
||||
```bash
|
||||
reme home_list_validation_results branch_name=candidate limit=10
|
||||
reme home_inspect_validation validation_path=evaluations/candidate/abcdef0/1
|
||||
reme home_inspect_validation validation_path=evaluations/candidate/abcdef0/1 case_id=case1 query_id=query1
|
||||
reme home_trace_query_evidence validation_path=evaluations/candidate/abcdef0/1 case_id=case1 query_id=query1
|
||||
reme home_inspect_memory_history validation_path=evaluations/candidate/abcdef0/1 case_id=case1
|
||||
reme home_compare_memory validation_path=evaluations/candidate/abcdef0/1 case_id=case1 base_ref=HEAD~1
|
||||
reme home_compare_memory validation_path=evaluations/candidate/abcdef0/1 target_validation_path=evaluations/candidate/1234567/1 case_id=case1
|
||||
```
|
||||
|
||||
`validation_path` is absolute or relative to `home/`. See
|
||||
[read-only evaluation inspection](src/reme_home/runtime_docs/validation-artifact-guide.md#read-only-evaluation-inspection)
|
||||
for pagination, commit-prefix resolution, previews and source pointers, and
|
||||
[memory checkpoint tools](src/reme_home/runtime_docs/validation-artifact-guide.md#memory-checkpoint-tools)
|
||||
for evidence provenance, missing-history states, comparison references and limits.
|
||||
|
||||
Host callers use the same Jobs through `app.run_job()`. For case-scoped agent
|
||||
tools, pin `validation_path` and `case_id` through `injected_job_kwargs`;
|
||||
the wrapper hides those fields and rejects model overrides. These Jobs reject
|
||||
path escapes, symlinks and restricted `_allowed_paths` contexts.
|
||||
|
||||
## Case-study batches
|
||||
|
||||
`home_case_study(evaluation_path, case_ids, focus=...)` investigates up to eight
|
||||
unique case IDs in input order, sharing the evaluation and unchanged focus.
|
||||
Cases run concurrently, each with fresh agent state, sandbox and timer. The
|
||||
host Step setting `concurrency` defaults to 4, matching dev_meta, and must be a
|
||||
positive integer; set it to 1 for sequential execution. Results preserve input
|
||||
order regardless of completion order. Cancelling the batch cancels running and
|
||||
queued cases and waits for their cleanup before returning. The
|
||||
whole selection is validated before model calls; a case execution failure is
|
||||
returned individually without cancelling later cases. It follows dev_meta's
|
||||
case-study workflow: trace judging and answer failures backward through retrieval,
|
||||
memory construction, source sessions and
|
||||
the evaluated implementation. The text report cites evidence paths/lines or
|
||||
JSON fields and distinguishes supported causes, hypotheses and missing evidence;
|
||||
it does not prescribe fixes.
|
||||
|
||||
The `home_case_study` component uses `home_case_study_agent`, a thin subclass of
|
||||
the bounded wrapper, defaulting to the dedicated `as_llm: home_case_study`
|
||||
component with model `qwen3.7-max`, 60 iterations and a 30-minute advisory
|
||||
budget, matching dev_meta's default budgets. It uses `LLM_BACKEND`, `LLM_API_KEY`
|
||||
and `LLM_BASE_URL` without changing the evaluation judge's model. Override
|
||||
`components.as_llm.home_case_study.model` to choose another model, or
|
||||
`components.agent_wrapper.home_case_study.as_llm` to select another host component. ReMe owns model
|
||||
startup, token accounting and saved session transcripts. The response answer is
|
||||
`{"results": [...]}`; each item contains `case_id`, `success`, and either `report`
|
||||
with its session ID/usage or `error`. Metadata includes `evaluation_path`, the
|
||||
evaluated commit and total `usage` (null if any usage is unavailable).
|
||||
Response success is false if any case fails; completed sibling reports remain
|
||||
available. No case scratch directory or intermediate analysis files are created.
|
||||
Returned reports include the agent's `status` and `error` in single-case metadata
|
||||
or each batch result. Only `completed` succeeds; interrupted, waiting and
|
||||
iteration-exhausted replies retain their report text and reported usage with `success=false`.
|
||||
|
||||
The host bounds each returned report using `output_token_limit` (default 2,000)
|
||||
with a 10% allowance, rounded down. The agent sees only the configured budget.
|
||||
Single-case response metadata and each batch result include `estimated_tokens`,
|
||||
`original_estimated_tokens` and `truncated`. Saved sessions retain the full report,
|
||||
and usage retains the full model cost. The host Step's optional `tokenizer_source`
|
||||
selects a local tokenizer file/directory or Hugging Face source through
|
||||
`reme.utils.qwen_token_tools`; its default is `Qwen/Qwen3.8-27B`. Exact counting
|
||||
requires `reme-ai[qwen-tokenizer]`; if unavailable, the utility warns and uses its
|
||||
UTF-8 byte estimate for counting and truncation.
|
||||
|
||||
`validation_path` remains an alias for `evaluation_path`. Legacy `case_id` calls
|
||||
still return a text report with per-case metadata. Do not supply both path names
|
||||
or both case selectors. Other inspection Jobs retain their `validation_path` argument.
|
||||
|
||||
The case-study toolkit and mandatory read-only OS sandbox are described in the
|
||||
[artifact guide](src/reme_home/runtime_docs/validation-artifact-guide.md#single-case-diagnostic-agent).
|
||||
The host pins case/evaluation selectors and code revision. Direct code reads can
|
||||
see current worktree changes; use revision-pinned Jobs to establish evaluated behavior.
|
||||
|
||||
Home agents build their own sandboxed `python_execute` tool; they do not depend on
|
||||
the benchmark configuration's local-only `python_execute` Job or require enabling
|
||||
its HTTP/MCP exposure.
|
||||
|
||||
The agent tool accepts either `code` or an existing `file`, optional file-only
|
||||
`args`, and a `timeout` in seconds (default 60, maximum 600). It uses `python -I -B`
|
||||
and returns returncode/stdout/stderr/timed_out, preserving partial stdout and stderr
|
||||
on timeout. Both executors reuse bounded output, timeouts, cancellation cleanup
|
||||
and network isolation from the platform backend.
|
||||
|
||||
Linux requires Bubblewrap and, with the default 4096-MiB limit, a writable
|
||||
delegated cgroup v2. macOS requires Seatbelt (`sandbox-exec`); memory limits are
|
||||
not enforced there. The Job probes sandbox startup before calling the model and
|
||||
never falls back to an unrestricted executor. The command whitelist parser is
|
||||
not used by this agent. Batch concurrency, sandbox policy, output-report token guidance and executor
|
||||
memory policy are host Step configuration, not model arguments:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
home_case_study:
|
||||
steps:
|
||||
- backend: home_case_study_step
|
||||
agent_wrapper: home_case_study
|
||||
concurrency: 4
|
||||
output_token_limit: 2000
|
||||
# memory_limit_mib: 0 # Explicitly disable Linux cgroup memory limiting if needed.
|
||||
```
|
||||
|
||||
With the configured service running, call from another terminal:
|
||||
|
||||
```bash
|
||||
reme home_case_study evaluation_path=evaluations/candidate/abcdef0/1 'case_ids=["case1","case2"]' 'focus=Trace low-scoring queries'
|
||||
```
|
||||
|
||||
Calls with `_allowed_paths` restrictions fail closed, as other Home artifact
|
||||
Jobs do. Structured inspection tools run in the host with their existing
|
||||
containment checks; only Bash/Python subprocesses run inside the OS sandbox.
|
||||
|
||||
## Diagnostic and optimizer agents
|
||||
|
||||
`home_diagnose` and `home_optimize` provide the minimal Meta-ReMe workflow using
|
||||
Home's existing Jobs. Enable Home with a benchmark judge and prepare the
|
||||
workspace first. Start the configured service in one terminal:
|
||||
|
||||
```bash
|
||||
reme start config=/absolute/path/to/home.yaml
|
||||
```
|
||||
|
||||
Call from another terminal:
|
||||
|
||||
```bash
|
||||
reme home_diagnose 'evaluation_paths=["evaluations/init/abcdef0/1"]' 'instruction=Explain the lowest-scoring cases'
|
||||
reme home_optimize 'instruction=Improve evidence retrieval while reducing query tokens'
|
||||
```
|
||||
|
||||
Diagnosis requires explicit, unique result directories, including failed runs
|
||||
with saved evidence. Each call creates a fresh diagnostic agent, delegates deep
|
||||
case investigation to `home_case_study`, and returns ranked, evidence-supported
|
||||
causes without prescribing fixes. An optimizer invocation automatically injects
|
||||
its available caller messages and compression summary; the model cannot supply
|
||||
or override that trajectory. Standalone calls record an empty trajectory.
|
||||
Reports and trajectories are saved under `home/logs/diagnostic-reports/` and
|
||||
`home/logs/diagnostic-trajectories/` with their evidence paths and session IDs.
|
||||
|
||||
Optimization reuses a completed full initial baseline covering every prepared
|
||||
training case and query, with no infrastructure errors. Otherwise it runs that
|
||||
baseline first, requiring clean `init` to be checked out. It never switches away
|
||||
from an existing candidate to repair a missing baseline. Baseline observations
|
||||
feed sampling history. Candidate execution failures retain Home's existing
|
||||
failure thresholds; low scores are valid evidence. Infrastructure failures stop
|
||||
baseline startup and are distinguished from candidate defects during iteration.
|
||||
|
||||
The optimizer diagnoses, edits declared `candidate_paths`, commits through
|
||||
managed Git Jobs, runs debug until execution passes, then evaluates using the
|
||||
host's adaptive sampling policy. The host automatically chooses whether to
|
||||
rebuild memory or replay compatible construction snapshots, as described below.
|
||||
The agent cannot change execution settings, selections, sampling mode, or
|
||||
construction reuse through its evaluation tool; only `debug` is model-selected.
|
||||
Normal `home_evaluate` callers retain their existing interface and replay support.
|
||||
Unchanged candidates and infrastructure retries follow the existing repetition
|
||||
rules. There is no architecture agent in this workflow.
|
||||
|
||||
Sampled or budgeted formal evaluations automatically find the nearest ancestor
|
||||
commit with a completed scored training run and reusable construction snapshots.
|
||||
The host checks dataset/runtime/config/model fingerprints, archive integrity,
|
||||
successful source answers and the existing 5% construction-failure limit. Invalid
|
||||
sources are skipped while looking for an older usable ancestor. The
|
||||
`home_construction_equivalence` agent compares the exact source and candidate
|
||||
commits. If construction is unchanged, the host draws once: 60% query-only replay,
|
||||
40% full construction and evaluation. Changed or uncertain construction always
|
||||
rebuilds. Substantive changes to `auto_memory.py` or its YAML/hyphenated variants
|
||||
conservatively force rebuilding without a model call, matching dev_meta.
|
||||
|
||||
The optimizer cannot request query-only evaluation, choose a source, alter the
|
||||
probability or bypass review. Its evaluation tool still exposes only `debug`.
|
||||
Baseline and debug always rebuild; ordinary unsampled, unbudgeted calls and
|
||||
explicit host `reuse_construction_from` calls retain their existing interfaces.
|
||||
Replay sampling stays within reusable source cases and may select new queries
|
||||
from those cases. Full rebuild sampling considers all prepared cases. An
|
||||
infrastructure retry preserves the previous mode, source and frozen selection
|
||||
without another review or probability draw. Both full and automatically reviewed
|
||||
query-only results can support the optimizer's final selection; the initial
|
||||
baseline must still be full and cover all cases/queries.
|
||||
|
||||
The equivalence agent receives one read-only export under
|
||||
`home/tmp/construction-equivalence-*/`: both pinned source trees, their complete
|
||||
diff, a copy of the fixed runtime and configuration, and only the chosen source's
|
||||
pre-query construction workspaces, build logs and minimal construction metadata.
|
||||
Git metadata is removed from exported workspaces. It cannot read live code,
|
||||
datasets, query results, scores, other evaluations, optimizer trajectory or host
|
||||
logs. Read, Glob, Grep and Bash all use the OS sandbox; there are no writable
|
||||
paths, Home Job tools or network access. Reports are host-written under
|
||||
`home/logs/construction-equivalence/`. Temporary exports, including partial copies,
|
||||
are deleted when review finishes, fails or is cancelled. Reports retain commit and
|
||||
source-evaluation provenance; `source_path` records the temporary location used
|
||||
during review and is no longer available afterward. Model or malformed-report failures are
|
||||
recorded and force a full rebuild; sandbox setup failure stops evaluation.
|
||||
Both construction-equivalence and candidate-admission reports accept JSON wrapped
|
||||
in prose or a code fence, ignore think blocks, and repair trailing commas outside
|
||||
strings. If parsing or schema validation still fails, the reviewer makes one
|
||||
format-only structured-output call with thinking disabled and no tools. This call
|
||||
retains the system prompt, compression summary and investigation history, including
|
||||
the invalid final reply. Successful corrections are saved in the session and their
|
||||
token usage is included in the report. If correction fails, the existing fallback
|
||||
applies: construction is rebuilt or candidate admission allows evaluation.
|
||||
Reviewer shell commands receive an explicit minimal environment instead of
|
||||
inheriting host model credentials or other environment values.
|
||||
Cancellation propagates, and a branch/commit change during review fails before
|
||||
sampling or spending a validation slot.
|
||||
|
||||
Evaluation manifests and responses retain `automatic_construction_reuse`,
|
||||
including source/base/candidate commits, the equivalence report and, when drawn,
|
||||
the probability, random draw and selected mode. Configure the reviewer through
|
||||
`components.as_llm.home_construction_equivalence` (default `qwen3.8-max`, 128,000
|
||||
context tokens) and
|
||||
`components.agent_wrapper.home_construction_equivalence.react_config.max_iters`
|
||||
(default 100). Optional
|
||||
`components.home.evaluation.default.construction_equivalence_memory_limit_mib`
|
||||
uses the existing OS sandbox memory-limit contract.
|
||||
|
||||
During the first `max_evaluations // 2` non-init formal starts, a fresh
|
||||
`home_candidate_gate` agent reviews each new candidate before sampling or starting
|
||||
an evaluation sandbox. It compares the candidate with its nearest previously
|
||||
evaluated ancestor (falling back to `init`). It accepts runtime mechanism changes
|
||||
and concrete execution, safety or contract fixes; it rejects prompt/parameter-only
|
||||
tuning, cosmetic changes and dead code during this early exploration phase.
|
||||
Ambiguous evidence is accepted. Baselines, debug, infrastructure retries and later
|
||||
refinement bypass admission. Ordinary unbudgeted `home_evaluate` calls remain
|
||||
ungated; supplying `max_evaluations` enables the same admission policy.
|
||||
|
||||
The reviewer receives only read-only source exports of the two exact commits and
|
||||
their complete diff under `home/tmp/candidate-gate-*/`. Its Read, Glob, Grep and
|
||||
Bash tools all use the OS sandbox, with no writable project paths or network.
|
||||
The live candidate repository, Git metadata, fixed runtime, datasets, evaluations,
|
||||
logs and optimizer trajectory are not granted. It cannot invoke Home Jobs.
|
||||
Sandbox setup failure stops evaluation; model or malformed-report failures are
|
||||
recorded and allow evaluation, following dev_meta's admission fallback policy.
|
||||
Cancellation propagates. A candidate branch/commit change during review requires
|
||||
a retry rather than evaluating unreviewed code.
|
||||
|
||||
`CANDIDATE_GATE_REJECTED` returns the reason and review path without drawing a
|
||||
sample, allocating evaluation artifacts or spending a validation slot. Host-written
|
||||
reports under `home/logs/candidate-gates/` retain both commits, decision, reason,
|
||||
session and usage; accepted reviews are also linked from evaluation manifests.
|
||||
Source exports, including partial copies, are deleted on completion, failure or
|
||||
cancellation; the report's `source_path` records their former temporary location.
|
||||
Configure the model via `components.as_llm.home_candidate_gate` and the 60-iteration
|
||||
default via `components.agent_wrapper.home_candidate_gate.react_config.max_iters`.
|
||||
The model defaults to `qwen3.8-max` with a 128,000-token context. Optional
|
||||
`components.home.evaluation.default.candidate_gate_memory_limit_mib` uses the
|
||||
existing OS sandbox memory-limit contract.
|
||||
|
||||
Both agents share the case-study OS sandbox implementation. All file tools,
|
||||
Bash and Python use it, including file metadata and Edit/Write checks. Evidence,
|
||||
fixed runtime and Git metadata are read-only. Each call receives a private
|
||||
scratch directory under `home/tmp/`; only the optimizer additionally receives
|
||||
writes to declared candidate roots. Network is denied. Diagnostic writes are
|
||||
limited to scratch. Host Jobs alone write reports, evaluation artifacts and Git
|
||||
metadata. Sandbox startup is probed before model calls and never falls back to
|
||||
unrestricted execution. Declared write roots must exist and remain real paths.
|
||||
Only one optimizer can run per Home workspace; cancellation releases its lock
|
||||
after nested tool cleanup. Separate diagnostic calls and case studies may run
|
||||
concurrently with fresh state.
|
||||
|
||||
The final optimizer response must identify `branch_name`, full `commit_sha`,
|
||||
`evaluation_path` and `report`. The host verifies a clean checkout at that exact
|
||||
branch/commit and a matching completed, scored, end-to-end evaluation without
|
||||
infrastructure failures. Sampled formal evidence and host-selected,
|
||||
construction-equivalent query-only evidence qualify; debug evidence does not.
|
||||
Keeping the initial baseline is valid. Invalid final claims
|
||||
fail while preserving changes and the raw report in
|
||||
`home/logs/optimization-reports/`. The host verifies provenance rather than
|
||||
ranking candidates by a single average score.
|
||||
|
||||
Dedicated `as_llm` and `agent_wrapper` components are named `home_optimizer` and
|
||||
`home_diagnostic`; override them through ordinary application configuration.
|
||||
Both default to `qwen3.8-max`, with context sizes 160,000 and 128,000, respectively.
|
||||
The optimizer has 3,000 iterations and a 150-minute advisory validation-cycle timer;
|
||||
configure `components.agent_wrapper.home_optimizer.validation_cycle_time_budget_minutes`
|
||||
(null disables timed reminders). The previous `time_budget_minutes` setting is a
|
||||
fallback when the cycle setting is absent. Diagnosis retains 80 iterations and
|
||||
a 60-minute whole-invocation timer. Neither timer enforces a time limit.
|
||||
|
||||
The optimizer follows dev_meta b53048e4: before reasoning it checks 50%, 75%, 90%,
|
||||
then 100% and each additional 10% of the cycle budget. Reminders include elapsed
|
||||
time, budget percentage, completed context compressions and guidance to finish
|
||||
editing, commit, pass the smoke test and begin validation. Temporary and durable
|
||||
reminders alternate, beginning with temporary. The cycle includes nested diagnosis,
|
||||
debug and evaluation time. Only a new counted formal evaluation returning
|
||||
`success` or `completed` resets the clock, thresholds, alternation and compression
|
||||
count; scores and candidate failures do not control reset. As in dev_meta, a
|
||||
completed result can reset even when its case results contain infrastructure errors;
|
||||
an incomplete failure, rejected call, cancellation, debug or pre-sandbox failure
|
||||
cannot. The initial full baseline precedes the first optimizer cycle and is excluded
|
||||
from its formal-validation numbering. A durable hint records the completed
|
||||
validation number and commit prefix, expires that cycle's old timer reminders and
|
||||
announces the next cycle with the remaining formal-validation count.
|
||||
Persisted old reminders are retained as in dev_meta.
|
||||
Hints use AgentScope's current reply message so accumulated token usage is retained.
|
||||
Evaluation tools queue transitions in request state; the optimizer injects them
|
||||
before the next reasoning call, after all tool results have been written. Thus a
|
||||
transition never separates an API tool call from its matching result.
|
||||
|
||||
`components.agent_wrapper.home_optimizer.max_evaluations` defaults to 10, matching
|
||||
dev_meta. This setting and the `home_evaluate` argument were renamed from
|
||||
`max_validations`; update existing configuration and callers to `max_evaluations`.
|
||||
The old name is rejected rather than silently falling back to the default budget.
|
||||
`get_validation_budget` reports consumed and remaining slots, the search
|
||||
phase and next-candidate guidance. A non-init formal run consumes one slot when
|
||||
its first sandbox initializes successfully, including runs that later fail or are
|
||||
cancelled. Initial baselines, debug, rejected calls and pre-start failures are free.
|
||||
Accounting reuses persistent started-run evidence and survives optimizer restarts;
|
||||
removing the rebuildable sampling cache does not restore the budget. The evaluator
|
||||
checks the limit while holding the formal-validation lock, before starting another
|
||||
run. At exhaustion it returns `VALIDATION_BUDGET_EXHAUSTED`; the optimizer selects
|
||||
from existing evidence. The first `max_evaluations // 2` starts favor structural
|
||||
exploration; later starts favor evidence-driven refinement. This guidance is advisory.
|
||||
Ordinary `home_evaluate` calls remain unbounded unless the host supplies
|
||||
`max_evaluations`; all recorded non-init formal starts in that workspace count.
|
||||
|
||||
Sessions and model usage follow ReMe's wrapper conventions; each nested call reports its own usage.
|
||||
The optimizer follows dev_meta's compression recovery policy. Recoverable model
|
||||
or summary-output failures leave context intact below the emergency ratio
|
||||
`(trigger_ratio + 1) / 2`. At or above that ratio (90% for an 80% trigger), old
|
||||
messages are atomically saved under `logs/context-offloads/`; recent context and
|
||||
the existing summary are retained, with a reminder pointing to the readable JSON
|
||||
file. This fallback makes no additional model call and counts as one completed
|
||||
compression. Cancellation, invalid configuration and uncompressible system prompts
|
||||
still propagate during normal operation. Diagnostic and case-study agents retain
|
||||
their existing compression behavior.
|
||||
|
||||
Optimizer and diagnostic agents have `add_draft` and `read_draft` notebook tools.
|
||||
Each keeps at most six reusable lessons in FIFO order, with a suggested 200 tokens
|
||||
per lesson and host truncation at 220 tokens. An optimizer invocation owns its own
|
||||
notebook and a separate diagnostic notebook shared by its successive fresh diagnoses.
|
||||
Standalone diagnoses start with an empty notebook. These in-memory lessons survive
|
||||
context compression, but are discarded when that optimization or diagnosis ends;
|
||||
they are not durable workspace memory and are not shared with case-study agents.
|
||||
|
||||
Host-only execution settings belong in the optimizer Step; model, iteration and
|
||||
timer settings belong in its wrapper. For example:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
home_optimize:
|
||||
steps:
|
||||
- backend: home_optimize_step
|
||||
agent_wrapper: home_optimizer
|
||||
evaluation_options:
|
||||
concurrency: 4
|
||||
timeout_seconds: 7200
|
||||
max_construction_failure_rate: 0.03
|
||||
# memory_limit_mib: 0
|
||||
components:
|
||||
agent_wrapper:
|
||||
home_optimizer:
|
||||
react_config:
|
||||
max_iters: 3000
|
||||
validation_cycle_time_budget_minutes: 150
|
||||
max_evaluations: 10
|
||||
```
|
||||
|
||||
`evaluation_options` also accepts `base_image`, `fail_fast` and
|
||||
`max_artifact_bytes`. Omit `fail_fast` to retain Home's debug/scored defaults.
|
||||
Restricted `_allowed_paths` calls fail closed. No real model or Docker benchmark
|
||||
is required by the isolated workflow tests.
|
||||
|
|
|
|||
28
plugins/home/examples/prepare.py
Normal file
28
plugins/home/examples/prepare.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Start a local Home application and prepare once: python prepare.py WORKSPACE."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from reme.application import Application
|
||||
|
||||
|
||||
async def main(workspace: str):
|
||||
"""Read WORKSPACE/prepare.yaml; a dictionary can instead be passed as spec=."""
|
||||
app = Application(
|
||||
workspace_dir=str(Path(workspace).resolve()),
|
||||
plugins=["home"],
|
||||
service={"backend": "cli"},
|
||||
)
|
||||
try:
|
||||
await app.start()
|
||||
response = await app.run_job("home_prepare", spec_path="prepare.yaml")
|
||||
if not response.success:
|
||||
raise RuntimeError(response.answer)
|
||||
print(response.answer)
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(sys.argv[1]))
|
||||
17
plugins/home/examples/prepare.yaml
Normal file
17
plugins/home/examples/prepare.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
benchmark:
|
||||
name: beam
|
||||
variant: 100K
|
||||
source: /data/BEAM
|
||||
case_ids: ["1", "3", "8"]
|
||||
# For an explicit full selection, remove case_ids and set all_cases: true.
|
||||
runtime:
|
||||
source_repo: /projects/ReMe
|
||||
source_ref: HEAD # Resolved once to a full commit SHA; uncommitted edits are excluded.
|
||||
config: benchmark.yaml
|
||||
plugins: [beam]
|
||||
candidate:
|
||||
paths: [plugins/beam/src]
|
||||
debug:
|
||||
enabled: true
|
||||
sessions_per_case: 3
|
||||
messages_per_session: 6
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "reme-home"
|
||||
version = "0.1.0"
|
||||
description = "Local command execution jobs for ReMe."
|
||||
description = "Self-evolution workspace preparation and local command execution jobs for ReMe."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
|
@ -17,7 +17,7 @@ where = ["src"]
|
|||
include = ["reme_home*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
reme_home = ["plugin.yaml"]
|
||||
reme_home = ["plugin.yaml", "runtime_docs/*.md", "agent/*.yaml"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue