From 1914d38451622fb46f118743bf197ebcec5a6bfc Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 7 Nov 2026 16:57:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E5=9C=B0=20uv=20=E4=B8=8E?= =?UTF-8?q?=E6=89=98=E7=AE=A1=20Python=20=E7=8E=AF=E5=A2=83=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验 - uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程 - 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包 - 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用 - 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗 - 发布包构建后去掉工作区继承的低完整性标签 - 修复调度集成测试并发时误取 skipped 运行的问题 --- README.md | 86 +- backend/Cargo.lock | 87 +++ backend/Cargo.toml | 2 + .../migrations/0004_create_uv_settings.sql | 11 + .../0005_uv_settings_drop_system_source.sql | 21 + .../migrations/0006_uv_settings_extra_env.sql | 2 + backend/src/api.rs | 47 +- backend/src/error.rs | 10 + backend/src/lib.rs | 2 + backend/src/python_env.rs | 125 +-- backend/src/python_envs.rs | 515 ++++++++++++ backend/src/runs/executor.rs | 84 +- backend/src/uv.rs | 717 +++++++++++++++++ backend/tests/python_env_probe.rs | 9 +- backend/tests/python_envs.rs | 174 +++++ backend/tests/runs.rs | 51 +- backend/tests/uv.rs | 208 +++++ contracts/openapi.json | 533 ++++++++++++- docs/RELEASE.md | 13 + frontend/src/App.css | 320 +++++++- frontend/src/App.test.tsx | 337 +++++++- frontend/src/App.tsx | 7 +- frontend/src/api/client.ts | 77 ++ frontend/src/api/generated.ts | 428 +++++++++- frontend/src/components/AppHeader.tsx | 45 ++ frontend/src/components/AppSidebar.tsx | 72 -- frontend/src/components/PageHeader.tsx | 32 - frontend/src/components/PythonEnvsSection.tsx | 342 ++++++++ .../PythonMirrorsSection.tsx} | 34 +- .../src/components/PythonRuntimeSection.tsx | 135 ++++ frontend/src/components/TaskEditorModal.tsx | 51 +- frontend/src/components/UvSettingsSection.tsx | 335 ++++++++ frontend/src/lib/format.ts | 11 + frontend/src/lib/taskForm.ts | 2 +- frontend/src/pages/TaskDashboard.tsx | 739 ++++++++++++------ scripts/build-release.ps1 | 12 + 36 files changed, 5168 insertions(+), 508 deletions(-) create mode 100644 backend/migrations/0004_create_uv_settings.sql create mode 100644 backend/migrations/0005_uv_settings_drop_system_source.sql create mode 100644 backend/migrations/0006_uv_settings_extra_env.sql create mode 100644 backend/src/python_envs.rs create mode 100644 backend/src/uv.rs create mode 100644 backend/tests/python_envs.rs create mode 100644 backend/tests/uv.rs create mode 100644 frontend/src/components/AppHeader.tsx delete mode 100644 frontend/src/components/AppSidebar.tsx delete mode 100644 frontend/src/components/PageHeader.tsx create mode 100644 frontend/src/components/PythonEnvsSection.tsx rename frontend/src/{pages/PythonMirrorsPage.tsx => components/PythonMirrorsSection.tsx} (91%) create mode 100644 frontend/src/components/PythonRuntimeSection.tsx create mode 100644 frontend/src/components/UvSettingsSection.tsx diff --git a/README.md b/README.md index 0e669b1..c53368f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 release/TaskPilot/ ├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管) ├── web/ # 前端静态资源(字体可选) +├── uv/ # 程序下载/自备的 uv.exe 放这里(Python 环境页可切换来源) +├── python/ # uv 下载的 Python 解释器(UV_PYTHON_INSTALL_DIR 默认指这里) ├── data/task_pilot.db # SQLite 数据库(首次启动创建) ├── logs/{task_id}/{run_id}.log ├── start.ps1 @@ -64,7 +66,7 @@ npm --prefix ..\stash-ui install npm --prefix ..\stash-ui run build pnpm --dir frontend install -cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000 +cargo run --manifest-path backend/Cargo.toml --bin task-pilot-backend # 终端一:API,127.0.0.1:3000 pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173 ``` @@ -117,8 +119,8 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 | | 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 | | 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 | -| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 | -| 界面 | 左侧导航(任务调度 / Python 镜像)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | +| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 | +| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务分组 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | | 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | @@ -220,14 +222,22 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled **没有虚拟环境时**: -- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`: - 编辑器会显示「用 uv 创建环境」按钮,点击后 - `pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。 - 已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像 - (`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。 -- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。 -- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。 - Python 任务不再静默回退到系统解释器。 +- 先看用户自己的环境:任务里指定的 `venv`、解释器路径、工作目录向上查找,命中就直接用,不重复创建。 +- 都没有时,**新建任务时会用 uv 自动创建 `.venv`**:`pyproject.toml` 走 `uv sync`, + 仅有 `requirements*.txt` 走 `uv venv .venv` + `uv pip install -r`,纯标准库脚本(没有依赖文件)建一个空环境。 + 保存按钮会显示「正在创建虚拟环境…」,环境问题当场暴露。手动路径是编辑器里的「用 uv 创建环境」按钮。 +- 运行期只做校验、不再自动装依赖:没有 `.venv` 的 Python 任务会直接失败并提示先创建环境。 +- 创建时会使用当前默认 PyPI 镜像(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。 +- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。 + **不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。 + 没有 uv 时点「Python 环境」页的「下载 uv」,从科大镜像 + `mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构的压缩包, + 校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去;本机已经装了 uv 的话,把它填进「指定路径」即可。 +- 「Python 环境」页还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`), + 会注入到每一次 uv 调用,并优先于镜像注入与进程环境。 +- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先去「Python 环境」配置。 +- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、页面里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX` + (任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。 - 解释器由语言自动决定,界面不再显示解释器输入框: Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。 自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。 @@ -248,7 +258,10 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled ## Web 界面 -- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。 +- **顶部品牌栏**:品牌 + 右侧 Rust 引擎在线状态与版本;页面级导航已去掉,配置都收到任务调度页的卡片里。 +- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + 按类型(脚本 / HTTP / 二进制)分组的任务卡片与分页; + 右栏 uv(版本只显示版本号 / 缓存目录 / Python 目录)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗** + (uv 与镜像源分属两个弹窗,不放在一起)。 - **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。 - **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。 - **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua), @@ -257,9 +270,21 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled 缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器由语言自动选择,界面不再要求手填。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 - **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 -- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。 - 默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL` - (任务自身配置的环境变量优先),不会自动安装依赖。 +- **Python 环境**:两块配置。 + - uv 可执行文件:显示当前使用的 uv 与版本、候选来源探测结果;可切「程序目录 uv(`uv\uv.exe`)」 + 或「指定路径」;本机已装 uv 就把它的路径填进「指定路径」;本机没有就点「下载 uv」, + 从科大镜像下载并自动切换。保存自定义路径时会先运行 `uv --version` 校验。 + - uv 环境变量:键值列表,只接受 `UV_` 前缀(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、 + `UV_HTTP_TIMEOUT`),会注入到每一次 uv 调用,并优先于镜像注入与进程环境。 + `UV_PYTHON_INSTALL_DIR` 未配置时默认指向程序目录下的 `python\`。 + - 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。 + 只在**装包**(建环境 / 预装依赖)时生效,任务运行时不再注入镜像变量。 +- **虚拟环境**:管理 uv 提供的 Python 版本和预创建的托管环境。 + - Python 版本:列出 uv 下载到程序目录 `python\` 的解释器(只算平台目录里的,不含系统 Python); + 填版本(如 `3.12`)点「下载 Python」即用 uv 下载,之后可在这里和新建环境下拉里选用。 + - 托管虚拟环境:预创建常用环境(放在 `data\envs\<名称>`),Python 版本从已下载列表里选, + 可选预装包;列表显示版本与绝对路径,「查看包」按需读取 `uv pip list` 的已安装包。 + 任务里把该路径填进「虚拟环境目录」即可复用。环境只在创建时装包,不会自动升级。 - **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自 `@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与 业务块(侧栏、任务表单元格、日志控制台、表单栅格)。 @@ -330,6 +355,25 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled | `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 | | `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) | +### uv + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量,以及各来源的探测结果 | +| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径和 `extra_env`;`custom` 会校验可运行 | +| `POST` | `/api/v1/uv/download` | 从科大镜像下载当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 | + +### 托管虚拟环境 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/v1/python/pythons` | uv 下载到程序目录 `python\` 的解释器(版本、路径) | +| `POST` | `/api/v1/python/pythons` | 用 uv 下载指定 Python 版本(`version`,如 `3.12`),返回最新列表 | +| `GET` | `/api/v1/python/envs` | 列出 `data/envs` 下的环境(名称、绝对路径、`pyvenv.cfg` 里的 Python 版本) | +| `POST` | `/api/v1/python/envs` | 用 uv 创建环境(`name`、可选 `python_version`、可选 `packages` 预装) | +| `GET` | `/api/v1/python/envs/{name}/packages` | 已安装的包(`uv pip list --format json`) | +| `DELETE` | `/api/v1/python/envs/{name}` | 删除整个环境目录 | + ### 任务与运行 | 方法 | 路径 | 说明 | @@ -475,7 +519,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | 模块 | 职责 | | --- | --- | | `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 | -| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 | +| `components/AppHeader.tsx` | 顶部导航、品牌、引擎状态 | | `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) | | `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 | | `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 | @@ -547,9 +591,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py | 层 | 覆盖 | | --- | --- | -| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | -| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 | -| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | +| Rust 单元(73) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | +| Rust 集成(48) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 | +| 前端 Vitest(37) | 仪表盘渲染、侧栏导航到 Python 环境页、保存 uv 来源与 `UV_*` 环境变量、镜像下载、Python 版本下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | | Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | @@ -578,7 +622,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py - HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。 - 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。 - Conda 环境不会自动激活,需要显式填写解释器路径。 -- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。 +- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。 +- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。 +- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。 - 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。 - 未安装为 Windows 服务;关闭启动脚本后调度停止。 - 错过的调度不会补跑。 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 28473a8..ca584cb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -263,6 +263,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -318,6 +324,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + [[package]] name = "croner" version = "4.0.1" @@ -455,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -553,6 +569,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "zlib-rs", +] + [[package]] name = "flume" version = "0.12.0" @@ -1234,6 +1259,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1835,6 +1872,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2172,6 +2215,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2 0.11.1", "sqlx", "tempfile", "thiserror", @@ -2187,6 +2231,7 @@ dependencies = [ "utoipa-axum", "uuid", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -2450,6 +2495,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -2947,8 +2998,44 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "9.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f17fbc870bfac28c6228aed7d6e6df352e26bf7015c4313727e16a7a01ce5e" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "libc", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 165cfc7..c05d506 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,6 +14,7 @@ futures-util = { version = "0.3.34", features = ["std"] } reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" +sha2 = "0.11.1" sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] } thiserror = "2.0.21" tokio = { version = "1.53.3", features = ["full"] } @@ -27,6 +28,7 @@ utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] } utoipa-axum = "0.3.0" uuid = { version = "1.28.0", features = ["v4"] } windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] } +zip = { version = "9.4.1", default-features = false, features = ["deflate"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/backend/migrations/0004_create_uv_settings.sql b/backend/migrations/0004_create_uv_settings.sql new file mode 100644 index 0000000..b1fe957 --- /dev/null +++ b/backend/migrations/0004_create_uv_settings.sql @@ -0,0 +1,11 @@ +-- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed), +-- 或用户自己下载后指定的路径(custom)。只保留一行。 +CREATE TABLE IF NOT EXISTS uv_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')), + custom_path TEXT, + updated_at TEXT NOT NULL +); + +INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at) +VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/backend/migrations/0005_uv_settings_drop_system_source.sql b/backend/migrations/0005_uv_settings_drop_system_source.sql new file mode 100644 index 0000000..50628ac --- /dev/null +++ b/backend/migrations/0005_uv_settings_drop_system_source.sql @@ -0,0 +1,21 @@ +-- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。 +CREATE TABLE uv_settings_v2 ( + id INTEGER PRIMARY KEY CHECK (id = 1), + source TEXT NOT NULL CHECK (source IN ('managed', 'custom')), + custom_path TEXT, + updated_at TEXT NOT NULL +); + +INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at) +SELECT id, + CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END, + custom_path, + updated_at +FROM uv_settings; + +DROP TABLE uv_settings; + +ALTER TABLE uv_settings_v2 RENAME TO uv_settings; + +INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at) +VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/backend/migrations/0006_uv_settings_extra_env.sql b/backend/migrations/0006_uv_settings_extra_env.sql new file mode 100644 index 0000000..ca8114c --- /dev/null +++ b/backend/migrations/0006_uv_settings_extra_env.sql @@ -0,0 +1,2 @@ +-- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。 +ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}'; diff --git a/backend/src/api.rs b/backend/src/api.rs index 81b34f3..51b9593 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -21,6 +21,11 @@ use crate::{ self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource, }, + python_envs::{ + __path_create_env, __path_delete_env, __path_install_python, __path_list_env_packages, + __path_list_envs, __path_list_pythons, CreateManagedEnvRequest, InstallPythonRequest, + ManagedEnvPackage, ManagedEnvSummary, ManagedPython, + }, python_mirrors::{ model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest}, routes::{ @@ -49,6 +54,10 @@ use crate::{ __path_get_task, __path_list_tasks, __path_update_task, }, }, + uv::{ + __path_download_uv, __path_get_uv_settings, __path_update_uv_settings, + UpdateUvSettingsRequest, UvCandidate, UvSettingsResponse, UvSource, + }, }; #[derive(Debug, Serialize, ToSchema)] @@ -137,9 +146,11 @@ pub async fn preview_cron( ) )] pub async fn probe_python_venv( + State(state): State, ApiJson(request): ApiJson, ) -> ApiResult> { - tokio::task::spawn_blocking(move || python_env::probe_venv(&request)) + let uv = crate::uv::current(&state.pool).await.program(); + tokio::task::spawn_blocking(move || python_env::probe_venv(&request, &uv)) .await .map(Json) .map_err(ApiError::internal) @@ -165,10 +176,16 @@ pub async fn create_python_venv( tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置"); None }); - python_env::create_project_venv(&request.working_dir, mirror.as_ref()) - .await - .map(Json) - .map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message)) + let uv = crate::uv::current(&state.pool).await; + python_env::create_project_venv( + &request.working_dir, + mirror.as_ref(), + &uv.program(), + &uv.env(), + ) + .await + .map(Json) + .map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message)) } #[utoipa::path( @@ -255,12 +272,21 @@ pub async fn list_schedule( preview_cron, probe_python_venv, create_python_venv, + get_uv_settings, + update_uv_settings, + download_uv, list_python_mirrors, get_python_mirror, create_python_mirror, update_python_mirror, delete_python_mirror, set_default_python_mirror, + list_envs, + create_env, + list_env_packages, + delete_env, + list_pythons, + install_python, list_directories, task_stats, list_schedule, @@ -293,6 +319,15 @@ pub async fn list_schedule( PythonMirrorResponse, CreatePythonMirrorRequest, UpdatePythonMirrorRequest, + UvSettingsResponse, + UpdateUvSettingsRequest, + UvCandidate, + UvSource, + ManagedEnvSummary, + ManagedEnvPackage, + CreateManagedEnvRequest, + ManagedPython, + InstallPythonRequest, DirectoryEntry, DirectoryListingResponse, TaskStatsResponse, @@ -357,6 +392,8 @@ pub fn router(state: AppState) -> Router { .route("/api/v1/openapi.json", get(openapi_json)) .merge(crate::tasks::routes::router()) .merge(crate::python_mirrors::routes::router()) + .merge(crate::python_envs::router()) + .merge(crate::uv::router()) .merge(crate::runs::routes::router()) .route("/api/{*path}", any(api_not_found)) // 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。 diff --git a/backend/src/error.rs b/backend/src/error.rs index 796c370..8d3d8d9 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -119,6 +119,16 @@ impl ApiError { } } + pub fn env_not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "ENV_NOT_FOUND", + message: message.into(), + field: None, + details: BTreeMap::new(), + } + } + pub fn mirror_conflict(message: impl Into) -> Self { Self { status: StatusCode::CONFLICT, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index b3097ac..12e1964 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,12 +5,14 @@ pub mod directory_browser; pub mod error; pub mod process_job; pub mod python_env; +pub mod python_envs; pub mod python_mirrors; pub mod runs; pub mod scheduler; pub mod security; pub mod state; pub mod tasks; +pub mod uv; use axum::Router; use sqlx::SqlitePool; diff --git a/backend/src/python_env.rs b/backend/src/python_env.rs index ed3ab2a..9845c48 100644 --- a/backend/src/python_env.rs +++ b/backend/src/python_env.rs @@ -33,6 +33,28 @@ impl PreparedPython { } command.envs(&self.environment); } + + /// 把 `directory` 插到 PATH 最前面,让任务脚本里的 `uv` 用平台副本。 + pub fn prepend_path(&mut self, directory: &Path) -> io::Result<()> { + let base = self + .environment + .get("PATH") + .map(OsString::from) + .or_else(|| env::var_os("PATH")) + .unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut paths = Vec::new(); + for path in std::iter::once(directory.to_path_buf()).chain(env::split_paths(&base)) { + let key = path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + paths.push(path); + } + } + let joined = env::join_paths(paths).map_err(io::Error::other)?; + self.environment + .insert("PATH".to_owned(), joined.to_string_lossy().into_owned()); + Ok(()) + } } pub fn prepare_script_environment( @@ -244,7 +266,7 @@ pub struct VenvProbeResponse { pub has_requirements: bool, /// 探测到的 requirements 文件名(仅根目录) pub requirements_files: Vec, - /// 没有虚拟环境时,是否具备用 uv 创建的条件 + /// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) pub can_create: bool, } @@ -255,7 +277,7 @@ pub struct CreatePythonVenvRequest { pub working_dir: String, } -pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse { +pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse { let working_dir = request.working_dir.trim(); let interpreter = { let trimmed = request.interpreter.trim(); @@ -271,7 +293,7 @@ pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse { .map(str::trim) .filter(|value| !value.is_empty()); let interpreter_kind = classify_interpreter(interpreter); - let uv = uv_available(); + let uv = crate::uv::probe_version(uv).is_some(); let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv); if working_dir.is_empty() { @@ -367,16 +389,12 @@ fn finalize_probe( response.has_pyproject = files.has_pyproject; response.has_requirements = !files.requirements.is_empty(); response.requirements_files = files.requirements; - response.can_create = response.status == VenvProbeStatus::Missing - && uv_available - && (response.has_pyproject || response.has_requirements); + response.can_create = response.status == VenvProbeStatus::Missing && uv_available; if response.status == VenvProbeStatus::Missing { response.message = if response.can_create { - "未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned() - } else if !uv_available { - "未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned() + "未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned() } else { - "未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned() + "未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned() }; } response @@ -426,25 +444,12 @@ pub fn scan_project_files(working_dir: &str) -> ProjectFiles { files } -/// 本机 PATH 中是否有可用的 uv。 -pub fn uv_available() -> bool { - let mut command = std::process::Command::new("uv"); - command - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); - } - command.status().is_ok_and(|status| status.success()) -} - /// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。 pub async fn create_project_venv( working_dir: &str, mirror: Option<&DefaultPythonMirror>, + uv: &Path, + uv_env: &BTreeMap, ) -> Result { let working_directory = absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?; @@ -457,25 +462,23 @@ pub async fn create_project_venv( interpreter: "python.exe".to_owned(), venv: None, }; - let existing = probe_venv(&probe_request); + let existing = probe_venv(&probe_request, uv); if existing.status == VenvProbeStatus::Linked { return Ok(existing); } let files = scan_project_files(working_dir); - if !files.has_pyproject && files.requirements.is_empty() { + if crate::uv::probe_version(uv).is_none() { return Err( - "项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(), + "uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(), ); } - if !uv_available() { - return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned()); - } + // 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。 if files.has_pyproject { - run_uv(&working_directory, &["sync"], mirror).await?; + run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?; } else { - run_uv(&working_directory, &["venv", ".venv"], mirror).await?; + run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?; let python = working_directory .join(".venv") .join("Scripts") @@ -486,12 +489,14 @@ pub async fn create_project_venv( &working_directory, &["pip", "install", "--python", &python, "-r", requirement], mirror, + uv, + uv_env, ) .await?; } } - let probe = probe_venv(&probe_request); + let probe = probe_venv(&probe_request, uv); if probe.status != VenvProbeStatus::Linked { return Err(format!( "uv 执行完成,但没有找到有效的 .venv:{}", @@ -501,12 +506,14 @@ pub async fn create_project_venv( Ok(probe) } -async fn run_uv( +pub(crate) async fn run_uv( working_dir: &Path, args: &[&str], mirror: Option<&DefaultPythonMirror>, + uv: &Path, + uv_env: &BTreeMap, ) -> Result { - let mut command = Command::new("uv"); + let mut command = Command::new(uv); command .args(args) .current_dir(working_dir) @@ -526,6 +533,8 @@ async fn run_uv( command.env("PIP_TRUSTED_HOST", host); } } + // 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。 + command.envs(uv_env); #[cfg(windows)] command.creation_flags(0x0800_0000); @@ -812,6 +821,30 @@ mod tests { assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } + #[test] + fn prepends_the_uv_directory_to_path() { + let temp = TempDir::new().unwrap(); + let mut prepared = prepare_script_environment( + temp.path().to_str().unwrap(), + "python.exe", + None, + &BTreeMap::new(), + ) + .unwrap(); + let uv_directory = temp.path().join("uv"); + + prepared.prepend_path(&uv_directory).unwrap(); + + let paths: Vec<_> = env::split_paths(&prepared.environment["PATH"]).collect(); + assert_eq!(paths.first(), Some(&uv_directory)); + // 重复插入要去重 + prepared.prepend_path(&uv_directory).unwrap(); + assert_eq!( + env::split_paths(&prepared.environment["PATH"]).count(), + paths.len() + ); + } + #[test] fn non_python_interpreter_is_not_modified() { let temp = TempDir::new().unwrap(); @@ -934,11 +967,14 @@ mod tests { } fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse { - probe_venv(&VenvProbeRequest { - working_dir: working_dir.to_owned(), - interpreter: interpreter.to_owned(), - venv: venv.map(str::to_owned), - }) + probe_venv( + &VenvProbeRequest { + working_dir: working_dir.to_owned(), + interpreter: interpreter.to_owned(), + venv: venv.map(str::to_owned), + }, + Path::new("uv"), + ) } #[test] @@ -985,7 +1021,7 @@ mod tests { assert_eq!(response.status, VenvProbeStatus::Missing); assert_eq!(response.display_path.as_deref(), Some("python.exe")); assert!(response.venv_path.is_none()); - assert!(!response.can_create); + assert_eq!(response.can_create, response.uv_available); } #[test] @@ -1004,12 +1040,13 @@ mod tests { } #[test] - fn probe_does_not_offer_uv_without_dependency_files() { + fn probe_offers_uv_creation_even_without_dependency_files() { let temp = TempDir::new().unwrap(); let response = probe(temp.path().to_str().unwrap(), "python.exe", None); - assert!(!response.can_create); + // 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。 + assert_eq!(response.can_create, response.uv_available); } #[test] diff --git a/backend/src/python_envs.rs b/backend/src/python_envs.rs new file mode 100644 index 0000000..80b1787 --- /dev/null +++ b/backend/src/python_envs.rs @@ -0,0 +1,515 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use axum::{ + Json, Router, + extract::{Path as AxumPath, State}, + http::StatusCode, + routing::{delete, get}, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::{ + error::{ApiError, ApiJson, ApiResult}, + python_env::run_uv, + state::AppState, +}; + +/// 预创建的虚拟环境放在 `data/envs` 下,按目录名管理。 +const ENV_ROOT: &str = "data/envs"; +const MAX_NAME_CHARS: usize = 64; +const MAX_PACKAGES: usize = 50; +const MAX_PACKAGE_CHARS: usize = 200; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ManagedEnvSummary { + pub name: String, + /// 虚拟环境根目录的绝对路径 + pub path: String, + /// 取自 `pyvenv.cfg` 的 `version_info` + pub python_version: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ManagedEnvPackage { + pub name: String, + pub version: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct CreateManagedEnvRequest { + /// 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 + pub name: String, + /// 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定 + #[serde(default)] + pub python_version: Option, + /// 预装包,每行一个,例如 `numpy==2.1.0` + #[serde(default)] + pub packages: Vec, +} + +fn root() -> PathBuf { + std::path::absolute(Path::new(ENV_ROOT)).unwrap_or_else(|_| PathBuf::from(ENV_ROOT)) +} + +fn validate_name(raw: &str) -> ApiResult { + let name = raw.trim(); + let valid = !name.is_empty() + && name.chars().count() <= MAX_NAME_CHARS + && !name.starts_with('.') + && name.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }); + if !valid { + return Err(ApiError::validation( + "name", + "环境名只能包含字母、数字、点、下划线、连字符,且不能以点开头", + )); + } + Ok(name.to_owned()) +} + +fn validate_python_version(raw: Option<&str>) -> ApiResult> { + let Some(value) = raw.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.len() > 16 + || !value + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + { + return Err(ApiError::validation( + "python_version", + "Python 版本只填数字和点,例如 3.11 或 3.12.1", + )); + } + Ok(Some(value.to_owned())) +} + +fn validate_packages(packages: &[String]) -> ApiResult> { + if packages.len() > MAX_PACKAGES { + return Err(ApiError::validation( + "packages", + format!("一次最多预装 {MAX_PACKAGES} 个包"), + )); + } + let mut cleaned = Vec::with_capacity(packages.len()); + for package in packages { + let package = package.trim(); + if package.is_empty() { + continue; + } + // 以 - 开头会被 uv 当成参数,直接拒绝。 + if package.starts_with('-') { + return Err(ApiError::validation( + "packages", + format!("包名不能以 - 开头: {package}"), + )); + } + if package.chars().count() > MAX_PACKAGE_CHARS + || package.chars().any(|character| character.is_control()) + { + return Err(ApiError::validation( + "packages", + format!("包名过长或包含非法字符: {package}"), + )); + } + cleaned.push(package.to_owned()); + } + Ok(cleaned) +} + +fn read_summary(name: &str, path: &Path) -> Option { + let python = path.join("Scripts").join("python.exe"); + let config = path.join("pyvenv.cfg"); + if !python.is_file() || !config.is_file() { + return None; + } + let python_version = fs::read_to_string(&config).ok().and_then(|text| { + text.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + (key.trim() == "version_info").then(|| value.trim().to_owned()) + }) + }); + Some(ManagedEnvSummary { + name: name.to_owned(), + path: path.to_string_lossy().into_owned(), + python_version, + }) +} + +fn env_path(name: &str) -> ApiResult { + let path = root().join(name); + if !path.is_dir() { + return Err(ApiError::env_not_found(format!("虚拟环境 {name} 不存在"))); + } + Ok(path) +} + +#[utoipa::path( + get, + path = "/api/v1/python/envs", + tag = "python-envs", + responses( + (status = 200, description = "Managed virtual environments", body = Vec) + ) +)] +pub async fn list_envs() -> ApiResult>> { + let directory = root(); + let mut envs = Vec::new(); + if let Ok(entries) = fs::read_dir(&directory) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(summary) = read_summary(&name, &entry.path()) { + envs.push(summary); + } + } + } + envs.sort_by_key(|env| env.name.to_lowercase()); + Ok(Json(envs)) +} + +#[utoipa::path( + post, + path = "/api/v1/python/envs", + tag = "python-envs", + request_body = CreateManagedEnvRequest, + responses( + (status = 201, description = "Virtual environment created", body = ManagedEnvSummary), + (status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse) + ) +)] +pub async fn create_env( + State(state): State, + ApiJson(request): ApiJson, +) -> ApiResult<(StatusCode, Json)> { + let name = validate_name(&request.name)?; + let python_version = validate_python_version(request.python_version.as_deref())?; + let packages = validate_packages(&request.packages)?; + let directory = root(); + let target = directory.join(&name); + if target.exists() { + return Err(ApiError::bad_request( + "ENV_EXISTS", + format!("虚拟环境 {name} 已存在,先删除再重建"), + )); + } + fs::create_dir_all(&directory).map_err(ApiError::internal)?; + + let uv = crate::uv::current(&state.pool).await; + let mirror = crate::python_mirrors::repository::default_enabled(&state.pool) + .await + .unwrap_or_else(|error| { + tracing::warn!(%error, "读取默认 Python 镜像失败,创建环境时改用 uv 自身配置"); + None + }); + let program = uv.program(); + if crate::uv::probe_version(&program).is_none() { + return Err(ApiError::bad_request( + "UV_UNAVAILABLE", + "uv 不可用:请先在「Python 环境」页下载或指定 uv", + )); + } + let target_arg = target.to_string_lossy().into_owned(); + + let mut venv_args = vec!["venv".to_owned(), target_arg.clone()]; + if let Some(version) = python_version { + venv_args.push("--python".to_owned()); + venv_args.push(version); + } + if let Err(message) = run_uv(&directory, &owned(&venv_args), None, &program, &uv.env()).await { + let _ = fs::remove_dir_all(&target); + return Err(ApiError::bad_request( + "ENV_CREATE_FAILED", + format!("创建虚拟环境失败: {message}"), + )); + } + + if !packages.is_empty() { + let python = target.join("Scripts").join("python.exe"); + let mut install_args = vec![ + "pip".to_owned(), + "install".to_owned(), + "--python".to_owned(), + python.to_string_lossy().into_owned(), + ]; + install_args.extend(packages.clone()); + if let Err(message) = run_uv( + &target, + &owned(&install_args), + mirror.as_ref(), + &program, + &uv.env(), + ) + .await + { + let _ = fs::remove_dir_all(&target); + return Err(ApiError::bad_request( + "ENV_CREATE_FAILED", + format!("安装预装包失败: {message}"), + )); + } + } + + let summary = read_summary(&name, &target).ok_or_else(|| { + ApiError::bad_request("ENV_CREATE_FAILED", "环境创建后没有找到 python.exe") + })?; + Ok((StatusCode::CREATED, Json(summary))) +} + +#[utoipa::path( + get, + path = "/api/v1/python/envs/{name}/packages", + tag = "python-envs", + params(("name" = String, Path, description = "Environment name")), + responses( + (status = 200, description = "Installed packages", body = Vec), + (status = 404, description = "Environment not found", body = crate::error::ErrorResponse) + ) +)] +pub async fn list_env_packages( + State(state): State, + AxumPath(name): AxumPath, +) -> ApiResult>> { + let name = validate_name(&name)?; + let path = env_path(&name)?; + let python = path.join("Scripts").join("python.exe"); + let uv = crate::uv::current(&state.pool).await; + let program = uv.program(); + if crate::uv::probe_version(&program).is_none() { + return Err(ApiError::bad_request( + "UV_UNAVAILABLE", + "uv 不可用:请先在「Python 环境」页下载或指定 uv", + )); + } + let arguments = [ + "pip".to_owned(), + "list".to_owned(), + "--python".to_owned(), + python.to_string_lossy().into_owned(), + "--format".to_owned(), + "json".to_owned(), + ]; + let stdout = run_uv(&path, &owned(&arguments), None, &program, &uv.env()) + .await + .map_err(|message| { + ApiError::bad_request( + "ENV_PACKAGES_FAILED", + format!("读取已安装包失败: {message}"), + ) + })?; + + let packages = serde_json::from_str::>(&stdout) + .map_err(ApiError::internal)? + .into_iter() + .filter_map(|value| { + let name = value.get("name")?.as_str()?.to_owned(); + let version = value + .get("version")? + .as_str() + .unwrap_or_default() + .to_owned(); + Some(ManagedEnvPackage { name, version }) + }) + .collect(); + Ok(Json(packages)) +} + +#[utoipa::path( + delete, + path = "/api/v1/python/envs/{name}", + tag = "python-envs", + params(("name" = String, Path, description = "Environment name")), + responses( + (status = 204, description = "Environment deleted"), + (status = 404, description = "Environment not found", body = crate::error::ErrorResponse) + ) +)] +pub async fn delete_env(AxumPath(name): AxumPath) -> ApiResult { + let name = validate_name(&name)?; + let path = env_path(&name)?; + fs::remove_dir_all(&path).map_err(ApiError::internal)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ManagedPython { + pub version: String, + /// uv 下载到平台目录下的解释器路径 + pub path: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct InstallPythonRequest { + /// 要下载的版本,例如 `3.12` 或 `3.12.4` + pub version: String, +} + +fn version_key(version: &str) -> Vec { + version + .split('.') + .map(|part| part.parse().unwrap_or(0)) + .collect() +} + +/// 只返回 uv 下载到平台目录里的解释器(忽略系统里已有的 Python)。 +async fn installed_pythons(pool: &sqlx::SqlitePool) -> Result, String> { + let uv = crate::uv::current(pool).await; + let program = uv.program(); + if crate::uv::probe_version(&program).is_none() { + return Err("uv 不可用:请先在「Python 环境」页下载或指定 uv".to_owned()); + } + let directory = uv.python_install_dir(); + fs::create_dir_all(&directory).map_err(|error| format!("无法创建 Python 目录: {error}"))?; + let arguments = [ + "python".to_owned(), + "list".to_owned(), + "--only-installed".to_owned(), + "--output-format".to_owned(), + "json".to_owned(), + ]; + let stdout = run_uv(&directory, &owned(&arguments), None, &program, &uv.env()).await?; + + let prefix = directory.to_string_lossy().to_lowercase(); + let mut pythons: Vec = serde_json::from_str::>(&stdout) + .map_err(|error| format!("解析 uv python list 失败: {error}"))? + .into_iter() + .filter_map(|value| { + let version = value.get("version")?.as_str()?.to_owned(); + // uv 按自己的 cwd 输出相对路径(这里的 cwd 就是平台 Python 目录),先补成绝对路径。 + let path = Path::new(value.get("path")?.as_str()?); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + directory.join(path) + }; + let path = absolute.to_string_lossy().into_owned(); + path.to_lowercase() + .starts_with(&prefix) + .then_some(ManagedPython { version, path }) + }) + .collect(); + pythons.sort_by_key(|python| version_key(&python.version)); + pythons.dedup_by(|left, right| left.version == right.version); + Ok(pythons) +} + +#[utoipa::path( + get, + path = "/api/v1/python/pythons", + tag = "python-envs", + responses( + (status = 200, description = "Pythons downloaded by uv into the platform directory", body = Vec), + (status = 400, description = "uv unavailable", body = crate::error::ErrorResponse) + ) +)] +pub async fn list_pythons(State(state): State) -> ApiResult>> { + let pythons = installed_pythons(&state.pool) + .await + .map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?; + Ok(Json(pythons)) +} + +#[utoipa::path( + post, + path = "/api/v1/python/pythons", + tag = "python-envs", + request_body = InstallPythonRequest, + responses( + (status = 200, description = "Python downloaded; returns the installed list", body = Vec), + (status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse) + ) +)] +pub async fn install_python( + State(state): State, + ApiJson(request): ApiJson, +) -> ApiResult>> { + let version = validate_python_version(Some(&request.version))? + .ok_or_else(|| ApiError::validation("version", "请填写要下载的 Python 版本,例如 3.12"))?; + let uv = crate::uv::current(&state.pool).await; + let program = uv.program(); + if crate::uv::probe_version(&program).is_none() { + return Err(ApiError::bad_request( + "UV_UNAVAILABLE", + "uv 不可用:请先在「Python 环境」页下载或指定 uv", + )); + } + let directory = uv.python_install_dir(); + fs::create_dir_all(&directory).map_err(ApiError::internal)?; + + let arguments = ["python".to_owned(), "install".to_owned(), version.clone()]; + run_uv(&directory, &owned(&arguments), None, &program, &uv.env()) + .await + .map_err(|message| { + ApiError::bad_request( + "PYTHON_INSTALL_FAILED", + format!("下载 Python {version} 失败: {message}"), + ) + })?; + + let pythons = installed_pythons(&state.pool) + .await + .map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?; + Ok(Json(pythons)) +} + +fn owned(arguments: &[String]) -> Vec<&str> { + arguments.iter().map(String::as_str).collect() +} + +pub fn router() -> Router { + Router::new() + .route("/api/v1/python/envs", get(list_envs).post(create_env)) + .route( + "/api/v1/python/envs/{name}/packages", + get(list_env_packages), + ) + .route("/api/v1/python/envs/{name}", delete(delete_env)) + .route( + "/api/v1/python/pythons", + get(list_pythons).post(install_python), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_safe_environment_names() { + assert_eq!(validate_name(" data-science ").unwrap(), "data-science"); + assert_eq!(validate_name("py3.11_web").unwrap(), "py3.11_web"); + } + + #[test] + fn rejects_unsafe_environment_names() { + for name in ["", "..", ".hidden", "a/b", "a\\b", "a b", "环境"] { + assert!(validate_name(name).is_err(), "{name} should be rejected"); + } + } + + #[test] + fn validates_python_versions() { + assert_eq!( + validate_python_version(Some(" 3.11 ")).unwrap().as_deref(), + Some("3.11") + ); + assert!(validate_python_version(Some("latest")).is_err()); + assert!(validate_python_version(Some("3.11 --system")).is_err()); + assert!(validate_python_version(None).unwrap().is_none()); + } + + #[test] + fn rejects_packages_that_look_like_flags() { + assert!(validate_packages(&["--index-url=http://x".to_owned()]).is_err()); + assert!(validate_packages(&["numpy==2.1.0".to_owned()]).is_ok()); + } +} diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index 23722ff..f33798a 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -437,7 +437,7 @@ impl TaskExecutor { } => { let script_language = language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter)); - let prepared = match prepare_script_environment( + let mut prepared = match prepare_script_environment( working_dir, interpreter, venv.as_deref(), @@ -448,58 +448,46 @@ impl TaskExecutor { return failed_outcome(format!("准备 Python 运行环境失败: {error}")); } }; + let mut notes = Vec::new(); + + // 环境在保存任务时创建;运行期只做校验,避免跑到一半才开始装依赖。 + if prepared.virtual_env.is_none() && script_language == ScriptLanguage::Python { + return failed_outcome( + "项目还没有 .venv;请在编辑器里用 uv 创建虚拟环境(新建任务时会自动创建)", + ); + } + if let Some(venv) = &prepared.virtual_env { + notes.push(format!("使用 Python 虚拟环境: {}", venv.display())); + } + + // 平台已经不依赖系统 PATH 的 uv:把平台副本放进任务进程的 PATH, + // 脚本里直接写 `uv ...` 也能用,而且和平台是同一个版本。 + let uv = crate::uv::current(&self.inner.pool).await; + if script_language == ScriptLanguage::Python + && let Some(directory) = uv.program().parent() + && let Err(error) = prepared.prepend_path(directory) + { + tracing::warn!(%error, "failed to prepend the managed uv directory to PATH"); + } + let mut command = Command::new(&prepared.interpreter); prepared.apply(&mut command); - let mut notes = Vec::new(); - if let Some(venv) = &prepared.virtual_env { - notes.push(format!("使用 Python 虚拟环境: {}", venv.display())); - } else if script_language == ScriptLanguage::Python { - // Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。 - let project = crate::python_env::scan_project_files(working_dir); - if project.has_pyproject || !project.requirements.is_empty() { - return failed_outcome( - "项目有 pyproject.toml / requirements.txt,但还没有 .venv;请先在编辑器中用 uv 创建虚拟环境", - ); - } - return failed_outcome( - "项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件", - ); - } + // uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程; + // 任务自己配的环境变量优先。 if script_language == ScriptLanguage::Python { - match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await - { - Ok(Some(mirror)) => { - if !env_has(env, "PIP_INDEX_URL") { - command.env("PIP_INDEX_URL", &mirror.index_url); - } - if !env_has(env, "UV_INDEX_URL") { - command.env("UV_INDEX_URL", &mirror.index_url); - } - if !env_has(env, "UV_DEFAULT_INDEX") { - command.env("UV_DEFAULT_INDEX", &mirror.index_url); - } - if let Some(host) = mirror - .trusted_host - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - && !env_has(env, "PIP_TRUSTED_HOST") - { - command.env("PIP_TRUSTED_HOST", host); - } - notes.push(format!( - "Python 包镜像: {} ({})", - mirror.name, mirror.index_url - )); + for (key, value) in uv.env() { + if !env_has(env, &key) { + command.env(key, value); } - Ok(None) => {} - Err(error) => { - tracing::warn!( - task_id = %task.id, - %error, - "failed to load default python mirror" - ); + } + if let Ok(Some(mirror)) = + crate::python_mirrors::repository::default_enabled(&self.inner.pool).await + { + for key in ["UV_DEFAULT_INDEX", "UV_INDEX_URL"] { + if !env_has(env, key) { + command.env(key, &mirror.index_url); + } } } } diff --git a/backend/src/uv.rs b/backend/src/uv.rs new file mode 100644 index 0000000..ed859e9 --- /dev/null +++ b/backend/src/uv.rs @@ -0,0 +1,717 @@ +use std::{ + collections::BTreeMap, + env, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::State, + routing::{get, post}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::SqlitePool; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + error::{ApiError, ApiJson, ApiResult}, + state::AppState, +}; + +const UV_EXECUTABLE: &str = "uv.exe"; +/// 自己下载的 uv 放在程序目录下的这个子目录里。 +const UV_DIRECTORY: &str = "uv"; +/// uv 下载的 Python 放在程序目录下的这个子目录里(相对路径默认值)。 +const PYTHON_DIR_DEFAULT: &str = "python"; +/// uv 的缓存目录默认值(相对程序目录)。 +const CACHE_DIR_DEFAULT: &str = "uv-cache"; +const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR"; +const CACHE_DIR_ENV: &str = "UV_CACHE_DIR"; +/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。 +const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"; +const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024; +const MAX_UV_ENV_ENTRIES: usize = 32; +const MAX_UV_ENV_VALUE_CHARS: usize = 512; + +/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性 +/// 会随启动方式变化,不适合当调度器的隐式依赖。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UvSource { + /// 程序目录 `uv\uv.exe`:下载或自己放进来 + Managed, + /// 用户指定路径的 uv + Custom, +} + +impl UvSource { + const fn as_str(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::Custom => "custom", + } + } + + fn parse(value: &str) -> Option { + match value { + // 旧版本存过 system;现在统一当成程序目录 uv,由用户下载或指定。 + "system" | "managed" => Some(Self::Managed), + "custom" => Some(Self::Custom), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct UvSettings { + pub source: UvSource, + pub custom_path: Option, + pub extra_env: BTreeMap, +} + +impl Default for UvSettings { + fn default() -> Self { + Self { + source: UvSource::Managed, + custom_path: None, + extra_env: BTreeMap::new(), + } + } +} + +impl UvSettings { + /// 执行时真正调用的 uv 程序。 + pub fn program(&self) -> PathBuf { + match self.source { + UvSource::Managed => managed_directory().join(UV_EXECUTABLE), + UvSource::Custom => self + .custom_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| managed_directory().join(UV_EXECUTABLE)), + } + } + + /// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`), + /// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。 + pub fn env(&self) -> BTreeMap { + let mut env = self.extra_env.clone(); + for (key, default) in UV_PATH_DEFAULTS { + let value = env + .get(key) + .map(String::as_str) + .unwrap_or(default) + .trim() + .to_owned(); + let resolved = resolve_platform_path(&value); + env.insert(key.to_owned(), resolved.to_string_lossy().into_owned()); + } + env + } + + /// uv 下载 Python 的目录(解析后的绝对路径)。 + pub fn python_install_dir(&self) -> PathBuf { + let value = self + .extra_env + .get(PYTHON_INSTALL_DIR_ENV) + .map(String::as_str) + .unwrap_or(PYTHON_DIR_DEFAULT); + resolve_platform_path(value) + } + + /// uv 的缓存目录(解析后的绝对路径)。 + pub fn cache_dir(&self) -> PathBuf { + let value = self + .extra_env + .get(CACHE_DIR_ENV) + .map(String::as_str) + .unwrap_or(CACHE_DIR_DEFAULT); + resolve_platform_path(value) + } +} + +/// 两个路径型 UV 变量的默认值:相对程序目录,随包移动;也可以改成绝对路径。 +const UV_PATH_DEFAULTS: [(&str, &str); 2] = [ + (PYTHON_INSTALL_DIR_ENV, PYTHON_DIR_DEFAULT), + (CACHE_DIR_ENV, CACHE_DIR_DEFAULT), +]; + +/// 相对路径按程序目录解析,绝对路径原样返回。 +fn resolve_platform_path(value: &str) -> PathBuf { + let path = Path::new(value.trim()); + if path.is_absolute() { + path.to_path_buf() + } else { + executable_directory().join(path) + } +} + +/// 程序目录下的 `uv` 目录(uv 下载后放这里)。 +pub fn managed_directory() -> PathBuf { + executable_directory().join(UV_DIRECTORY) +} + +fn executable_directory() -> PathBuf { + env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// 运行 ` --version`;不是可用的 uv 时返回 `None`。 +pub fn probe_version(program: &Path) -> Option { + let mut command = std::process::Command::new(program); + command + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + version + .to_ascii_lowercase() + .starts_with("uv ") + .then_some(version) +} + +/// 当前架构对应的 Windows 压缩包名。 +const fn archive_name() -> &'static str { + if cfg!(target_arch = "aarch64") { + "uv-aarch64-pc-windows-msvc.zip" + } else { + "uv-x86_64-pc-windows-msvc.zip" + } +} + +/// 从 `.sha256` 里取第一段十六进制摘要。 +fn parse_sha256(text: &str) -> Option { + let token = text.split_whitespace().next()?; + (token.len() == 64 && token.chars().all(|character| character.is_ascii_hexdigit())) + .then(|| token.to_ascii_lowercase()) +} + +fn hex_digest(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut hex = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +fn extract_uv_exe(archive: &[u8], destination: &Path) -> Result<(), String> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive)) + .map_err(|error| format!("uv 压缩包无效: {error}"))?; + let mut entry = archive + .by_name(UV_EXECUTABLE) + .map_err(|_| format!("uv 压缩包里没有 {UV_EXECUTABLE}"))?; + let mut file = + std::fs::File::create(destination).map_err(|error| format!("无法写入 uv: {error}"))?; + std::io::copy(&mut entry, &mut file).map_err(|error| format!("解压 uv 失败: {error}"))?; + Ok(()) +} + +/// 解压到临时文件、验证可运行、再替换掉旧的 uv。 +fn install_archive(archive: &[u8], managed: &Path) -> Result { + std::fs::create_dir_all(managed).map_err(|error| format!("无法创建 uv 目录: {error}"))?; + let target = managed.join(UV_EXECUTABLE); + let temporary = managed.join(format!("uv-download-{}.tmp", Uuid::new_v4().simple())); + + extract_uv_exe(archive, &temporary)?; + if probe_version(&temporary).is_none() { + let _ = std::fs::remove_file(&temporary); + return Err("下载的 uv 无法运行,已丢弃".to_owned()); + } + if target.exists() { + std::fs::remove_file(&target).map_err(|error| format!("无法替换旧的 uv: {error}"))?; + } + std::fs::rename(&temporary, &target).map_err(|error| format!("无法安装 uv: {error}"))?; + + probe_version(&target).ok_or_else(|| "安装后的 uv 无法运行".to_owned()) +} + +/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。 +async fn download_and_install() -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .user_agent("TaskPilot/0.1") + .build() + .map_err(|error| format!("无法创建下载客户端: {error}"))?; + let url = format!("{UV_MIRROR}/{}", archive_name()); + + let checksum = client + .get(format!("{url}.sha256")) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| format!("下载 uv 校验文件失败: {error}"))? + .text() + .await + .map_err(|error| format!("读取 uv 校验文件失败: {error}"))?; + let expected = parse_sha256(&checksum).ok_or_else(|| "uv 校验文件格式不正确".to_owned())?; + + let response = client + .get(&url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| format!("下载 uv 失败: {error}"))?; + if response + .content_length() + .is_some_and(|length| length > MAX_UV_ARCHIVE_BYTES) + { + return Err("uv 压缩包超出大小上限".to_owned()); + } + let archive = response + .bytes() + .await + .map_err(|error| format!("读取 uv 压缩包失败: {error}"))?; + if archive.len() as u64 > MAX_UV_ARCHIVE_BYTES { + return Err("uv 压缩包超出大小上限".to_owned()); + } + if hex_digest(&Sha256::digest(&archive)) != expected { + return Err("uv 压缩包 sha256 校验失败".to_owned()); + } + + let managed = managed_directory(); + tokio::task::spawn_blocking(move || install_archive(&archive, &managed)) + .await + .map_err(|error| format!("安装 uv 失败: {error}"))? +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UvCandidate { + pub source: UvSource, + pub path: String, + pub available: bool, + pub version: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UvSettingsResponse { + pub source: UvSource, + pub custom_path: Option, + /// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 + pub extra_env: BTreeMap, + /// 程序目录下的 uv 目录 + pub managed_dir: String, + /// 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) + pub python_dir: String, + /// 实际生效的 uv 缓存目录(绝对路径) + pub cache_dir: String, + /// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 + pub env_defaults: BTreeMap, + /// 当前设置指向的 uv;不可用时 `version` 为空 + pub executable: Option, + pub version: Option, + /// 各来源的探测结果 + pub candidates: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdateUvSettingsRequest { + pub source: UvSource, + #[serde(default)] + pub custom_path: Option, + /// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 + #[serde(default)] + pub extra_env: BTreeMap, +} + +/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。 +pub async fn current(pool: &SqlitePool) -> UvSettings { + match load(pool).await { + Ok(settings) => settings, + Err(error) => { + tracing::warn!(%error, "读取 uv 设置失败,回退到程序目录 uv"); + UvSettings::default() + } + } +} + +fn candidate(source: UvSource, program: &Path) -> UvCandidate { + let version = probe_version(program); + UvCandidate { + source, + path: program.to_string_lossy().into_owned(), + available: version.is_some(), + version, + } +} + +fn describe(settings: &UvSettings) -> UvSettingsResponse { + let managed_directory = managed_directory(); + let mut candidates = vec![candidate( + UvSource::Managed, + &managed_directory.join(UV_EXECUTABLE), + )]; + if let Some(path) = settings + .custom_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + candidates.push(candidate(UvSource::Custom, Path::new(path))); + } + let current = candidates + .iter() + .find(|candidate| candidate.source == settings.source); + + UvSettingsResponse { + source: settings.source, + custom_path: settings.custom_path.clone(), + extra_env: settings.extra_env.clone(), + managed_dir: managed_directory.to_string_lossy().into_owned(), + python_dir: settings.python_install_dir().to_string_lossy().into_owned(), + cache_dir: settings.cache_dir().to_string_lossy().into_owned(), + env_defaults: UV_PATH_DEFAULTS + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + executable: current.map(|candidate| candidate.path.clone()), + version: current.and_then(|candidate| candidate.version.clone()), + candidates, + } +} + +/// 只允许 `UV_` 前缀:这组变量是注入给 uv 进程的,不能借它改 PATH 之类的东西。 +fn validate_extra_env(extra_env: &BTreeMap) -> ApiResult> { + if extra_env.len() > MAX_UV_ENV_ENTRIES { + return Err(ApiError::validation( + "extra_env", + format!("环境变量最多 {MAX_UV_ENV_ENTRIES} 条"), + )); + } + + let mut cleaned = BTreeMap::new(); + for (name, value) in extra_env { + let name = name.trim(); + let valid_name = name.starts_with("UV_") + && name.len() > 3 + && name.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }); + if !valid_name { + return Err(ApiError::validation( + "extra_env", + format!("环境变量名必须是大写的 UV_ 前缀: {name}"), + )); + } + + let value = value.trim(); + if value.is_empty() { + return Err(ApiError::validation( + "extra_env", + format!("{name} 的值不能为空"), + )); + } + if value.chars().count() > MAX_UV_ENV_VALUE_CHARS { + return Err(ApiError::validation( + "extra_env", + format!("{name} 的值不能超过 {MAX_UV_ENV_VALUE_CHARS} 个字符"), + )); + } + cleaned.insert(name.to_owned(), value.to_owned()); + } + Ok(cleaned) +} + +fn validate(request: UpdateUvSettingsRequest) -> ApiResult { + let custom_path = request + .custom_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + + if request.source == UvSource::Custom { + let path = custom_path.as_deref().ok_or_else(|| { + ApiError::validation("custom_path", "请填写自己下载的 uv 可执行文件路径") + })?; + if !Path::new(path).is_file() { + return Err(ApiError::validation( + "custom_path", + format!("找不到 uv 可执行文件: {path}"), + )); + } + if probe_version(Path::new(path)).is_none() { + return Err(ApiError::validation( + "custom_path", + "该文件无法运行 `uv --version`,它可能不是 uv", + )); + } + } + + Ok(UvSettings { + source: request.source, + custom_path, + extra_env: validate_extra_env(&request.extra_env)?, + }) +} + +async fn load(pool: &SqlitePool) -> ApiResult { + let row = sqlx::query_as::<_, (String, Option, String)>( + "SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await?; + + let Some((source, custom_path, extra_env)) = row else { + return Ok(UvSettings::default()); + }; + Ok(UvSettings { + source: UvSource::parse(&source).unwrap_or(UvSource::Managed), + custom_path, + extra_env: serde_json::from_str(&extra_env).unwrap_or_default(), + }) +} + +async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> { + let extra_env = serde_json::to_string(&settings.extra_env)?; + sqlx::query( + "INSERT INTO uv_settings (id, source, custom_path, extra_env, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + source = excluded.source, + custom_path = excluded.custom_path, + extra_env = excluded.extra_env, + updated_at = excluded.updated_at", + ) + .bind(settings.source.as_str()) + .bind(settings.custom_path.as_deref()) + .bind(extra_env) + .bind(Utc::now()) + .execute(pool) + .await?; + Ok(()) +} + +#[utoipa::path( + get, + path = "/api/v1/uv", + tag = "uv", + responses( + (status = 200, description = "uv settings and detected executables", body = UvSettingsResponse) + ) +)] +pub async fn get_uv_settings(State(state): State) -> ApiResult> { + let settings = load(&state.pool).await?; + tokio::task::spawn_blocking(move || describe(&settings)) + .await + .map(Json) + .map_err(ApiError::internal) +} + +#[utoipa::path( + put, + path = "/api/v1/uv", + tag = "uv", + request_body = UpdateUvSettingsRequest, + responses( + (status = 200, description = "uv settings saved", body = UvSettingsResponse), + (status = 400, description = "Validation failed", body = crate::error::ErrorResponse) + ) +)] +pub async fn update_uv_settings( + State(state): State, + ApiJson(request): ApiJson, +) -> ApiResult> { + let settings = tokio::task::spawn_blocking(move || validate(request)) + .await + .map_err(ApiError::internal)??; + save(&state.pool, &settings).await?; + tokio::task::spawn_blocking(move || describe(&settings)) + .await + .map(Json) + .map_err(ApiError::internal) +} + +#[utoipa::path( + post, + path = "/api/v1/uv/download", + tag = "uv", + responses( + (status = 200, description = "uv downloaded from the mirror and switched to it", body = UvSettingsResponse), + (status = 400, description = "Download, verification or install failed", body = crate::error::ErrorResponse) + ) +)] +pub async fn download_uv(State(state): State) -> ApiResult> { + // shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。 + let version = download_and_install() + .await + .map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?; + tracing::info!(%version, "downloaded uv into the managed directory"); + + let mut settings = load(&state.pool).await?; + // 下载的目的就是让它生效,所以直接切到程序目录 uv。 + settings.source = UvSource::Managed; + save(&state.pool, &settings).await?; + tokio::task::spawn_blocking(move || describe(&settings)) + .await + .map(Json) + .map_err(ApiError::internal) +} + +pub fn router() -> Router { + Router::new() + .route("/api/v1/uv", get(get_uv_settings).put(update_uv_settings)) + .route("/api/v1/uv/download", post(download_uv)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_directory_sits_next_to_the_binary() { + let managed = managed_directory(); + + assert_eq!(managed.file_name(), Some(std::ffi::OsStr::new("uv"))); + assert_eq!(managed.parent(), env::current_exe().unwrap().parent()); + } + + fn request(source: UvSource, custom_path: Option<&str>) -> UpdateUvSettingsRequest { + UpdateUvSettingsRequest { + source, + custom_path: custom_path.map(str::to_owned), + extra_env: BTreeMap::new(), + } + } + + #[test] + fn program_follows_the_configured_source() { + let custom = UvSettings { + source: UvSource::Custom, + custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()), + extra_env: BTreeMap::new(), + }; + assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe")); + + let managed = UvSettings { + source: UvSource::Managed, + custom_path: None, + extra_env: BTreeMap::new(), + }; + assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE)); + } + + #[test] + fn rejects_custom_source_without_a_runnable_uv() { + assert!(validate(request(UvSource::Custom, None)).is_err()); + assert!( + validate(request( + UvSource::Custom, + Some(r"C:\definitely\not\here\uv.exe") + )) + .is_err() + ); + } + + #[test] + fn keeps_the_custom_path_when_switching_away_from_custom() { + let settings = validate(request(UvSource::Managed, Some(" C:\\tools\\uv.exe "))).unwrap(); + + assert_eq!(settings.source, UvSource::Managed); + assert_eq!(settings.custom_path.as_deref(), Some(r"C:\tools\uv.exe")); + } + + #[test] + fn accepts_uv_prefixed_environment_variables() { + let mut extra_env = BTreeMap::new(); + extra_env.insert("UV_CACHE_DIR".to_owned(), " D:\\uv-cache ".to_owned()); + extra_env.insert("UV_HTTP_TIMEOUT".to_owned(), "120".to_owned()); + let settings = validate(UpdateUvSettingsRequest { + source: UvSource::Managed, + custom_path: None, + extra_env, + }) + .unwrap(); + + assert_eq!( + settings.extra_env.get("UV_CACHE_DIR").map(String::as_str), + Some("D:\\uv-cache") + ); + } + + #[test] + fn rejects_non_uv_environment_variables() { + for name in ["PATH", "uv_cache_dir", "UV_", "UV-CACHE"] { + let mut extra_env = BTreeMap::new(); + extra_env.insert(name.to_owned(), "x".to_owned()); + let result = validate(UpdateUvSettingsRequest { + source: UvSource::Managed, + custom_path: None, + extra_env, + }); + assert!(result.is_err(), "{name} should be rejected"); + } + + let mut extra_env = BTreeMap::new(); + extra_env.insert("UV_CACHE_DIR".to_owned(), " ".to_owned()); + assert!( + validate(UpdateUvSettingsRequest { + source: UvSource::Managed, + custom_path: None, + extra_env, + }) + .is_err() + ); + } + + #[test] + fn picks_the_windows_archive_for_this_architecture() { + let name = archive_name(); + + assert!(name.starts_with("uv-")); + assert!(name.ends_with("-pc-windows-msvc.zip")); + } + + #[test] + fn parses_the_sha256_sidecar() { + let digest = "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"; + let text = format!("{digest} uv-x86_64-pc-windows-msvc.zip\n"); + + assert_eq!( + parse_sha256(&text).as_deref(), + Some(digest.to_ascii_lowercase().as_str()) + ); + assert!(parse_sha256("not a checksum").is_none()); + assert!(parse_sha256("").is_none()); + } + + #[test] + fn extracts_uv_from_the_archive() { + use std::io::Write; + + let mut buffer = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buffer); + writer + .start_file(UV_EXECUTABLE, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"fake uv").unwrap(); + writer.finish().unwrap(); + } + let archive = buffer.into_inner(); + let directory = tempfile::TempDir::new().unwrap(); + let target = directory.path().join(UV_EXECUTABLE); + + extract_uv_exe(&archive, &target).unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"fake uv"); + } +} diff --git a/backend/tests/python_env_probe.rs b/backend/tests/python_env_probe.rs index d93552b..bf99c77 100644 --- a/backend/tests/python_env_probe.rs +++ b/backend/tests/python_env_probe.rs @@ -225,17 +225,16 @@ async fn venv_probe_reports_project_dependency_files() { } #[tokio::test] -async fn venv_create_rejects_project_without_dependency_files() { +async fn venv_create_rejects_missing_working_directory() { let (app, temp_dir) = test_app().await; let token = fetch_token(&app).await; - let project = temp_dir.path().join("empty-project"); - fs::create_dir_all(&project).expect("project directory should be created"); + let absent = temp_dir.path().join("absent-project"); let (status, body) = post_create( &app, Some(&token), json!({ - "working_dir": project.to_str().expect("project path should be UTF-8") + "working_dir": absent.to_str().expect("absent path should be UTF-8") }), ) .await; @@ -246,7 +245,7 @@ async fn venv_create_rejects_project_without_dependency_files() { body["message"] .as_str() .expect("message should be a string") - .contains("pyproject.toml") + .contains("工作目录") ); } diff --git a/backend/tests/python_envs.rs b/backend/tests/python_envs.rs new file mode 100644 index 0000000..eca946b --- /dev/null +++ b/backend/tests/python_envs.rs @@ -0,0 +1,174 @@ +use axum::{ + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn test_app() -> (axum::Router, TempDir) { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let pool = task_pilot_backend::database::connect("sqlite::memory:") + .await + .expect("test database should initialize"); + let app = task_pilot_backend::app(pool, temp_dir.path().join("logs")) + .await + .expect("test app should start"); + (app, temp_dir) +} + +async fn auth_token(app: &axum::Router) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/session") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + let json: Value = serde_json::from_slice(&body).expect("session should be JSON"); + json["token"] + .as_str() + .expect("token should be a string") + .to_owned() +} + +async fn send( + app: &axum::Router, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, +) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header("x-taskpilot-token", token); + } + let request = if let Some(body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&body).expect("body should serialize"), + )) + .expect("request should build") + } else { + builder.body(Body::empty()).expect("request should build") + }; + let response = app + .clone() + .oneshot(request) + .await + .expect("router should respond"); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + + (status, serde_json::from_slice(&body).unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn managed_env_list_returns_an_array() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send(&app, Method::GET, "/api/v1/python/envs", None, None).await; + + assert_eq!(status, StatusCode::OK); + assert!(body.is_array(), "envs should be an array: {body}"); +} + +#[tokio::test] +async fn managed_env_create_validates_input() { + let (app, _temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, body) = send( + &app, + Method::POST, + "/api/v1/python/envs", + Some(&token), + Some(json!({ "name": "../escape" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "name"); + + let (status, body) = send( + &app, + Method::POST, + "/api/v1/python/envs", + Some(&token), + Some(json!({ "name": "data-science", "python_version": "latest" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "python_version"); + + let (status, body) = send( + &app, + Method::POST, + "/api/v1/python/envs", + Some(&token), + Some(json!({ "name": "data-science", "packages": ["--index-url=http://x"] })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "packages"); +} + +#[tokio::test] +async fn missing_managed_env_returns_not_found() { + let (app, _temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, body) = send( + &app, + Method::GET, + "/api/v1/python/envs/absent/packages", + None, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "ENV_NOT_FOUND"); + + let (status, body) = send( + &app, + Method::DELETE, + "/api/v1/python/envs/absent", + Some(&token), + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "ENV_NOT_FOUND"); +} + +#[tokio::test] +async fn managed_env_mutations_require_token() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send( + &app, + Method::POST, + "/api/v1/python/envs", + None, + Some(json!({ "name": "no-token" })), + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); +} diff --git a/backend/tests/runs.rs b/backend/tests/runs.rs index c4e8f98..2edd4b8 100644 --- a/backend/tests/runs.rs +++ b/backend/tests/runs.rs @@ -201,6 +201,46 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value { panic!("run {run_id} did not reach a terminal state"); } +#[tokio::test] +async fn python_task_without_venv_fails_with_editor_hint() { + let app = test_app().await; + let working_dir = app._temp_dir.path().join("bare-python"); + fs::create_dir_all(&working_dir).expect("working directory should be created"); + fs::write(working_dir.join("main.py"), "print('hi')\n").expect("script should be written"); + + let mut task = script_task( + "bare_python", + &working_dir.to_string_lossy(), + "python.exe", + "main.py", + vec![], + "0 0 2 * * *", + 10, + ); + task["action"]["language"] = json!("python"); + let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await; + assert_eq!(create_response.status(), StatusCode::CREATED); + + let run_response = send_json( + &app.router, + Method::POST, + "/api/v1/tasks/bare_python/run", + None, + ) + .await; + assert_eq!(run_response.status(), StatusCode::ACCEPTED); + let run = response_json(run_response).await; + let completed = wait_for_run(&app.router, run["id"].as_i64().unwrap()).await; + + assert_eq!(completed["status"], "failed"); + let message = completed["error_message"] + .as_str() + .expect("failed run should carry an error message"); + // 运行期不再自动装依赖,只提示去编辑器里创建。 + assert!(message.contains("编辑器里用 uv 创建"), "{message}"); + app.state.shutdown(); +} + #[tokio::test] async fn shell_language_runs_batch_file_with_cmd_c() { let app = test_app().await; @@ -706,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() { ) .await; let runs = response_json(response).await; - if let Some(item) = runs["items"] - .as_array() - .and_then(|items| items.first()) - .filter(|item| item["trigger"] == "schedule") - { + // 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。 + if let Some(item) = runs["items"].as_array().and_then(|items| { + items + .iter() + .find(|item| item["trigger"] == "schedule" && item["status"] != "skipped") + }) { scheduled_run = Some(item.clone()); break; } diff --git a/backend/tests/uv.rs b/backend/tests/uv.rs new file mode 100644 index 0000000..2ea7a52 --- /dev/null +++ b/backend/tests/uv.rs @@ -0,0 +1,208 @@ +use std::{fs, path::Path}; + +use axum::{ + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn test_app() -> (axum::Router, TempDir) { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let pool = task_pilot_backend::database::connect("sqlite::memory:") + .await + .expect("test database should initialize"); + let app = task_pilot_backend::app(pool, temp_dir.path().join("logs")) + .await + .expect("test app should start"); + (app, temp_dir) +} + +async fn auth_token(app: &axum::Router) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/session") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + let json: Value = serde_json::from_slice(&body).expect("session should be JSON"); + json["token"] + .as_str() + .expect("token should be a string") + .to_owned() +} + +async fn send_json( + app: &axum::Router, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, +) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header("x-taskpilot-token", token); + } + let request = if let Some(body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&body).expect("body should serialize"), + )) + .expect("request should build") + } else { + builder.body(Body::empty()).expect("request should build") + }; + let response = app + .clone() + .oneshot(request) + .await + .expect("router should respond"); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + + (status, serde_json::from_slice(&body).unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn uv_settings_default_to_the_managed_uv() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["source"], "managed"); + assert_eq!(body["custom_path"], Value::Null); + let managed_dir = body["managed_dir"] + .as_str() + .expect("managed_dir should be a string"); + assert_eq!( + Path::new(managed_dir).file_name().unwrap(), + std::ffi::OsStr::new("uv") + ); + // 不再读系统 PATH,候选里只有程序目录 uv(自定义路径未设置时也不出现)。 + let sources = body["candidates"] + .as_array() + .expect("candidates should be an array") + .iter() + .map(|candidate| candidate["source"].as_str().unwrap().to_owned()) + .collect::>(); + assert_eq!(sources, vec!["managed"]); +} + +#[tokio::test] +async fn uv_settings_reject_unusable_custom_path_and_persist_valid_ones() { + let (app, temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, body) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ "source": "custom", "custom_path": null })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "custom_path"); + + let (status, body) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ "source": "custom", "custom_path": "C:\\missing\\uv.exe" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "custom_path"); + + // 文件存在但不是可运行的 uv,也要拒绝保存。 + let fake = temp_dir.path().join("fake.exe"); + fs::write(&fake, b"not a program").expect("fake executable should be written"); + let (status, body) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ + "source": "custom", + "custom_path": fake.to_string_lossy() + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "custom_path"); + + // 切回程序目录 uv 时保留已填写的自定义路径,方便再次切换。 + let (status, saved) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ + "source": "managed", + "custom_path": " C:\\tools\\uv.exe " + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(saved["source"], "managed"); + assert_eq!(saved["custom_path"], "C:\\tools\\uv.exe"); + + let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["source"], "managed"); + assert_eq!(body["custom_path"], "C:\\tools\\uv.exe"); + assert!( + body["candidates"] + .as_array() + .expect("candidates should be an array") + .iter() + .any(|candidate| candidate["source"] == "custom") + ); +} + +#[tokio::test] +async fn uv_settings_mutations_require_token() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + None, + Some(json!({ "source": "managed", "custom_path": null })), + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); +} + +#[tokio::test] +async fn uv_download_requires_token() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send_json(&app, Method::POST, "/api/v1/uv/download", None, None).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); +} diff --git a/contracts/openapi.json b/contracts/openapi.json index 70073b2..9ea8c74 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -89,6 +89,145 @@ } } }, + "/api/v1/python/envs": { + "get": { + "tags": [ + "python-envs" + ], + "operationId": "list_envs", + "responses": { + "200": { + "description": "Managed virtual environments", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedEnvSummary" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "python-envs" + ], + "operationId": "create_env", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedEnvRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Virtual environment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedEnvSummary" + } + } + } + }, + "400": { + "description": "Validation or uv failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/python/envs/{name}": { + "delete": { + "tags": [ + "python-envs" + ], + "operationId": "delete_env", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Environment name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Environment deleted" + }, + "404": { + "description": "Environment not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/python/envs/{name}/packages": { + "get": { + "tags": [ + "python-envs" + ], + "operationId": "list_env_packages", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Environment name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Installed packages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedEnvPackage" + } + } + } + } + }, + "404": { + "description": "Environment not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/python/mirrors": { "get": { "tags": [ @@ -343,6 +482,80 @@ } } }, + "/api/v1/python/pythons": { + "get": { + "tags": [ + "python-envs" + ], + "operationId": "list_pythons", + "responses": { + "200": { + "description": "Pythons downloaded by uv into the platform directory", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedPython" + } + } + } + } + }, + "400": { + "description": "uv unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "python-envs" + ], + "operationId": "install_python", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallPythonRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Python downloaded; returns the installed list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedPython" + } + } + } + } + }, + "400": { + "description": "Validation or uv failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/python/venv": { "post": { "tags": [ @@ -1160,6 +1373,94 @@ } } } + }, + "/api/v1/uv": { + "get": { + "tags": [ + "uv" + ], + "operationId": "get_uv_settings", + "responses": { + "200": { + "description": "uv settings and detected executables", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UvSettingsResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "uv" + ], + "operationId": "update_uv_settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUvSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "uv settings saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UvSettingsResponse" + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/uv/download": { + "post": { + "tags": [ + "uv" + ], + "operationId": "download_uv", + "responses": { + "200": { + "description": "uv downloaded from the mirror and switched to it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UvSettingsResponse" + } + } + } + }, + "400": { + "description": "Download, verification or install failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -1170,6 +1471,33 @@ "skip" ] }, + "CreateManagedEnvRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识" + }, + "packages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "预装包,每行一个,例如 `numpy==2.1.0`" + }, + "python_version": { + "type": [ + "string", + "null" + ], + "description": "创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定" + } + }, + "additionalProperties": false + }, "CreatePythonMirrorRequest": { "type": "object", "required": [ @@ -1398,6 +1726,19 @@ "POST" ] }, + "InstallPythonRequest": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "description": "要下载的版本,例如 `3.12` 或 `3.12.4`" + } + }, + "additionalProperties": false + }, "InterpreterKind": { "type": "string", "enum": [ @@ -1427,6 +1768,60 @@ } } }, + "ManagedEnvPackage": { + "type": "object", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "ManagedEnvSummary": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string", + "description": "虚拟环境根目录的绝对路径" + }, + "python_version": { + "type": [ + "string", + "null" + ], + "description": "取自 `pyvenv.cfg` 的 `version_info`" + } + } + }, + "ManagedPython": { + "type": "object", + "required": [ + "version", + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "uv 下载到平台目录下的解释器路径" + }, + "version": { + "type": "string" + } + } + }, "PythonMirrorResponse": { "type": "object", "description": "一个 Python 包索引镜像(pip / uv)。", @@ -2000,6 +2395,142 @@ }, "additionalProperties": false }, + "UpdateUvSettingsRequest": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "custom_path": { + "type": [ + "string", + "null" + ] + }, + "extra_env": { + "type": "object", + "description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "source": { + "$ref": "#/components/schemas/UvSource" + } + }, + "additionalProperties": false + }, + "UvCandidate": { + "type": "object", + "required": [ + "source", + "path", + "available" + ], + "properties": { + "available": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/UvSource" + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "UvSettingsResponse": { + "type": "object", + "required": [ + "source", + "extra_env", + "managed_dir", + "python_dir", + "cache_dir", + "env_defaults", + "candidates" + ], + "properties": { + "cache_dir": { + "type": "string", + "description": "实际生效的 uv 缓存目录(绝对路径)" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UvCandidate" + }, + "description": "各来源的探测结果" + }, + "custom_path": { + "type": [ + "string", + "null" + ] + }, + "env_defaults": { + "type": "object", + "description": "两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "executable": { + "type": [ + "string", + "null" + ], + "description": "当前设置指向的 uv;不可用时 `version` 为空" + }, + "extra_env": { + "type": "object", + "description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "managed_dir": { + "type": "string", + "description": "程序目录下的 uv 目录" + }, + "python_dir": { + "type": "string", + "description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)" + }, + "source": { + "$ref": "#/components/schemas/UvSource" + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "UvSource": { + "type": "string", + "description": "uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性\n会随启动方式变化,不适合当调度器的隐式依赖。", + "enum": [ + "managed", + "custom" + ] + }, "VenvProbeRequest": { "type": "object", "description": "编辑器在用户填写工作目录时发起的探测请求。", @@ -2041,7 +2572,7 @@ "properties": { "can_create": { "type": "boolean", - "description": "没有虚拟环境时,是否具备用 uv 创建的条件" + "description": "没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)" }, "display_path": { "type": [ diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 45c9c46..039f87c 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -18,6 +18,19 @@ - SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件) - 运行日志:`logs/{task_id}/{run_id}.log` +- 托管虚拟环境:`data/envs/<名称>/`(「虚拟环境」页创建,删除环境就是删掉整个目录) +- uv 下载的 Python 解释器:`python/<版本>/`(`UV_PYTHON_INSTALL_DIR` 默认指这里) + +## Python 与 uv + +用 uv 创建虚拟环境时,uv 的来源在「Python 环境」页切换(不读系统 PATH): + +- 本目录下的 `uv/uv.exe`(默认):没有 uv 时点「下载 uv」,会从科大镜像下载并校验 sha256 后放进来;也可以自己下载后放到这里 +- 任意位置的 `uv.exe`:选「指定路径」,保存时会先校验能运行;本机已装 uv 就把它的路径填进来 + +同一页还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),会注入到每一次 uv 调用。 + +「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;离线环境请自己准备 uv 并指定路径。 ## 配置 diff --git a/frontend/src/App.css b/frontend/src/App.css index c84a01f..4eb1d66 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -48,10 +48,9 @@ .page-container { width: 100%; - max-width: 1200px; + max-width: 1600px; min-width: 0; margin: 0 auto; - padding: 32px 32px 48px; } .page-stack { @@ -59,6 +58,7 @@ min-width: 0; flex-direction: column; gap: 24px; + padding: 32px 32px 48px; } /* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */ @@ -1203,3 +1203,319 @@ flex-direction: column; } } + +.uv-env-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.uv-env-row { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(240px, 1fr) auto; + gap: 8px; + align-items: center; +} + +@media (max-width: 720px) { + .uv-env-row { + grid-template-columns: 1fr; + } +} + +.env-packages-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px; +} + +/* ---------- 顶部导航(三栏工作台) ---------- */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + padding: 0 8px; +} + +.app-header-brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.app-header-nav { + display: flex; + align-items: center; + gap: 6px; +} + +.app-header-status { + margin-left: auto; +} + +/* ---------- 三栏工作台 ---------- */ +.dashboard { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 320px; + gap: 16px; + align-items: start; + padding: 16px 16px 40px; +} + +.dashboard-head { + grid-column: 1 / -1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 8px 4px 0; +} + +.dashboard-column { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.dash-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.dash-metric { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px; + border: 1px solid var(--su-border); + border-radius: 8px; + background-color: var(--su-bg-subtle); +} + +.dash-metric-value { + font-size: 22px; + font-weight: 600; +} + +.dash-metric-danger { + color: var(--su-color-danger); +} + +.dash-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.dash-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--su-border); +} + +.dash-list-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.dash-kv { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 10px; + min-width: 0; +} + +.dash-kv > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.task-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border: 1px solid var(--su-border); + border-radius: 8px; + background-color: var(--su-bg-subtle); + min-width: 0; +} + +.task-item-main { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.task-item-mark { + display: grid; + place-items: center; + flex: none; + width: 32px; + height: 32px; + border: 1px solid var(--su-border); + border-radius: 8px; + background-color: var(--su-bg-card); + font-weight: 700; +} + +.task-item-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.task-item-actions { + justify-content: flex-end; +} + +@media (max-width: 1200px) { + .dashboard { + grid-template-columns: 240px minmax(0, 1fr); + } + + .dashboard-right { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + } + + .dashboard-right > * { + flex: 1 1 260px; + } +} + +@media (max-width: 860px) { + .dashboard { + grid-template-columns: minmax(0, 1fr); + } + + .task-grid { + grid-template-columns: minmax(0, 1fr); + } + + .app-header { + flex-wrap: wrap; + } +} + +/* ---------- 左栏用户卡片 ---------- */ +.user-card { + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar { + position: relative; + flex: none; +} + +.user-online { + position: absolute; + right: 0; + bottom: 0; + width: 10px; + height: 10px; + border: 2px solid var(--su-bg-card); + border-radius: 50%; + background-color: #22c55e; +} + +.user-meta { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.user-dot { + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background-color: #22c55e; +} + +.user-bio { + margin: 12px 0 0; + padding-top: 10px; + border-top: 1px solid var(--su-border); + color: var(--su-text-muted); + font-size: 12px; + line-height: 1.6; +} + +/* 弹窗里的多个区块 */ +.modal-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* uv 弹窗:环境变量左标签 + 底部操作 */ +/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */ +.form-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.form-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.form-section-icon { + color: var(--su-text-muted); +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.form-actions { + display: flex; + align-items: center; + gap: 8px; + padding-top: 4px; +} + +.form-actions > button:last-child { + flex: 1; +} + +@media (max-width: 720px) { + .form-section-head { + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c47ef53..a4d6974 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -46,9 +46,58 @@ function jsonResponse(body: unknown, status = 200): Response { interface FetchMockOptions { directories?: (path: string | null) => Response + envs?: Array<{ + name: string + path: string + python_version: string | null + }> + envPackages?: Record> + pythons?: Array<{ version: string; path: string }> +} + +function uvSettingsPayload( + source: string, + customPath: string | null, + managedVersion: string | null = null, +) { + return { + source, + custom_path: customPath, + managed_dir: 'D:\\TaskPilot\\uv', + executable: + source === 'managed' + ? 'D:\\TaskPilot\\uv\\uv.exe' + : (customPath ?? 'C:\\tools\\uv\\uv.exe'), + version: source === 'managed' ? managedVersion : 'uv 0.9.0', + candidates: [ + { + source: 'managed', + path: 'D:\\TaskPilot\\uv\\uv.exe', + available: managedVersion !== null, + version: managedVersion, + }, + ...(customPath + ? [ + { + source: 'custom', + path: customPath, + available: true, + version: 'uv 0.9.0', + }, + ] + : []), + ], + } } function createFetchMock(options: FetchMockOptions = {}) { + let uvSource = 'managed' + let uvCustomPath: string | null = null + let uvManagedVersion: string | null = null + let managedEnvs = options.envs ?? [] + let managedPythons = options.pythons ?? [] + const managedPackages = options.envPackages ?? {} + return vi.fn( async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = @@ -97,6 +146,69 @@ function createFetchMock(options: FetchMockOptions = {}) { }, ]) } + if (url.endsWith('/api/v1/python/pythons') && init?.method === 'POST') { + const request = JSON.parse(String(init.body)) as { version: string } + const path = `D:\\TaskPilot\\python\\cpython-${request.version}-windows-x86_64-none\\python.exe` + if (!managedPythons.some((item) => item.version === request.version)) { + managedPythons = [ + ...managedPythons, + { version: request.version, path }, + ] + } + return jsonResponse(managedPythons) + } + if (url.endsWith('/api/v1/python/pythons')) { + return jsonResponse(managedPythons) + } + if (url.includes('/api/v1/python/envs/') && url.endsWith('/packages')) { + const name = decodeURIComponent( + url.split('/api/v1/python/envs/')[1].replace('/packages', ''), + ) + return jsonResponse(managedPackages[name] ?? []) + } + if (url.endsWith('/api/v1/python/envs') && init?.method === 'POST') { + const request = JSON.parse(String(init.body)) as { + name: string + python_version?: string | null + } + const created = { + name: request.name, + path: `D:\\TaskPilot\\data\\envs\\${request.name}`, + python_version: request.python_version ?? '3.11.11', + } + managedEnvs = [...managedEnvs, created] + return jsonResponse(created, 201) + } + if (url.includes('/api/v1/python/envs/') && init?.method === 'DELETE') { + const name = decodeURIComponent( + url.split('/api/v1/python/envs/')[1], + ) + managedEnvs = managedEnvs.filter((env) => env.name !== name) + return new Response(null, { status: 204 }) + } + if (url.endsWith('/api/v1/python/envs')) { + return jsonResponse(managedEnvs) + } + if (url.endsWith('/api/v1/uv/download')) { + uvSource = 'managed' + uvManagedVersion = 'uv 0.9.0' + return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion)) + } + if (url.endsWith('/api/v1/uv')) { + const request = init?.body + ? (JSON.parse(String(init.body)) as { + source: string + custom_path?: string | null + }) + : null + if (request) { + uvSource = request.source + uvCustomPath = request.custom_path ?? null + } + return jsonResponse( + uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion), + ) + } if (url.endsWith('/api/v1/cron/preview')) { return jsonResponse({ timezone: 'Asia/Shanghai', @@ -106,6 +218,21 @@ function createFetchMock(options: FetchMockOptions = {}) { ], }) } + if (url.endsWith('/api/v1/python/venv/create')) { + return jsonResponse({ + status: 'linked', + interpreter_kind: 'python', + venv_path: 'D:\\Projects\\uvproject\\.venv', + display_path: '.venv\\Scripts\\python.exe', + source: 'working_directory', + message: '已创建虚拟环境', + uv_available: true, + has_pyproject: true, + has_requirements: false, + requirements_files: [], + can_create: false, + }) + } if (url.endsWith('/api/v1/python/venv')) { const probe = JSON.parse(String(init?.body)) as { working_dir: string @@ -122,7 +249,7 @@ function createFetchMock(options: FetchMockOptions = {}) { ? '已自动关联工作目录中的虚拟环境' : creatable ? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv' - : '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv', + : '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv', uv_available: creatable, has_pyproject: creatable, has_requirements: false, @@ -189,21 +316,221 @@ describe('TaskDashboard', () => { expect(await screen.findByText('在线')).toBeInTheDocument() }) - it('navigates to python mirrors from the sidebar', async () => { + it('navigates to the python settings page from the sidebar', async () => { vi.stubGlobal('fetch', createFetchMock()) const user = userEvent.setup() renderApp() await user.click( - await screen.findByRole('button', { name: 'Python 镜像' }), + await screen.findByRole('button', { name: 'Python 环境' }), ) expect( - await screen.findByRole('heading', { name: 'Python 镜像' }), + await screen.findByRole('heading', { name: 'Python 环境' }), ).toBeInTheDocument() + // 镜像和 uv 在同一页 expect( await screen.findByRole('row', { name: /清华 TUNA/ }), ).toBeInTheDocument() + expect( + await screen.findByText('uv 可执行文件'), + ).toBeInTheDocument() + }) + + it('saves the uv executable and env vars picked on the python page', async () => { + const fetchMock = createFetchMock() + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: 'Python 环境' })) + expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument() + + await user.click( + screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }), + ) + await user.type( + screen.getByLabelText('uv 可执行文件'), + 'D:\\tools\\uv.exe', + ) + await user.click(screen.getByRole('button', { name: '+ 添加变量' })) + await user.type(screen.getByLabelText('环境变量名 1'), 'UV_CACHE_DIR') + await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache') + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([input, init]) => + String(input).endsWith('/api/v1/uv') && init?.method === 'PUT', + ), + ).toBe(true) + }) + const saved = fetchMock.mock.calls.find( + ([input, init]) => + String(input).endsWith('/api/v1/uv') && init?.method === 'PUT', + ) + expect(JSON.parse(String(saved?.[1]?.body))).toEqual({ + source: 'custom', + custom_path: 'D:\\tools\\uv.exe', + extra_env: { UV_CACHE_DIR: 'D:\\uv-cache' }, + }) + }) + + it('downloads uv from the mirror and switches to it', async () => { + const fetchMock = createFetchMock() + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: 'Python 环境' })) + await user.click(await screen.findByRole('button', { name: '下载 uv' })) + + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([input, init]) => + String(input).endsWith('/api/v1/uv/download') && + init?.method === 'POST', + ), + ).toBe(true) + }) + expect((await screen.findAllByText('uv 0.9.0')).length).toBeGreaterThan(0) + }) + + it('downloads a python version with uv', async () => { + const fetchMock = createFetchMock() + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '虚拟环境' })) + await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12') + await user.click(screen.getByRole('button', { name: '下载 Python' })) + + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([input, init]) => + String(input).endsWith('/api/v1/python/pythons') && + init?.method === 'POST', + ), + ).toBe(true) + }) + const installed = fetchMock.mock.calls.find( + ([input, init]) => + String(input).endsWith('/api/v1/python/pythons') && + init?.method === 'POST', + ) + expect(JSON.parse(String(installed?.[1]?.body))).toEqual({ + version: '3.12', + }) + }) + + it('creates a managed python environment with prefetched packages', async () => { + const fetchMock = createFetchMock({ + pythons: [ + { + version: '3.11.11', + path: 'D:\\TaskPilot\\python\\cpython-3.11.11-windows-x86_64-none\\python.exe', + }, + ], + }) + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '虚拟环境' })) + expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '+ 新建环境' })) + await user.type(screen.getByLabelText('环境名'), 'data-science') + await user.click(screen.getByRole('button', { name: '默认(uv 自动选)' })) + await user.click(await screen.findByRole('option', { name: '3.11.11' })) + await user.type( + screen.getByLabelText(/预装包/), + 'numpy==2.1.0{enter}pandas', + ) + await user.click(screen.getByRole('button', { name: '创建' })) + + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([input, init]) => + String(input).endsWith('/api/v1/python/envs') && + init?.method === 'POST', + ), + ).toBe(true) + }) + const created = fetchMock.mock.calls.find( + ([input, init]) => + String(input).endsWith('/api/v1/python/envs') && + init?.method === 'POST', + ) + expect(JSON.parse(String(created?.[1]?.body))).toEqual({ + name: 'data-science', + python_version: '3.11.11', + packages: ['numpy==2.1.0', 'pandas'], + }) + }) + + it('shows installed packages of a managed environment', async () => { + vi.stubGlobal( + 'fetch', + createFetchMock({ + envs: [ + { + name: 'data-science', + path: 'D:\\TaskPilot\\data\\envs\\data-science', + python_version: '3.11.11', + }, + ], + envPackages: { + 'data-science': [{ name: 'numpy', version: '2.1.0' }], + }, + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '虚拟环境' })) + await user.click(await screen.findByRole('button', { name: '查看包' })) + + expect(await screen.findByText('numpy')).toBeInTheDocument() + expect(screen.getByText('2.1.0')).toBeInTheDocument() + }) + + it('creates the python environment before saving a new task', async () => { + const fetchMock = createFetchMock() + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.clear(screen.getByLabelText(/任务标识/)) + await user.type(screen.getByLabelText(/任务标识/), 'uv_env_task') + await user.type(screen.getByLabelText('名称'), 'uv env task') + await user.type( + screen.getByLabelText(/项目工作目录/), + 'D:\\Projects\\uvproject', + ) + await user.click(screen.getByRole('button', { name: '保存并热刷新' })) + + await waitFor(() => { + const requests = fetchMock.mock.calls.map(([input, init]) => ({ + url: String(input), + method: init?.method, + })) + const venvIndex = requests.findIndex((request) => + request.url.endsWith('/api/v1/python/venv/create'), + ) + const taskIndex = requests.findIndex( + (request) => + request.url.endsWith('/api/v1/tasks') && request.method === 'POST', + ) + expect(venvIndex).toBeGreaterThanOrEqual(0) + expect(taskIndex).toBeGreaterThan(venvIndex) + }) }) it('shows the next run time reported by the schedule endpoint', async () => { @@ -284,7 +611,7 @@ describe('TaskDashboard', () => { ) expect( - await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, { + await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, { timeout: 3000, }), ).toBeInTheDocument() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4303cf..d3d99b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,6 @@ import { Button, Layout, Result } from '@stash/ui' import { Route, Routes, useNavigate } from 'react-router-dom' -import { AppSidebar } from './components/AppSidebar' -import { PythonMirrorsPage } from './pages/PythonMirrorsPage' +import { AppHeader } from './components/AppHeader' import { TaskDashboard } from './pages/TaskDashboard' import './App.css' @@ -24,15 +23,13 @@ function App() { return ( } - siderWidth={224} + header={} maxWidth="full" contentPadding="none" >
} /> - } /> } />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3b721ce..354ba2a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -33,6 +33,17 @@ export type CreatePythonMirrorRequest = components['schemas']['CreatePythonMirrorRequest'] export type UpdatePythonMirrorRequest = components['schemas']['UpdatePythonMirrorRequest'] +export type UvSettings = components['schemas']['UvSettingsResponse'] +export type UvCandidate = components['schemas']['UvCandidate'] +export type UvSource = components['schemas']['UvSource'] +export type UpdateUvSettingsRequest = + components['schemas']['UpdateUvSettingsRequest'] +export type ManagedEnv = components['schemas']['ManagedEnvSummary'] +export type ManagedEnvPackage = components['schemas']['ManagedEnvPackage'] +export type CreateManagedEnvRequest = + components['schemas']['CreateManagedEnvRequest'] +export type ManagedPython = components['schemas']['ManagedPython'] +export type InstallPythonRequest = components['schemas']['InstallPythonRequest'] export interface TaskFilters { q?: string @@ -359,3 +370,69 @@ export function setDefaultPythonMirror(id: string): Promise { { method: 'POST' }, ) } + +/** uv 设置:只用程序目录 uv 或自己指定的可执行文件(不读系统 PATH)。 */ +export function getUvSettings(signal?: AbortSignal): Promise { + return requestJson('/api/v1/uv', { signal }) +} + +export function updateUvSettings( + request: UpdateUvSettingsRequest, +): Promise { + return requestJson('/api/v1/uv', { + method: 'PUT', + body: JSON.stringify(request), + }) +} + +/** 本机没有 uv 时,从镜像下载到程序目录并切换过去。 */ +export function downloadUv(): Promise { + return requestJson('/api/v1/uv/download', { method: 'POST' }) +} + +/** 预创建的托管虚拟环境(放在 data/envs 下)。 */ +export function listManagedEnvs( + signal?: AbortSignal, +): Promise { + return requestJson('/api/v1/python/envs', { signal }) +} + +export function createManagedEnv( + request: CreateManagedEnvRequest, +): Promise { + return requestJson('/api/v1/python/envs', { + method: 'POST', + body: JSON.stringify(request), + }) +} + +export function listManagedEnvPackages( + name: string, + signal?: AbortSignal, +): Promise { + return requestJson( + `/api/v1/python/envs/${encodeURIComponent(name)}/packages`, + { signal }, + ) +} + +export function deleteManagedEnv(name: string): Promise { + return requestJson( + `/api/v1/python/envs/${encodeURIComponent(name)}`, + { method: 'DELETE' }, + ) +} + +/** uv 下载到平台目录(`python\`)里的解释器。 */ +export function listPythons(signal?: AbortSignal): Promise { + return requestJson('/api/v1/python/pythons', { signal }) +} + +export function installPython( + request: InstallPythonRequest, +): Promise { + return requestJson('/api/v1/python/pythons', { + method: 'POST', + body: JSON.stringify(request), + }) +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 4ecc4ee..284cbb7 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -52,6 +52,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/python/envs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_envs"]; + put?: never; + post: operations["create_env"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/python/envs/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_env"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/python/envs/{name}/packages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_env_packages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/python/mirrors": { parameters: { query?: never; @@ -100,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/python/pythons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_pythons"]; + put?: never; + post: operations["install_python"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/python/venv": { parameters: { query?: never; @@ -356,12 +420,52 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/uv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_uv_settings"]; + put: operations["update_uv_settings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uv/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["download_uv"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { /** @enum {string} */ ConcurrencyPolicy: "skip"; + CreateManagedEnvRequest: { + /** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */ + name: string; + /** @description 预装包,每行一个,例如 `numpy==2.1.0` */ + packages?: string[]; + /** @description 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定 */ + python_version?: string | null; + }; CreatePythonMirrorRequest: { enabled?: boolean; index_url: string; @@ -426,6 +530,10 @@ export interface components { }; /** @enum {string} */ HttpMethod: "GET" | "POST"; + InstallPythonRequest: { + /** @description 要下载的版本,例如 `3.12` 或 `3.12.4` */ + version: string; + }; /** @enum {string} */ InterpreterKind: "python" | "node" | "other"; LogChunkResponse: { @@ -434,6 +542,22 @@ export interface components { /** Format: int64 */ next_offset: number; }; + ManagedEnvPackage: { + name: string; + version: string; + }; + ManagedEnvSummary: { + name: string; + /** @description 虚拟环境根目录的绝对路径 */ + path: string; + /** @description 取自 `pyvenv.cfg` 的 `version_info` */ + python_version?: string | null; + }; + ManagedPython: { + /** @description uv 下载到平台目录下的解释器路径 */ + path: string; + version: string; + }; /** @description 一个 Python 包索引镜像(pip / uv)。 */ PythonMirrorResponse: { /** Format: date-time */ @@ -611,6 +735,49 @@ export interface components { timeout_secs?: number; timezone?: string; }; + UpdateUvSettingsRequest: { + custom_path?: string | null; + /** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */ + extra_env?: { + [key: string]: string; + }; + source: components["schemas"]["UvSource"]; + }; + UvCandidate: { + available: boolean; + path: string; + source: components["schemas"]["UvSource"]; + version?: string | null; + }; + UvSettingsResponse: { + /** @description 实际生效的 uv 缓存目录(绝对路径) */ + cache_dir: string; + /** @description 各来源的探测结果 */ + candidates: components["schemas"]["UvCandidate"][]; + custom_path?: string | null; + /** @description 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 */ + env_defaults: { + [key: string]: string; + }; + /** @description 当前设置指向的 uv;不可用时 `version` 为空 */ + executable?: string | null; + /** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */ + extra_env: { + [key: string]: string; + }; + /** @description 程序目录下的 uv 目录 */ + managed_dir: string; + /** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */ + python_dir: string; + source: components["schemas"]["UvSource"]; + version?: string | null; + }; + /** + * @description uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性 + * 会随启动方式变化,不适合当调度器的隐式依赖。 + * @enum {string} + */ + UvSource: "managed" | "custom"; /** @description 编辑器在用户填写工作目录时发起的探测请求。 */ VenvProbeRequest: { /** @description 解释器或可执行程序,对应 `action.interpreter` */ @@ -621,7 +788,7 @@ export interface components { working_dir: string; }; VenvProbeResponse: { - /** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */ + /** @description 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) */ can_create: boolean; /** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */ display_path?: string | null; @@ -729,6 +896,121 @@ export interface operations { }; }; }; + list_envs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Managed virtual environments */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedEnvSummary"][]; + }; + }; + }; + }; + create_env: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateManagedEnvRequest"]; + }; + }; + responses: { + /** @description Virtual environment created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedEnvSummary"]; + }; + }; + /** @description Validation or uv failure */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete_env: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Environment name */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Environment deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Environment not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + list_env_packages: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Environment name */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Installed packages */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedEnvPackage"][]; + }; + }; + /** @description Environment not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; list_python_mirrors: { parameters: { query?: never; @@ -939,6 +1221,68 @@ export interface operations { }; }; }; + list_pythons: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pythons downloaded by uv into the platform directory */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedPython"][]; + }; + }; + /** @description uv unavailable */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + install_python: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InstallPythonRequest"]; + }; + }; + responses: { + /** @description Python downloaded; returns the installed list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedPython"][]; + }; + }; + /** @description Validation or uv failure */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; probe_python_venv: { parameters: { query?: never; @@ -1544,4 +1888,86 @@ export interface operations { }; }; }; + get_uv_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description uv settings and detected executables */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UvSettingsResponse"]; + }; + }; + }; + }; + update_uv_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateUvSettingsRequest"]; + }; + }; + responses: { + /** @description uv settings saved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UvSettingsResponse"]; + }; + }; + /** @description Validation failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + download_uv: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description uv downloaded from the mirror and switched to it */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UvSettingsResponse"]; + }; + }; + /** @description Download, verification or install failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; } diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx new file mode 100644 index 0000000..25da5f7 --- /dev/null +++ b/frontend/src/components/AppHeader.tsx @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' +import { Brand, Space, Tag, Text } from '@stash/ui' +import { fetchHealth } from '../api/client' + +/** + * 顶部导航:品牌 + 引擎状态。 + * + * 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗), + * 所以这里不再放页面级导航。 + */ +export function AppHeader() { + const healthQuery = useQuery({ + queryKey: ['health'], + queryFn: ({ signal }) => fetchHealth(signal), + refetchInterval: 30_000, + }) + + const status = healthQuery.isPending + ? { tone: 'neutral' as const, label: '检测中' } + : healthQuery.isError + ? { tone: 'danger' as const, label: '引擎离线' } + : { tone: 'success' as const, label: '引擎在线' } + + return ( +
+
+ + + 本地任务调度 · Windows + +
+ + + + {status.label} + + + {healthQuery.data?.version + ? `Rust 引擎 v${healthQuery.data.version}` + : '127.0.0.1:3000'} + + +
+ ) +} diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx deleted file mode 100644 index b669fbd..0000000 --- a/frontend/src/components/AppSidebar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Brand, Icon, Menu, Tag, Text, type MenuItem } from '@stash/ui' -import { useLocation, useNavigate } from 'react-router-dom' -import { fetchHealth } from '../api/client' - -const NAV_ITEMS: MenuItem[] = [ - { - key: '/', - label: '任务调度', - icon: , - }, - { - key: '/mirrors', - label: 'Python 镜像', - icon: , - }, -] - -/** - * 左侧导航:品牌 / 主导航 / 引擎状态。 - * - * 页面本身只负责内容,导航与全局状态收在这里;窄屏时 Layout 会把侧栏 - * 堆到内容上方,这里再用横向布局变形成顶部导航条。 - */ -export function AppSidebar() { - const location = useLocation() - const navigate = useNavigate() - const activeKey = location.pathname === '/mirrors' ? '/mirrors' : '/' - const healthQuery = useQuery({ - queryKey: ['health'], - queryFn: ({ signal }) => fetchHealth(signal), - refetchInterval: 30_000, - }) - - const status = healthQuery.isPending - ? { tone: 'neutral' as const, label: '检测中' } - : healthQuery.isError - ? { tone: 'danger' as const, label: '引擎离线' } - : { tone: 'success' as const, label: '引擎在线' } - - return ( -
-
- - - 本地任务调度 · Windows - -
- - navigate(key)} - itemHeight={38} - /> - -
-
- - {status.label} - - - {healthQuery.data?.version - ? `Rust 引擎 v${healthQuery.data.version}` - : '127.0.0.1:3000'} - -
-
-
- ) -} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx deleted file mode 100644 index 7bff8d0..0000000 --- a/frontend/src/components/PageHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Space, Text, Title } from '@stash/ui' -import type { ReactNode } from 'react' - -interface PageHeaderProps { - eyebrow: string - title: string - subtitle?: ReactNode - actions?: ReactNode -} - -/** 页面标题区:左侧标题 / 说明,右侧操作。所有页面共用。 */ -export function PageHeader({ - eyebrow, - title, - subtitle, - actions, -}: PageHeaderProps) { - return ( -
-
-

{eyebrow}

- {title} - {subtitle !== undefined ? {subtitle} : null} -
- {actions !== undefined ? ( - - {actions} - - ) : null} -
- ) -} diff --git a/frontend/src/components/PythonEnvsSection.tsx b/frontend/src/components/PythonEnvsSection.tsx new file mode 100644 index 0000000..b769c5d --- /dev/null +++ b/frontend/src/components/PythonEnvsSection.tsx @@ -0,0 +1,342 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Empty, + FormField, + Input, + Modal, + Notice, + Result, + Select, + Space, + Spinner, + Table, + Tag, + Text, + Textarea, + TextButton, + toast, + type TableColumn, +} from '@stash/ui' +import { useState } from 'react' +import { + createManagedEnv, + deleteManagedEnv, + listManagedEnvPackages, + listManagedEnvs, + listPythons, + type ManagedEnv, +} from '../api/client' +import { ConfirmDialog } from './ConfirmDialog' + +const ENV_COLUMNS: TableColumn[] = [ + { key: 'name', title: '名称', width: 200 }, + { key: 'python_version', title: 'Python', width: 120 }, + { key: 'path', title: '路径', width: 380 }, + { key: 'actions', title: '操作', align: 'right', width: 180 }, +] + +const PACKAGE_COLUMNS: TableColumn[] = [ + { key: 'name', title: '包', width: 320 }, + { key: 'version', title: '版本', width: 160 }, +] + +function linesToPackages(value: string): string[] { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +/** + * 「Python 环境」页的托管虚拟环境区块。 + * + * 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进 + * 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。 + */ +export function PythonEnvsSection() { + const queryClient = useQueryClient() + const [createOpen, setCreateOpen] = useState(false) + const [name, setName] = useState('') + const [pythonVersion, setPythonVersion] = useState('') + const [packagesText, setPackagesText] = useState('') + const [selected, setSelected] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const envsQuery = useQuery({ + queryKey: ['managed-envs'], + queryFn: ({ signal }) => listManagedEnvs(signal), + }) + const pythonsQuery = useQuery({ + queryKey: ['managed-pythons'], + queryFn: ({ signal }) => listPythons(signal), + }) + const pythonOptions = [ + { value: '', label: '默认(uv 自动选)' }, + ...(pythonsQuery.data ?? []).map((python) => ({ + value: python.version, + label: python.version, + })), + ] + const packagesQuery = useQuery({ + queryKey: ['managed-env-packages', selected], + queryFn: ({ signal }) => listManagedEnvPackages(selected ?? '', signal), + enabled: Boolean(selected), + }) + + const createMutation = useMutation({ + mutationFn: () => + createManagedEnv({ + name: name.trim(), + python_version: pythonVersion.trim() || null, + packages: linesToPackages(packagesText), + }), + onSuccess: (env: ManagedEnv) => { + setCreateOpen(false) + setName('') + setPythonVersion('') + setPackagesText('') + toast.success( + env.python_version + ? `已创建环境 ${env.name}(Python ${env.python_version})` + : `已创建环境 ${env.name}`, + ) + void queryClient.invalidateQueries({ queryKey: ['managed-envs'] }) + }, + onError: (error) => toast.error(error.message), + }) + + const deleteMutation = useMutation({ + mutationFn: (env: ManagedEnv) => deleteManagedEnv(env.name), + onSuccess: (_data, env) => { + setDeleteTarget(null) + if (selected === env.name) { + setSelected(null) + } + toast.success(`已删除环境 ${env.name}`) + void queryClient.invalidateQueries({ queryKey: ['managed-envs'] }) + }, + onError: (error) => toast.error(error.message), + }) + + const envs = envsQuery.data ?? [] + const rows = envs.map((env) => ({ + id: env.name, + name: {env.name}, + python_version: env.python_version ? ( + {env.python_version} + ) : ( + + — + + ), + path: ( + + {env.path} + + ), + actions: ( + + setSelected(env.name)}>查看包 + setDeleteTarget(env)}> + 删除 + + + ), + })) + + return ( + <> + + + + 虚拟环境 + + 预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。 + + + + + + + + {envsQuery.isError ? ( + void envsQuery.refetch()} + > + 重试 + + } + /> + ) : envsQuery.isPending ? ( +
+ + 正在读取虚拟环境... +
+ ) : envs.length === 0 ? ( + setCreateOpen(true)}>新建环境} + /> + ) : ( + + )} + + + {selected ? ( + +
+ {selected} 已安装的包 + + + 共 {packagesQuery.data?.length ?? 0} 个 + + setSelected(null)}>收起 + +
+ {packagesQuery.isError ? ( + + ) : packagesQuery.isPending ? ( +
+ +
+ ) : (packagesQuery.data?.length ?? 0) === 0 ? ( + + ) : ( +
({ + id: item.name, + name: {item.name}, + version: {item.version}, + }))} + /> + )} + + ) : null} + + + 「新建环境」会用 uv 建一个空环境并按需预装包(需要网络);之后不会自动升级依赖。 + 运行任务不会改动这些环境。 + + + { + if (!createMutation.isPending) { + setCreateOpen(false) + } + }} + title="新建虚拟环境" + size="md" + closeOnOverlay={!createMutation.isPending} + footer={ + <> + + + + } + > +
{ + event.preventDefault() + if (!name.trim()) { + toast.error('请填写环境名') + return + } + createMutation.mutate() + }} + > + + setName(event.target.value)} + className="mono-input" + placeholder="data-science" + spellCheck={false} + /> + + + +