Compare commits

..
25 Commits
Author SHA1 Message Date
CNWei 8fdc55e17d feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构
- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320
- 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边
- uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页
- 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量
- 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*,
  运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
2026-11-08 21:26:47 +08:00
CNWei 37479f48c7 docs: 更新 uv 与虚拟环境文档
- README:三栏工作台与弹窗说明、uv 路径默认值(python / uv-cache)、镜像只在装包时生效、API 与数据布局、测试现状注记

- RELEASE:补 uv-cache 与 uv 目录、配置入口改为弹窗、运行时注入说明

- 新增 ADR-0009:uv 与托管 Python 环境的决策与边界

- PLAN:补记已完成的后继迭代
2026-11-07 17:05:02 +08:00
CNWei 1914d38451 feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验

- uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程

- 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包

- 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用

- 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗

- 发布包构建后去掉工作区继承的低完整性标签

- 修复调度集成测试并发时误取 skipped 运行的问题
2026-11-07 16:57:27 +08:00
CNWei 2aa2a1ac18 fix(frontend): 环境探测不再显示加载态,只在最终结果更新
探测期间保留上一次最终结果,移除正在探测提示;首次无结果时保持空状态;占位结果不参与自动回填,最终结果返回后一次性更新。
2026-11-04 23:27:43 +08:00
CNWei 62f1661b2c fix(frontend): 切换目录时只保留一次虚拟环境状态
切换目录时先同步清掉旧目录自动填入的 venv;实时 venv 为空时立即忽略防抖旧值;旧查询结果不再回填新目录;补充 Playwright 回归用例。
2026-11-04 23:20:54 +08:00
CNWei 8501deba07 Revert "fix(frontend): 稳定虚拟环境探测状态,避免输入时闪烁"
This reverts commit 78ddc17fce.
2026-11-04 23:14:28 +08:00
CNWei 78ddc17fce fix(frontend): 稳定虚拟环境探测状态,避免输入时闪烁
探测状态保留上一次结果,新结果返回后再更新;空目录改为中性提示;防抖期间不因旧工作目录闪出警告;占位数据期间不自动回填虚拟环境。
2026-11-04 23:02:30 +08:00
CNWei 10f8e6bb66 style(frontend): 精简脚本语言提示并移除解释器输入框
- 脚本语言提示从大块灰色卡片改成下拉框右侧的一行说明
- Python 表单不再显示解释器输入框,固定使用 .venv 里的 Python
- 虚拟环境目录字段改为全宽并明确自动探测语义
- 非 Python 语言仍由语言自动设置 cmd.exe/node.exe/lua.exe,API 仍可传自定义 interpreter
2026-11-04 22:51:37 +08:00
CNWei 917674880b feat: 拆分任务类型并支持脚本语言联动
- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua)
- 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览
- 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器
- TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
2026-11-04 22:07:14 +08:00
CNWei 8e6b3b92b9 feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create
- 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像
- 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错
- 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器
- 补后端探测/创建测试、前端入口测试与 README
2026-11-03 22:57:17 +08:00
CNWei 29fc579275 feat: 左侧导航改版与 Python 镜像管理
- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航
- 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方
- Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先
- 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除
- 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
2026-11-03 22:12:11 +08:00
CNWei d7a6e17531 style(frontend): 去掉刷新提示条、隐藏弹窗滚动条并前置时区列
- 刷新按钮保持「刷新」文案,不再出现「同步中...」和顶部进度条,仅禁用防重复点击
- 任务编辑器 / 目录选择弹窗隐藏滚动条,滚轮与触控板仍可滚动
- 任务表「时区」列移到「调度周期」前面
2026-11-02 23:43:31 +08:00
CNWei c7a66c22b6 feat(frontend): 时区下拉、表格时区列与刷新反馈
- 编辑器 IANA 时区改为 Select,当前只保留 Asia/Shanghai;编辑旧任务时保留其已有值
- 任务表新增「时区」列,类型标签并入名称列,重新分配列宽
- 手动刷新改为独立状态 + 顶部进度条 + 固定按钮宽度,避免刷新时工具栏左右跳动与旧内容残留的观感
2026-11-02 23:32:03 +08:00
CNWei 608872bf06 fix(frontend): 修复已结束运行打不开日志的问题
StrictMode 的 effect 清理会取消挂载时排队的 rAF,但没有把句柄归零;已结束的 run 在挂载时就处于 complete 状态,后续 ingest 看到「还有帧在排队」便不再安排渲染,日志一直停在「等待日志输出...」。取消后把 frameRef 归零,并在 e2e 增加「运行结束后重新打开日志」的回放断言。
2026-11-02 23:19:07 +08:00
CNWei d3f0f57f6d feat(frontend): 自动回填并支持修改虚拟环境目录
- 虚拟环境目录与解释器移到工作目录正下方,虚拟环境目录优先且更宽
- 探测到虚拟环境时自动填入(工作目录内用相对路径 .venv),仍可手动修改
- 手动改过的值不再被探测结果覆盖;换工作目录只清理上一轮自动填入的值并重新探测
- 防抖期间忽略过期探测结果,避免旧环境被回填
2026-11-02 23:08:13 +08:00
CNWei b7ded69a7d fix(cron): 用中文说明替代解析器原始报错
- 后端 CronPreviewError 增加 user_message,预览与任务保存共用;解析器英文原文只写日志
- 针对「0/2」这类单数字步长给出具体改法(*/2 或 1-10/2)
- 前端预览面板用警告图标 + 中文提示展示,网络异常回退通用文案,Cron 输入同步 aria-invalid
2026-11-02 22:35:39 +08:00
CNWei b2ff255e32 style(frontend): 重排任务编辑器布局
- 未来运行时间改为全宽三列面板,次数选择器移到面板头部,去掉内部滚动条
- Cron / 超时、时区 / 任务类型两两成行,消除原右列的大片空白
- 校验改为提交时触发,避免 blur 插错误行导致下方控件位移、下拉框点击丢失
2026-11-02 22:28:31 +08:00
CNWei 369fc07b62 style(frontend): 搜索框贴左边缘对齐
工具栏去掉左侧内边距,让搜索框与页头、指标卡的左边缘对齐;右侧保留 16px 给筛选控件,窄屏恢复左右对称留白。
2026-11-02 22:00:04 +08:00
CNWei 69d721055d fix(frontend): 补齐全局按钮 reset,修复 Select 选项灰底
组件库的 Select / Segmented / Tabs / Menu 都把按钮底色交给宿主 reset;之前 index.css 只设了 cursor,Chrome 默认的 ButtonFace 灰底和按钮边框就露了出来,下拉面板看起来像一块块灰条。
2026-11-02 21:47:03 +08:00
CNWei 3c1a603b90 style(frontend): 接入 stash-ui 设计系统
- 依赖 @stash/ui(file:../../stash-ui),统一引入 style.css 与 Toaster
- 任务面板、表单、弹窗、日志、状态标签全部迁到组件库,App.css 只保留页面布局
- 移除自写 useFocusTrap,改用基于原生 dialog 的 Modal
- 修复 LogViewer 流式追加时误判离开底部、导致不再自动跟随的问题
- 发布脚本检查 stash-ui 产物;README 补充组件库构建与 file: 依赖刷新说明
2026-11-01 23:54:35 +08:00
CNWei 07ca14ea34 perf(logs): 日志事件按窗口合并推送,抽屉改为虚拟滚动
- 后端 LogSink 把逐行事件按 16 KiB / 100 ms 合并成一条 log 事件,文件写入保持即时;后台 tick 与析构 flush 保证低输出和收尾都不丢尾部
- 前端新增 LogViewer:固定行高虚拟滚动、跟随底部/回到最新,增量按 rAF 合并,长日志 DOM 节点数恒定
- 日志抽屉改为行缓冲(lib/logLines)+ 可见窗口计算(lib/logWindow)
- 详情区改 flex 布局,日志面板吃掉剩余高度,回到最新按钮不再被裁到面板外
- 测试:Rust 单元 52→57,Vitest 13→28,Playwright 4→5(1500 行虚拟滚动)
- 文档:README 补充合并推送与虚拟滚动,新增 ADR-0008
2026-10-27 22:38:53 +08:00
CNWei 14fb3563c5 chore(fonts): 字体二进制不入库,改用脚本生成
- 远程仓库对单次推送体积有限制,6.7 MB 字体无法一次推送,先从版本库移除
- 新增 tools/build-fonts.py:下载官方分包、合并并子集化,一条命令重建
- @font-face 保留,字体缺失时回退系统字体;离线用例改为按可用性断言
2026-10-26 00:46:20 +08:00
CNWei 942f8de3b7 docs: 重写 README 并加入 uv 示例与端到端自检
- README 重排为快速开始、执行语义、输入校验、API 参考与故障排查
- 新增 samples/python-task:uv 管理的演示任务
- 新增 tools/verify.py:14 项 API 全链路自检
2026-10-26 00:38:16 +08:00
CNWei 815258c19b feat: 加固运行期可靠性并补齐调度与观测接口
- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态
- 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected
- 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule
- SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB
- 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
2026-10-26 00:38:16 +08:00
CNWei 773196d689 style(frontend): 对齐 Alright 主题并接入离线字体
- 主题色板、圆角、阴影与 0.3s 缓动对齐参考站点,布局与尺寸不变
- index.html 预加载本地字体,index.css 定义 LXGW WenKai / Outfit-Medium
- 新增断网渲染端到端用例与 ADR-0006
2026-10-26 00:38:15 +08:00
85 changed files with 14584 additions and 3187 deletions
+5
View File
@@ -17,7 +17,12 @@
/data/
/logs/
/.e2e-data/
/.tmp/
/release/
/samples/python-task/.venv/
# 字体二进制体积大(约 6.7 MB),不入库;用 tools/build-fonts.py 重新生成
/frontend/public/fonts/LXGWWenKai-*.woff2
# Editors and operating system files
/.idea/
+11 -2
View File
@@ -146,8 +146,6 @@ Task
├── timezone 默认使用系统时区,可选择 IANA 时区
├── enabled 是否参与自动调度
├── timeout_secs 单次执行超时
├── concurrency_policy 首版固定为 skip
├── retry_policy 首版允许 0 次重试,结构上预留
├── action 带 type 标签的任务动作
├── created_at
└── updated_at
@@ -462,3 +460,14 @@ cargo test --manifest-path backend/Cargo.toml
4. 任务模板、导入导出和批量操作。
5. 运行指标、告警和长期监控。
6. HTTP 响应断言、认证方案和请求重放。
## 13. 已完成的后继迭代
计划阶段之外已经交付、并已写入 ADR 的改动:
- **本地 uv 管理**([ADR-0009](docs/decisions/0009-uv-and-managed-environments.md)):uv 只用程序目录副本或指定路径,
不读系统 PATH;可从科大镜像下载并校验 sha256;`UV_*` 环境变量可配置,两个路径变量默认相对程序目录(`python` / `uv-cache`)。
- **托管虚拟环境**:`data/envs/<名称>`、uv 下载的 Python 版本管理、预装包与已装包查看。
- **环境时机调整**:新建任务保存时用 uv 创建 `.venv`,运行期只校验;运行任务会注入平台 uv、`UV_*` 与默认 `UV_DEFAULT_INDEX`。
- **界面**:顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗(uv 与镜像源不再同页)。
+555 -265
View File
@@ -1,374 +1,664 @@
# TaskPilot
TaskPilot 是一个面向 Windows 的本地定时任务管理平台。项目目标是用 React 提供任务管理界面,用 Rust 实现 Cron 调度、Windows 进程管理和运行日志。
TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。
当前已完成 Phase 1 至 Phase 6:React 任务管理界面、Rust API、SQLite 持久化、Cron 调度、三类任务执行器、实时日志、安全边界、生产静态托管和 Windows 本地打包均已落地。
- 单用户、单实例,只监听 `127.0.0.1`
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
- SQLite 持久化,重启后任务与历史仍在
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
仓库根目录的 `task_pilot.html` 是最初的单文件视觉原型,目前只作为 React 迁移期间的设计参考,不是正式前端入口。
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标卡、任务表格、660px 编辑弹窗、Cron 预览区和深色日志查看器均在保留真实功能的前提下复刻。
---
## 当前能力
## 目录
- React 页面可以检测 Rust 后端连接状态。
- Vite 开发服务器将 `/api` 代理到 Axum 后端。
- Rust 服务提供健康检查和 OpenAPI 文档。
- OpenAPI 可以导出为稳定快照,并生成前端 TypeScript 类型。
- Cron POC 支持 5/6 位表达式、秒级调度、别名、月份/星期名称和 POSIX 日/星期语义。
- 任务可以持久化到 SQLite,并支持创建、查询、更新和删除。
- 任务列表支持名称/ID 搜索、启用状态筛选、Action 类型筛选和分页。
- 脚本、HTTP 和二进制进程任务具有独立的类型化载荷与后端校验。
- 启用的脚本任务由 Tokio 调度循环自动执行,并支持启动后恢复调度。
- 后端重启后,已启用任务仍可持久化并重新注册。
- 支持手动立即运行,重复触发时记录为 `skipped`,不会并发执行同一任务。
- 每次运行记录触发来源、状态、耗时、退出码和错误信息。
- 已实现 Windows Job Object,超时或关闭时会终止目标进程树。
- stdout 和 stderr 会写入独立运行日志,并可通过 API 增量读取。
- HTTP Action 支持 GET/POST、Header、Body、超时和期望状态码。
- 二进制进程 Action 使用 Job Object 执行,并支持工作目录、参数和环境变量。
- 运行中任务可以通过 API 或前端日志抽屉取消。
- `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
- 工作目录支持「选择文件夹…」打开页面内的目录浏览器(盘符 → 逐级进入 → 使用此目录),选中后回填输入框,同时保留手动输入。
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
- Playwright 已覆盖运行日志、编辑、停用、删除和窄屏布局主流程。
- 后端只监听 `127.0.0.1`,拒绝非本地 Origin。
- 浏览器写操作需要运行期随机管理令牌,进程重启后自动更换。
- Axum 在生产模式托管 React 静态资源,并支持 SPA 路由回退。
- 弹窗和日志抽屉支持初始焦点、Tab 循环、Escape 关闭和焦点恢复。
- 提供 Windows Release 打包和一键启动脚本。
- 前端与后端均具备基础测试和统一质量检查命令。
- [快速开始](#快速开始)
- [功能总览](#功能总览)
- [任务模型](#任务模型)
- [调度与执行语义](#调度与执行语义)
- [日志与编码](#日志与编码)
- [Python 与虚拟环境](#python-与虚拟环境)
- [Web 界面](#web-界面)
- [安全模型](#安全模型)
- [配置项](#配置项)
- [API 参考](#api-参考)
- [输入校验](#输入校验)
- [数据与文件布局](#数据与文件布局)
- [架构与模块](#架构与模块)
- [开发与测试](#开发与测试)
- [故障排查](#故障排查)
- [已知限制](#已知限制)
- [架构决策记录](#架构决策记录)
## 技术栈
---
### 前端
## 快速开始
- React 19
- TypeScript 5.9
- Vite 8
- React Router
- TanStack Query
- React Hook Form
- Zod
- Vitest + Testing Library
- Playwright
### 后端
- Rust 1.95.0 MSVC
- Axum
- Tokio
- Serde
- Utoipa / OpenAPI
- Croner
- SQLx / SQLite
- Tower HTTP
### 工具链
- Volta 2.0.2
- Node.js 24.12.0
- pnpm 11.20.0
Node.js 和 pnpm 版本固定在 `frontend/package.json` 的 `volta` 字段中,Rust 版本固定在根目录的 `rust-toolchain.toml` 中。
## 环境要求
- Windows 10/11
- Volta
- Rustup
- Visual Studio C++ Build Tools,供 Rust MSVC 工具链使用
首次使用 Volta 时会自动切换到项目固定版本:
### 方式一:打包版(推荐)
```powershell
volta --version
node --version
pnpm --version
rustc --version
.\scripts\build-release.ps1 # 构建前端 + Release 可执行文件 + 可分发目录
.\release\TaskPilot\start.ps1 # 启动并访问 http://127.0.0.1:3000
```
## 本地开发
`start.ps1 -OpenBrowser` 会自动打开浏览器。产物目录结构:
首次拉取项目后安装前端依赖:
```text
release/TaskPilot/
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
├── web/ # 前端静态资源(字体可选)
├── uv/ # 程序下载/自备的 uv.exe 放这里(uv 配置弹窗可切换来源)
├── python/ # uv 下载的 Python 解释器(UV_PYTHON_INSTALL_DIR 默认相对值为 python)
├── uv-cache/ # uv 缓存(UV_CACHE_DIR 默认相对值为 uv-cache,运行后才会出现)
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
├── logs/{task_id}/{run_id}.log
├── start.ps1
└── README.md # 即 docs/RELEASE.md
```
### 方式二:本地开发
```powershell
# 前端依赖 @stash/ui(file:../../stash-ui),首次使用先在组件库构建 dist
npm --prefix ..\stash-ui install
npm --prefix ..\stash-ui run build
pnpm --dir frontend install
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
```
终端一,启动 Rust API:
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
`@stash/ui` 走 `file:` 依赖(pnpm 会复制一份构建产物):组件库重新 `npm run build`
之后,需要再执行一次 `pnpm --dir frontend install` 才会同步新的 `dist`。
### 方式三:用 uv 跑官方示例与自检
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
```powershell
# 1) 在示例目录创建虚拟环境(Python 3.11)
cd samples\python-task
uv venv
cd ..\..
# 2) 启动一个临时后端(任意终端)
cargo run --manifest-path backend/Cargo.toml
# 3) 运行 14 项端到端自检
uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
```
终端二,启动 React 前端:
自检会真实创建任务、立即运行、读回日志,校验:健康检查、令牌、目录列举、
虚拟环境探测、创建/调度注册、运行成功、中文日志、`VIRTUAL_ENV` 注入、
无替换字符、统计口径、删除任务。
```powershell
pnpm --dir frontend dev
```text
[OK ] venv probe links the demo environment status=linked path=.venv\Scripts\python.exe
[OK ] task is registered in the schedule next_run_at=2026-10-25T19:00:00Z
[OK ] run succeeded status=succeeded exit=0
[OK ] log contains Chinese output
14/14 checks passed
```
访问 <http://127.0.0.1:5173>。Rust 服务默认监听 `127.0.0.1:3000`,可通过 `TASK_PILOT_PORT` 修改端口:
`--keep` 保留任务,`--working-dir` 可换成任意含 `.venv` 的目录。
```powershell
$env:TASK_PILOT_PORT = "3100"
cargo run --manifest-path backend/Cargo.toml
---
## 功能总览
| 分组 | 能力 |
| --- | --- |
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选 |
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
---
## 任务模型
### 字段
| 字段 | 说明 |
| --- | --- |
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
| `name` | 展示名,1–120 字符 |
| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 |
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
| `enabled` | 是否注册调度 |
| `timeout_secs` | 1–86400;超时终止整个进程树 |
| `action` | 见下 |
### 三类 Action
```jsonc
// 脚本(language 省略时按 interpreter 推断,旧任务兼容)
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
"language": "python", "interpreter": "python.exe", "script": "main.py",
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
// Shell:cmd.exe /C run.bat;Node/Lua 同理
{ "type": "script", "working_dir": "D:\\Projects\\ops", "language": "shell",
"interpreter": "cmd.exe", "script": "run.bat", "args": [], "env": {} }
// HTTP
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
// 进程
{ "type": "process", "executable": "C:\\Tools\\agent.exe",
"working_dir": "C:\\Tools", "args": ["--serve"], "env": {} }
```
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
### 运行状态机
## Windows Release
构建前端生产资源、Rust Release 可执行文件和可分发目录:
```powershell
.\scripts\build-release.ps1
```text
queued ──► running ──► succeeded / failed / timed_out / cancelled
└────────────────► skipped (同一任务已有运行实例)
```
产物位于 `release/TaskPilot/`。启动打包版本:
终态不可再取消;进程被强杀后,下次启动会把遗留的 `queued`/`running`
统一标记为 `cancelled`,并写入「TaskPilot 进程重启,运行被中断」。
```powershell
.\release\TaskPilot\start.ps1
```
---
访问 <http://127.0.0.1:3000>。打包使用同源 API,因此 Release 模式修改 `TASK_PILOT_PORT` 后无需重新构建前端。
## 调度与执行语义
打包目录中的 [RELEASE.md](docs/RELEASE.md) 会作为使用与故障排查说明一并复制。
- **三类任务都参与调度**。启用即注册,停用即取消;后端重启后从数据库恢复注册。
- 每个任务一个独立 Tokio 循环:计算下次触发 → 等待 → 调用执行器。
- **重叠触发**:同一任务已有运行实例时,新触发记为 `skipped`,不会并发。
- **没有全局并发上限**:10 个任务同时到点会各自启动一个进程。
- **取消**:通过 `CancellationToken` + `TerminateJobObject`,5 秒内未退出则 `kill`。
- **超时**:与取消同一路径,状态记为 `timed_out`。
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
SQLite 数据库默认创建在启动目录的 `data/task_pilot.db`。通过以下环境变量可以使用其他数据库路径:
---
```powershell
$env:TASK_PILOT_DATABASE_URL = "sqlite://data/custom.db"
cargo run --manifest-path backend/Cargo.toml
```
## 日志与编码
数据库迁移会在后端启动时自动执行。
文件布局:`logs/{task_id}/{run_id}.log`(UTF-8 写入)
运行日志默认写入 `logs/{task_id}/{run_id}.log`,可通过以下环境变量配置:
- 每次运行 **5 MiB** 上限,超出后丢弃后续输出并写一条 `[system]` 说明。
- **单行 256 KiB** 上限:超出部分丢弃、继续读到行尾,避免内存无界增长。
- 保留期(默认 30 天)与总容量(默认 512 MiB)清理;**跳过正在写入的日志**。
- 读取按 64 KiB 分块且对齐 UTF-8 边界;`complete` 只在"运行结束且读到文件末尾"时为真。
- **合并推送**:子进程输出按行写入文件后,先在内存里按 **100 ms / 16 KiB** 窗口合并,
每个窗口只发一条 SSE `log` 事件(偏移仍严格连续);低输出时由后台任务按时推,
运行结束时在 `LogSink` 析构里兜底 flush,不会丢尾部。
- **虚拟滚动**:日志抽屉只渲染视口附近的行(固定行高 20 px),几万行时 DOM 节点数恒定;
增量先并进行缓冲、一帧最多重渲染一次。往上翻时停止跟随并显示「回到最新」。长行横向滚动、不折行。
- **输出编码**:默认 `auto` = 逐行先按 UTF-8 解码,失败回退到系统 ANSI 代码页(简体中文为 GBK)。
可用 `TASK_PILOT_LOG_ENCODING=gbk|utf-8|windows-1251` 强制。
- **脱敏**:环境变量/Header 名包含 `SECRET`、`TOKEN`、`PASSWORD`、`PASS`、`API_KEY`、
`PRIVATE_KEY`、`CREDENTIAL`、`AUTHORIZATION`、`AUTH` 时,其值在日志中被替换为 `[REDACTED]`。
```powershell
$env:TASK_PILOT_LOG_DIR = "logs"
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
$env:TASK_PILOT_LOG_ENCODING = "auto"
```
---
`TASK_PILOT_LOG_ENCODING` 默认为 `auto`:逐行先按 UTF-8 解码,失败时回退到系统 ANSI 代码页(简体中文为 GBK),保证中文程序输出不会变成 `����`。需要强制某一种编码时可填写 `gbk`、`utf-8`、`windows-1251` 等标签。
## Python 与虚拟环境
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
编辑器和执行器共用同一份解析逻辑:
## API
1. 手动指定的 `action.venv`
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
3. 从工作目录向上查找 `.venv`、`venv`、`env`
| 方法 | 路径 | 用途 |
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
**没有虚拟环境时**:
- 先看用户自己的环境:任务里指定的 `venv`、解释器路径、工作目录向上查找,命中就直接用,不重复创建。
- 都没有时,**新建任务时会用 uv 自动创建 `.venv`**:`pyproject.toml` 走 `uv sync`,
仅有 `requirements*.txt` 走 `uv venv .venv` + `uv pip install -r`,纯标准库脚本(没有依赖文件)建一个空环境。
保存按钮会显示「正在创建虚拟环境…」,环境问题当场暴露。手动路径是编辑器里的「用 uv 创建环境」按钮。
- 运行期只做校验、不再自动装依赖:没有 `.venv` 的 Python 任务会直接失败并提示先创建环境。
- 创建时会使用当前默认 PyPI 镜像:注入 `UV_DEFAULT_INDEX`(实际生效的是它)以及兼容用的 `UV_INDEX_URL` / `PIP_INDEX_URL`,不会绕过镜像设置。
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
没有 uv 时在 uv 配置弹窗点「下载 uv」,从「下载镜像」(默认科大
`mirrors.ustc.edu.cn/github-release`)下载当前架构的压缩包,
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去(弹窗状态条里会显示 uv 与解释器的下载地址);
本机已经装了 uv 的话,把它填进「指定路径」即可。
- **uv 的下载镜像不跟 PyPI 包索引混用**:它配在 uv 配置弹窗的「下载镜像」里(GitHub Release
镜像根),uv 本体与 Python 解释器都从它派生(uv 地址 = `镜像根/astral-sh/uv/LatestRelease`,
解释器地址 = `镜像根/astral-sh/python-build-standalone/releases/download`)。
解释器这一步也可以单独覆盖:环境变量里就有一条 `UV_PYTHON_INSTALL_MIRROR`(默认跟随镜像根,
改它的值即可换成别的发行源),它和 `UV_PYTHON_INSTALL_DIR`、`UV_CACHE_DIR` 一样是固定项,
只能改值、不能删除。
- uv 配置弹窗里还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、`UV_HTTP_TIMEOUT`),
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的
`python` / `uv-cache`(随包移动),也可以改成绝对路径。
- Python 解释器的下载与查看也在 uv 配置弹窗里(`uv python install` / `uv python list`)——
解释器属于 uv,只是建虚拟环境时要用;虚拟环境弹窗只从这份已下载列表里挑版本。
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。
- 运行任务时会把平台 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` 字段传入。
命中虚拟环境后注入:
| 变量 | 行为 |
| --- | --- |
| `PATH` | 虚拟环境 `Scripts` 目录置前,保留用户 PATH,按大小写不敏感去重 |
| `VIRTUAL_ENV` / `VIRTUAL_ENV_PROMPT` | 指向虚拟环境根目录 |
| `PYTHONUNBUFFERED` | 强制 `1`(实时日志依赖) |
| `PYTHONIOENCODING` | 默认 `utf-8`,用户显式设置优先;避免 GBK 控制台下打印 emoji/生僻字符直接崩溃 |
| `PYTHONHOME` / `PYTHONPATH` | 虚拟环境模式下清理继承的污染值 |
编辑器会实时显示 `● 已关联虚拟环境: .venv\Scripts\python.exe`,并同步更新调用预览。
---
## Web 界面
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
页面级导航已去掉,配置都收到任务调度页的卡片里。
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **uv 配置弹窗**:顶部状态条(版本 / 可执行文件 / uv 下载与解释器镜像地址 / 解释器与环境变量数量)+ 按配置顺序排的三段。
① **uv 可执行文件**:单选「程序目录 uv / 指定路径」——程序目录只展示路径、不可编辑,缺失时才出现「下载 uv」;
指定路径是可编辑输入框,右侧「浏览」(页面内文件浏览器挑 `uv.exe`,保存时校验能否运行);下面是「下载镜像」
(GitHub Release 镜像根,uv 与解释器共用);② **环境变量**:`UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` 决定目录,
`UV_PYTHON_INSTALL_MIRROR` 决定解释器下载源,这三项只能改值、不能删除(清空或重复会拦下保存),
其余 `UV_*` 变量可增删;③ **Python 解释器**:按上面配置的目录列出已下载版本,填版本号即下载。
底部关闭 / 保存设置;uv 来源不读系统 PATH。
- **镜像源弹窗**:只放 PyPI 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;
可新增、编辑、启用停用、设为默认。包索引只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
uv 自己的下载镜像不在这里,在 uv 配置弹窗里。
- **虚拟环境弹窗**:只负责**使用**已下载的解释器创建托管环境
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包);解释器下载入口在 uv 配置弹窗。
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
`tools/build-fonts.py` 生成,缺失时回退系统字体。
---
## 安全模型
| 边界 | 实现 |
| --- | --- |
| 监听地址 | 固定 `127.0.0.1`(`TASK_PILOT_PORT` 只改端口) |
| Origin | 带 `Origin` 的请求必须是 `http://127.0.0.1:*` 或 `http://localhost:*`,否则 403 |
| 管理令牌 | 非 GET/HEAD/OPTIONS 必须带 `X-TaskPilot-Token`;进程启动时随机生成,重启轮换 |
| 比较方式 | 常量时间比较,避免时序侧信道 |
| 请求体 | 上限 2 MiB |
| 命令执行 | 参数以 argv 传递,不经过 Shell 拼接 |
| 读取接口 | 只读接口(任务列表、日志、目录列举、统计)免令牌 |
| 前端令牌 | 客户端缓存令牌;写请求 403 时丢弃缓存并重试一次 |
> 这是**本地单用户工具**的威胁模型:本机其他进程仍可调用只读接口。不要把端口暴露到局域网。
---
## 配置项
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/health` | 后端健康检查 |
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
| `TASK_PILOT_PORT` | `3000` | 监听端口(地址固定回环) |
| `TASK_PILOT_DATABASE_URL` | `sqlite://data/task_pilot.db` | SQLite 路径;`sqlite::memory:` 用于测试 |
| `TASK_PILOT_LOG_DIR` | `logs` | 运行日志根目录 |
| `TASK_PILOT_STATIC_DIR` | `frontend/dist` | 生产静态资源目录(打包版由 `start.ps1` 设为 `web`) |
| `TASK_PILOT_LOG_RETENTION_DAYS` | `30` | 日志保留天数 |
| `TASK_PILOT_LOG_MAX_TOTAL_MB` | `512` | 日志总容量上限 |
| `TASK_PILOT_LOG_ENCODING` | `auto` | 子进程输出编码;`auto` 为 UTF-8→系统代码页回退 |
| `RUST_LOG` | `task_pilot_backend=debug,tower_http=debug` | 日志级别 |
数据库连接:SQLite 开启 `foreign_keys`、**WAL**、`busy_timeout=5s`、`synchronous=NORMAL`,最多 5 个连接。
---
## API 参考
### 系统
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/health` | 健康检查 |
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 |
| `GET` | `/api/v1/tasks` | 查询任务列表 |
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 |
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
### Python 镜像
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/python/mirrors` | 镜像列表(默认项优先) |
| `POST` | `/api/v1/python/mirrors` | 新增镜像 |
| `GET` | `/api/v1/python/mirrors/{id}` | 镜像详情 |
| `PUT` | `/api/v1/python/mirrors/{id}` | 更新名称 / 地址 / 信任主机 / 启用状态 |
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
### uv
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 |
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 |
| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
### 托管虚拟环境
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/python/pythons` | uv 下载到平台目录(`UV_PYTHON_INSTALL_DIR`)的解释器(版本、路径) |
| `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}` | 删除整个环境目录 |
### 任务与运行
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/tasks` | 任务列表(`q`、`enabled`、`action_type`、`page`、`page_size`) |
| `POST` | `/api/v1/tasks` | 创建任务 |
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
| `PUT` | `/api/v1/tasks/{id}` | 完整更新任务 |
| `DELETE` | `/api/v1/tasks/{id}` | 删除任务 |
| `POST` | `/api/v1/tasks/{id}/enable` | 启用任务并注册调度 |
| `POST` | `/api/v1/tasks/{id}/disable` | 停用任务并取消调度 |
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行 |
| `GET` | `/api/v1/tasks/{id}/runs` | 查询任务运行历史 |
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行状态 |
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量读取运行日志 |
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整运行日志 |
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消正在运行的任务 |
| `GET` | `/api/v1/events` | SSE 运行状态和日志事件 |
| `GET` | `/api/v1/tasks/{id}` | 任务详情 |
| `PUT` | `/api/v1/tasks/{id}` | 完整更新 |
| `DELETE` | `/api/v1/tasks/{id}` | 删除(级联删除运行记录) |
| `POST` | `/api/v1/tasks/{id}/enable` | 启用并注册调度 |
| `POST` | `/api/v1/tasks/{id}/disable` | 停用并取消调度 |
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行(`202`) |
| `GET` | `/api/v1/tasks/{id}/runs` | 运行历史(`status`、`trigger`、分页) |
| `GET` | `/api/v1/runs/{run_id}` | 单次运行详情 |
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量日志(`content`、`next_offset`、`complete`) |
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整日志 |
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消运行 |
| `GET` | `/api/v1/events` | SSE:`run` / `log` / `lagged` / `ready` |
任务列表查询参数:
### 示例
- `q`:按任务 ID 或名称搜索。
- `enabled`:按启用状态筛选。
- `action_type`:`script`、`http` 或 `process`。
- `page`:页码,默认 `1`。
- `page_size`:每页数量,默认 `20`,最大 `100`。
创建任务:
运行历史查询额外支持 `status` 和 `trigger` 过滤。
```http
POST /api/v1/tasks
X-TaskPilot-Token: <session token>
Content-Type: application/json
健康检查示例:
```json
{
"status": "ok",
"service": "task-pilot-backend",
"version": "0.1.0"
}
```
创建脚本任务示例:
```json
{
"id": "sync_data",
"name": "数据同步",
"description": "把上游数据同步到本地仓库,失败时记录日志",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
"timeout_secs": 300,
"retry_policy": {
"max_attempts": 0,
"delay_secs": 0
},
"action": {
"type": "script",
"working_dir": "D:\\Projects\\sync",
"interpreter": "python.exe",
"script": "main.py",
"args": ["--mode", "full"],
"env": {
"PYTHONUNBUFFERED": "1"
}
"env": {}
}
}
```
校验失败和资源不存在时统一返回结构化错误:
统计数据:
```json
{
"code": "VALIDATION_ERROR",
"message": "Cron 表达式无效",
"field": "cron",
"details": {}
}
{ "total": 12, "enabled": 9, "running": 1, "script": 7, "http": 3, "process": 2 }
```
## 开发命令
调度信息:
### 完整检查
```json
[ { "task_id": "sync_data", "next_run_at": "2026-10-26T18:00:00Z" } ]
```
在项目根目录执行:
统一错误结构:
```json
{ "code": "VALIDATION_ERROR", "message": "Cron 表达式无效", "field": "cron", "details": {} }
```
常见错误码:`VALIDATION_ERROR`、`INVALID_JSON`、`INVALID_QUERY`、`TASK_NOT_FOUND`、
`RUN_NOT_FOUND`、`RUN_LOG_NOT_FOUND`、`RUN_NOT_CANCELLABLE`、`TASK_ID_CONFLICT`、
`DIRECTORY_PATH_INVALID`、`DIRECTORY_NOT_FOUND`、`DIRECTORY_UNREADABLE`、`FORBIDDEN`、`INTERNAL_ERROR`。
---
## 输入校验
保存任务时逐项强制(失败返回 `400 VALIDATION_ERROR` 并带 `field`):
| 字段 | 规则 |
| --- | --- |
| `id` | 1–64,`[A-Za-z0-9_-]`;重复返回 `409 TASK_ID_CONFLICT` |
| `name` | 去空格后 1–120 字符 |
| `cron` | 可解析且存在未来触发点 |
| `timezone` | 有效 IANA 名称 |
| `timeout_secs` | 1–86400 |
| `description` | ≤500 字符,可留空 |
| `action.working_dir` / `script` | 非空 |
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
| `action.url` | 可解析、限 `http`/`https`、必须有主机名 |
| `action.headers` | 名称/值符合 HTTP 规范 |
| `action.body` | ≤ 1 MiB |
| `action.expected_status` | 每项 100–599 |
| 请求体 | 未知字段直接拒绝(`deny_unknown_fields`) |
查询与读取:`page ≥ 1`、`page_size` 夹到 1–100、日志 `offset` 不得超过文件长度。
**不在保存时校验**:工作目录是否存在、脚本文件是否存在、解释器是否存在(虚拟环境除外)。
这些在执行时才暴露,属于日志里的运行错误。
---
## 数据与文件布局
```text
data/task_pilot.db # SQLite(WAL)
data/envs/<名称>/ # 托管虚拟环境(「虚拟环境」弹窗创建)
logs/{task_id}/{run_id}.log # UTF-8 运行日志
uv/uv.exe # 平台 uv(下载或自备)
python/<版本>/ # uv 下载的 Python 解释器
uv-cache/ # uv 缓存(首次使用后出现)
.tmp/ # check.ps1 的测试临时目录(已 gitignore)
```
`tasks` 表:主键 `id`,索引 `updated_at`、`enabled`、`action_type`。
`task_runs` 表:`task_id` 外键 `ON DELETE CASCADE`,`status`/`trigger_type` 带 CHECK 约束,
索引 `(task_id, created_at DESC)` 与 `status`。
`uv_settings` 表:单行配置(`source`、`custom_path`、`extra_env` JSON)。
---
## 架构与模块
```text
React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + token)
│
├── tasks: routes → service(校验) → repository(SQL)
├── runs : routes → repository
├── Scheduler ──trigger──► TaskExecutor ──► Job Object
└── broadcast(512) ──► SSE /api/v1/events
```
| 模块 | 职责 |
| --- | --- |
| `api.rs` | 路由、OpenAPI 聚合、系统级接口(health/session/cron/stats/schedule) |
| `tasks/` | 任务模型、校验、SQL、CRUD 路由 |
| `runs/` | 运行记录、执行器、日志落盘、日志读取与 SSE |
| `scheduler.rs` | 每任务一个调度循环、下次触发时间、注册与取消 |
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入、用 uv 建环境 |
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
前端:
| 模块 | 职责 |
| --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `components/AppHeader.tsx` | 顶部品牌栏 |
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务卡片、uv/镜像/环境卡片与三个配置弹窗 |
| `components/UvSettingsModal.tsx` | uv 弹窗:状态条、来源选择、下载镜像、Python 解释器下载与列表、`UV_*` 环境变量 |
| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗:用已下载解释器创建环境、环境列表、已装包 |
| `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 |
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器(`mode="file"` 时为文件选择器,用于挑 `uv.exe`) |
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 |
| `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) |
| `lib/logWindow.ts` | 可见行窗口计算 |
| `api/client.ts` | 类型化客户端、令牌缓存、403 重试 |
| `lib/taskForm.ts` | Zod 校验与表单 ↔ API 载荷映射 |
---
## 开发与测试
### 环境
- Windows 10/11
- Rust 1.95(`rust-toolchain.toml` 固定,MSVC)
- Node.js 24 / pnpm 11(`frontend/package.json` 的 `volta` 固定)
- Visual Studio C++ Build Tools(Rust MSVC 需要)
- 可选:`uv`(跑示例任务与自检脚本)
### 完整质量门
```powershell
.\scripts\check.ps1
```
该脚本依次运行:
依次执行:前端 lint → 类型检查 → Vitest → 生产构建 → `cargo fmt --check` →
`clippy -D warnings` → `cargo test` → Playwright(真实后端 + Vite + Chrome)。
- 前端 lint
- TypeScript 类型检查
- Vitest
- 前端生产构建
- Rust 格式检查
- Rust Clippy
- Rust 单元和集成测试
- Playwright 浏览器端到端测试
脚本会把 `TMP`/`TEMP` 指向仓库内 `.tmp/`,让测试不依赖系统临时目录的权限策略,
结束时自动清理。
### 浏览器端到端测试
### 单独命令
```powershell
pnpm --dir frontend lint | typecheck | test | build
cargo fmt --manifest-path backend/Cargo.toml -- --check
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path backend/Cargo.toml
pnpm --dir frontend test:e2e
```
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
### 生成接口契约和类型
修改后端 API DTO 或路由后执行:
### 契约与类型
```powershell
.\scripts\generate-api.ps1
.\scripts\generate-api.ps1 # 更新 contracts/openapi.json 与 frontend/src/api/generated.ts
```
该命令会更新:
生成文件不要手工修改。
- `contracts/openapi.json`
- `frontend/src/api/generated.ts`
### 界面字体(可选)
生成文件不应手工修改。
### 单独检查
字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面:
```powershell
pnpm --dir frontend lint
pnpm --dir frontend typecheck
pnpm --dir frontend test
pnpm --dir frontend build
cargo fmt --manifest-path backend/Cargo.toml -- --check
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path backend/Cargo.toml
uv run --with fonttools --with brotli tools/build-fonts.py
```
## 项目结构
脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点,
输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行,
只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。
```text
task_pilot/
├── backend/
│ ├── src/
│ │ ├── api.rs
│ │ ├── cron.rs
│ │ ├── database.rs
│ │ ├── error.rs
│ │ ├── lib.rs
│ │ ├── main.rs
│ │ ├── process_job.rs
│ │ ├── scheduler.rs
│ │ ├── security.rs
│ │ ├── state.rs
│ │ ├── runs/
│ │ └── tasks/
│ ├── migrations/
│ ├── tests/
│ ├── Cargo.toml
│ └── Cargo.lock
├── contracts/
│ └── openapi.json
├── docs/
│ └── decisions/
├── frontend/
│ ├── e2e/
│ ├── src/
│ │ ├── components/
│ │ ├── lib/
│ │ └── pages/
│ ├── package.json
│ ├── playwright.config.ts
│ ├── vite.config.ts
│ └── vitest.config.ts
├── scripts/
│ ├── check.ps1
│ ├── build-release.ps1
│ ├── e2e.ps1
│ ├── generate-api.ps1
│ └── start.ps1
├── task_pilot.html
├── PLAN.md
├── README.md
└── rust-toolchain.toml
```
### 测试构成
架构决策记录:
| 层 | 覆盖 |
| --- | --- |
| Rust 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
---
## 故障排查
| 现象 | 处理 |
| --- | --- |
| `start.ps1` 无法运行 | `powershell -ExecutionPolicy Bypass -File .\release\TaskPilot\start.ps1` |
| 端口被占用 | `$env:TASK_PILOT_PORT = "3100"` 后重启 |
| 任务不执行 | 确认已启用、Cron 与下次触发时间正确;查看运行历史里的错误信息 |
| PowerShell 脚本任务报「不是数字签名」 | 执行策略拦未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录 `Unblock-File` |
| 日志出现 `����` | 程序未按 UTF-8 输出:设置 `TASK_PILOT_LOG_ENCODING=gbk`(或让程序输出 UTF-8) |
| 日志尾部缺失 | 单次运行超过 5 MiB 或单行超过 256 KiB,会截断并写 `[system]` 说明 |
| 运行一直显示"运行中" | 已由启动对账修复;若仍出现,检查进程是否被系统挂起 |
| 虚拟环境未生效 | 目录下需同时有 `pyvenv.cfg` 与 `Scripts\python.exe`;也可在表单里显式指定 |
| `cargo test` 报临时目录拒绝访问 | 用 `.\scripts\check.ps1`(会把 TEMP 指向 `.tmp/`)或手动设置 `TEMP` 到可写目录 |
---
## 已知限制
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
- Conda 环境不会自动激活,需要显式填写解释器路径。
- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。
- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。
- 默认缓存与 Python 目录在程序目录下(`uv-cache` / `python`),删除发布目录等于丢掉缓存和解释器;想复用全局目录就把 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR` 改成绝对路径。
- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
- 错过的调度不会补跑。
- 界面字体不入库(体积原因),需用 `tools/build-fonts.py` 生成;缺失时回退系统字体。
---
## 架构决策记录
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md)
## 当前限制
- 重试策略字段已持久化,但自动重试尚未实现。
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
- [ADR-0006:视觉风格对齐主题并自托管字体](docs/decisions/0006-visual-theme-and-offline-fonts.md)
- [ADR-0007:运行期可靠性优化](docs/decisions/0007-runtime-reliability.md)
- [ADR-0008:日志合并推送与虚拟滚动](docs/decisions/0008-log-streaming-and-virtualization.md)
- [ADR-0009:uv 与托管 Python 环境](docs/decisions/0009-uv-and-managed-environments.md)
## 开发计划
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。
+87
View File
@@ -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",
]
+2
View File
@@ -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"
-28
View File
@@ -1,28 +0,0 @@
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
CHECK (concurrency_policy = 'skip'),
retry_max_attempts INTEGER NOT NULL DEFAULT 0
CHECK (retry_max_attempts >= 0),
retry_delay_secs INTEGER NOT NULL DEFAULT 0
CHECK (retry_delay_secs >= 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
+127
View File
@@ -0,0 +1,127 @@
-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。
-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。
CREATE TABLE IF NOT EXISTS uv_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
custom_path TEXT,
extra_env TEXT NOT NULL DEFAULT '{}',
mirror_url TEXT,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at)
VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
+148 -9
View File
@@ -1,6 +1,6 @@
use axum::{
Json, Router,
extract::State,
extract::{DefaultBodyLimit, State},
http::{HeaderValue, Method, header},
middleware,
response::IntoResponse,
@@ -18,7 +18,21 @@ use crate::{
},
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
python_env::{
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
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::{
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
__path_list_python_mirrors, __path_set_default_python_mirror,
__path_update_python_mirror,
},
},
runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
@@ -31,14 +45,18 @@ use crate::{
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
},
routes::{
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
__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)]
@@ -109,7 +127,10 @@ pub async fn preview_cron(
chrono::Utc::now(),
request.count,
)
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
.map_err(|error| {
tracing::debug!(error = %error, "cron preview failed");
ApiError::validation("expression", error.user_message())
})?;
Ok(Json(preview))
}
@@ -124,21 +145,55 @@ pub async fn preview_cron(
)
)]
pub async fn probe_python_venv(
State(state): State<AppState>,
ApiJson(request): ApiJson<VenvProbeRequest>,
) -> ApiResult<Json<VenvProbeResponse>> {
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)
}
#[utoipa::path(
post,
path = "/api/v1/python/venv/create",
tag = "system",
request_body = CreatePythonVenvRequest,
responses(
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
)
)]
pub async fn create_python_venv(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
) -> ApiResult<Json<VenvProbeResponse>> {
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
.await
.unwrap_or_else(|error| {
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
None
});
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(
get,
path = "/api/v1/system/directories",
tag = "system",
params(ListDirectoriesQuery),
responses(
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
(status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse),
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
)
)]
@@ -162,6 +217,47 @@ pub async fn list_directories(
})
}
#[utoipa::path(
get,
path = "/api/v1/stats",
tag = "tasks",
responses((status = 200, description = "Dashboard counters", body = TaskStatsResponse))
)]
pub async fn task_stats(State(state): State<AppState>) -> ApiResult<Json<TaskStatsResponse>> {
let counts = crate::tasks::repository::counts(&state.pool).await?;
Ok(Json(TaskStatsResponse {
total: counts.total,
enabled: counts.enabled,
running: u64::try_from(state.executor.running_count()).unwrap_or_default(),
script: counts.script,
http: counts.http,
process: counts.process,
}))
}
#[utoipa::path(
get,
path = "/api/v1/schedule",
tag = "tasks",
responses(
(status = 200, description = "Next trigger time per enabled task", body = Vec<ScheduledTaskResponse>)
)
)]
pub async fn list_schedule(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<ScheduledTaskResponse>>> {
let tasks = crate::tasks::repository::list_enabled_tasks(&state.pool).await?;
Ok(Json(
tasks
.into_iter()
.map(|task| ScheduledTaskResponse {
next_run_at: state.scheduler.next_run_at(&task.id),
task_id: task.id,
})
.collect(),
))
}
#[derive(OpenApi)]
#[openapi(
info(
@@ -174,7 +270,25 @@ pub async fn list_directories(
session,
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,
list_tasks,
create_task,
get_task,
@@ -197,16 +311,30 @@ pub async fn list_directories(
CronPreviewResponse,
VenvProbeRequest,
VenvProbeResponse,
CreatePythonVenvRequest,
VenvProbeStatus,
VenvSource,
InterpreterKind,
PythonMirrorResponse,
CreatePythonMirrorRequest,
UpdatePythonMirrorRequest,
UvSettingsResponse,
UpdateUvSettingsRequest,
UvCandidate,
UvSource,
ManagedEnvSummary,
ManagedEnvPackage,
CreateManagedEnvRequest,
ManagedPython,
InstallPythonRequest,
DirectoryEntry,
DirectoryListingResponse,
TaskStatsResponse,
ScheduledTaskResponse,
ErrorResponse,
TaskActionType,
ScriptLanguage,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
@@ -251,11 +379,22 @@ pub fn router(state: AppState) -> Router {
"/api/v1/python/venv",
axum::routing::post(probe_python_venv),
)
.route(
"/api/v1/python/venv/create",
axum::routing::post(create_python_venv),
)
.route("/api/v1/system/directories", get(list_directories))
.route("/api/v1/stats", get(task_stats))
.route("/api/v1/schedule", get(list_schedule))
.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 以上。
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
.layer(cors)
.route_layer(middleware::from_fn_with_state(
state.clone(),
+21
View File
@@ -21,6 +21,27 @@ pub enum CronPreviewError {
InvalidTimezone(String),
}
impl CronPreviewError {
/// 面向界面的中文说明。解析器原始错误(英文、带组件路径)只写日志,
/// 不再直接抛给前端。
pub fn user_message(&self) -> String {
match self {
Self::InvalidExpression(detail) => {
if detail.contains("Invalid step syntax") {
"Cron 表达式步长写法不正确:不要用「0/2」这种单数字步长,通配步长写「*/2」,范围步长写「1-10/2」"
.to_owned()
} else {
"Cron 表达式无效,请检查字段取值与步长写法(通配步长用 */n,范围步长用 a-b/n)"
.to_owned()
}
}
Self::TooManyRuns => format!("一次最多预览 {MAX_PREVIEW_RUNS} 次运行"),
Self::NoFutureOccurrence => "Cron 表达式没有可计算的未来执行时间".to_owned(),
Self::InvalidTimezone(_) => "时区必须是有效的 IANA 时区名称".to_owned(),
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CronPreviewRequest {
+16 -9
View File
@@ -1,20 +1,27 @@
use std::{error::Error, str::FromStr};
use std::{error::Error, str::FromStr, time::Duration};
use sqlx::{
SqlitePool,
migrate::MigrateError,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
};
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
let options = SqliteConnectOptions::from_str(database_url)?
let in_memory = database_url.contains(":memory:");
let mut options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.foreign_keys(true);
let max_connections = if database_url == "sqlite::memory:" {
1
} else {
5
};
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
// 运行日志和状态写入与前端查询会并发访问同一个库;WAL 允许读写并行,
// busy_timeout 则避免瞬时冲突直接返回 "database is locked"。
if !in_memory {
options = options
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal);
}
let max_connections = if in_memory { 1 } else { 5 };
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.connect_with(options)
+46 -2
View File
@@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500;
pub struct ListDirectoriesQuery {
/// 绝对目录路径;留空表示列出盘符
pub path: Option<String>,
/// 是否一并返回文件(挑选可执行文件时用);默认只返回目录
#[serde(default)]
pub include_files: Option<bool>,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -22,6 +25,8 @@ pub struct DirectoryEntry {
pub name: String,
/// 绝对路径,选中后回填到表单
pub path: String,
/// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)
pub is_dir: bool,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -79,6 +84,7 @@ pub fn list_directories(
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
})?;
let include_files = query.include_files.unwrap_or(false);
let mut entries = Vec::new();
let mut truncated = false;
for entry in reader.flatten() {
@@ -87,19 +93,26 @@ pub fn list_directories(
.file_type()
.map(|kind| kind.is_dir())
.unwrap_or_else(|_| entry_path.is_dir());
if !is_directory {
if !is_directory && !include_files {
continue;
}
entries.push(DirectoryEntry {
name: entry.file_name().to_string_lossy().into_owned(),
path: entry_path.to_string_lossy().into_owned(),
is_dir: is_directory,
});
if entries.len() >= MAX_ENTRIES {
truncated = true;
break;
}
}
entries.sort_by_key(|entry| entry.name.to_lowercase());
// 目录在前、文件在后,各自按名称排序:挑选文件时不用在一堆文件里找文件夹。
entries.sort_by(|left, right| {
right
.is_dir
.cmp(&left.is_dir)
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
});
Ok(DirectoryListingResponse {
path: Some(directory.to_string_lossy().into_owned()),
@@ -126,6 +139,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
DirectoryEntry {
name: format!("{letter}:\\"),
path: format!("{letter}:\\"),
is_dir: true,
}
})
.collect()
@@ -136,6 +150,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
vec![DirectoryEntry {
name: "/".to_owned(),
path: "/".to_owned(),
is_dir: true,
}]
}
@@ -146,6 +161,14 @@ mod tests {
fn query(path: Option<&str>) -> ListDirectoriesQuery {
ListDirectoriesQuery {
path: path.map(str::to_owned),
include_files: None,
}
}
fn query_with_files(path: Option<&str>) -> ListDirectoriesQuery {
ListDirectoriesQuery {
path: path.map(str::to_owned),
include_files: Some(true),
}
}
@@ -164,6 +187,7 @@ mod tests {
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["Alpha", "zeta"]);
assert!(listing.entries.iter().all(|entry| entry.is_dir));
assert_eq!(
listing.parent.as_deref(),
Some(temp.path().parent().unwrap().to_str().unwrap())
@@ -171,6 +195,26 @@ mod tests {
assert!(!listing.truncated);
}
#[test]
fn lists_files_when_requested_after_directories() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("bin")).unwrap();
fs::write(temp.path().join("zeta.exe"), "x").unwrap();
fs::write(temp.path().join("Alpha.txt"), "x").unwrap();
let listing =
list_directories(&query_with_files(Some(temp.path().to_str().unwrap()))).unwrap();
let names = listing
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["bin", "Alpha.txt", "zeta.exe"]);
assert!(listing.entries[0].is_dir);
assert!(listing.entries[1..].iter().all(|entry| !entry.is_dir));
}
#[test]
fn returns_absolute_paths_for_entries() {
let temp = tempfile::TempDir::new().unwrap();
+30
View File
@@ -109,6 +109,36 @@ impl ApiError {
}
}
pub fn mirror_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "MIRROR_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn env_not_found(message: impl Into<String>) -> 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<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "MIRROR_CONFLICT",
message: message.into(),
field: Some("index_url".to_owned()),
details: BTreeMap::new(),
}
}
pub fn route_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
+3
View File
@@ -5,11 +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;
+412 -47
View File
@@ -2,14 +2,20 @@ use std::{
collections::BTreeMap,
env,
ffi::OsString,
io,
fs, io,
path::{Path, PathBuf},
process::Stdio,
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::{
process::Command,
time::{Duration, timeout},
};
use utoipa::ToSchema;
use crate::python_mirrors::model::DefaultPythonMirror;
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
#[derive(Debug, Clone)]
@@ -27,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(
@@ -79,7 +107,7 @@ pub fn prepare_script_environment(
.to_string_lossy()
.into_owned(),
);
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
apply_stdio_defaults(&mut environment);
return Ok(PreparedPython {
interpreter: python,
virtual_env: Some(venv.clone()),
@@ -93,7 +121,7 @@ pub fn prepare_script_environment(
});
}
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
apply_stdio_defaults(&mut environment);
Ok(PreparedPython {
interpreter: interpreter_path,
virtual_env: None,
@@ -102,6 +130,17 @@ pub fn prepare_script_environment(
})
}
/// Python 的标准流默认跟随系统代码页(简体中文为 GBK),一旦打印 emoji 或生僻字符
/// 就会 `UnicodeEncodeError` 直接失败。这里默认改用 UTF-8,日志侧本来就优先按 UTF-8 解码。
/// 用户在任务里显式设置的 `PYTHONIOENCODING` 仍然优先。
fn apply_stdio_defaults(environment: &mut BTreeMap<String, String>) {
// 实时日志依赖标准流不缓冲,这一项始终强制。
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
environment
.entry("PYTHONIOENCODING".to_owned())
.or_insert_with(|| "utf-8".to_owned());
}
/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
@@ -219,76 +258,148 @@ pub struct VenvProbeResponse {
pub display_path: Option<String>,
pub source: Option<VenvSource>,
pub message: String,
/// 本机 PATH 中是否能找到 uv
pub uv_available: bool,
/// 工作目录根部是否存在 pyproject.toml
pub has_pyproject: bool,
/// 工作目录根部是否存在 requirements*.txt
pub has_requirements: bool,
/// 探测到的 requirements 文件名(仅根目录)
pub requirements_files: Vec<String>,
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
pub can_create: bool,
}
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreatePythonVenvRequest {
pub working_dir: String,
}
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
let working_dir = request.working_dir.trim();
let interpreter = request.interpreter.trim();
let interpreter = {
let trimmed = request.interpreter.trim();
if trimmed.is_empty() {
"python.exe"
} else {
trimmed
}
};
let configured_venv = request
.venv
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let interpreter_kind = classify_interpreter(interpreter);
let uv = crate::uv::probe_version(uv).is_some();
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
if working_dir.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写工作目录");
}
if interpreter.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
}
if !matches!(interpreter_kind, InterpreterKind::Python) {
return VenvProbeResponse {
return respond(VenvProbeResponse {
status: VenvProbeStatus::NotApplicable,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
};
..base_probe()
});
}
let working_directory = match absolute_path(Path::new(working_dir)) {
Ok(path) => path,
Err(error) => {
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
return respond(incomplete_probe(
interpreter_kind,
&format!("工作目录无效: {error}"),
));
}
};
if !working_directory.is_dir() {
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
return respond(incomplete_probe(
interpreter_kind,
"工作目录不存在,暂时无法探测虚拟环境",
));
}
match resolve_venv(working_dir, interpreter, configured_venv) {
Ok(ResolvedVenv {
venv: Some(venv),
source,
}) => VenvProbeResponse {
status: VenvProbeStatus::Linked,
interpreter_kind,
display_path: Some(python_display_path(&venv, working_dir)),
venv_path: Some(venv.to_string_lossy().into_owned()),
source,
message: venv_source_message(source),
},
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
status: VenvProbeStatus::Missing,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
},
Err(error) => VenvProbeResponse {
status: VenvProbeStatus::Invalid,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: error.to_string(),
respond(
match resolve_venv(working_dir, interpreter, configured_venv) {
Ok(ResolvedVenv {
venv: Some(venv),
source,
}) => VenvProbeResponse {
status: VenvProbeStatus::Linked,
interpreter_kind,
display_path: Some(python_display_path(&venv, working_dir)),
venv_path: Some(venv.to_string_lossy().into_owned()),
source,
message: venv_source_message(source),
..base_probe()
},
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
status: VenvProbeStatus::Missing,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
..base_probe()
},
Err(error) => VenvProbeResponse {
status: VenvProbeStatus::Invalid,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: error.to_string(),
..base_probe()
},
},
)
}
fn base_probe() -> VenvProbeResponse {
VenvProbeResponse {
status: VenvProbeStatus::Incomplete,
interpreter_kind: InterpreterKind::Other,
venv_path: None,
display_path: None,
source: None,
message: String::new(),
uv_available: false,
has_pyproject: false,
has_requirements: false,
requirements_files: Vec::new(),
can_create: false,
}
}
fn finalize_probe(
mut response: VenvProbeResponse,
working_dir: &str,
uv_available: bool,
) -> VenvProbeResponse {
let files = scan_project_files(working_dir);
response.uv_available = uv_available;
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;
if response.status == VenvProbeStatus::Missing {
response.message = if response.can_create {
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
} else {
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
};
}
response
}
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
VenvProbeResponse {
status: VenvProbeStatus::Incomplete,
@@ -297,9 +408,168 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
display_path: None,
source: None,
message: message.to_owned(),
..base_probe()
}
}
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
#[derive(Debug, Clone, Default)]
pub struct ProjectFiles {
pub has_pyproject: bool,
pub requirements: Vec<String>,
}
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
let mut files = ProjectFiles::default();
let Ok(entries) = fs::read_dir(working_dir) else {
return files;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let lower = name.to_ascii_lowercase();
if lower == "pyproject.toml" {
files.has_pyproject = true;
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
files.requirements.push(name);
}
}
files.requirements.sort();
files
}
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
pub async fn create_project_venv(
working_dir: &str,
mirror: Option<&DefaultPythonMirror>,
uv: &Path,
uv_env: &BTreeMap<String, String>,
) -> Result<VenvProbeResponse, String> {
let working_directory =
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
if !working_directory.is_dir() {
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
}
let probe_request = VenvProbeRequest {
working_dir: working_dir.to_owned(),
interpreter: "python.exe".to_owned(),
venv: None,
};
let existing = probe_venv(&probe_request, uv);
if existing.status == VenvProbeStatus::Linked {
return Ok(existing);
}
let files = scan_project_files(working_dir);
if crate::uv::probe_version(uv).is_none() {
return Err(
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
);
}
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
if files.has_pyproject {
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
} else {
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
let python = working_directory
.join(".venv")
.join("Scripts")
.join("python.exe");
let python = python.to_string_lossy().into_owned();
for requirement in &files.requirements {
run_uv(
&working_directory,
&["pip", "install", "--python", &python, "-r", requirement],
mirror,
uv,
uv_env,
)
.await?;
}
}
let probe = probe_venv(&probe_request, uv);
if probe.status != VenvProbeStatus::Linked {
return Err(format!(
"uv 执行完成,但没有找到有效的 .venv:{}",
probe.message
));
}
Ok(probe)
}
pub(crate) async fn run_uv(
working_dir: &Path,
args: &[&str],
mirror: Option<&DefaultPythonMirror>,
uv: &Path,
uv_env: &BTreeMap<String, String>,
) -> Result<String, String> {
let mut command = Command::new(uv);
command
.args(args)
.current_dir(working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(mirror) = mirror {
command
.env("PIP_INDEX_URL", &mirror.index_url)
.env("UV_INDEX_URL", &mirror.index_url)
.env("UV_DEFAULT_INDEX", &mirror.index_url);
if let Some(host) = mirror
.trusted_host
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
command.env("PIP_TRUSTED_HOST", host);
}
}
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
command.envs(uv_env);
#[cfg(windows)]
command.creation_flags(0x0800_0000);
let output = timeout(Duration::from_secs(600), command.output())
.await
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
.map_err(|error| format!("无法启动 uv: {error}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
return Err(format!(
"uv {} 失败: {}",
args.join(" "),
truncate_output(detail)
));
}
Ok(stdout.into_owned())
}
fn truncate_output(value: &str) -> String {
const MAX_CHARS: usize = 1200;
if value.chars().count() <= MAX_CHARS {
return value.to_owned();
}
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
truncated.push('…');
truncated
}
fn venv_source_message(source: Option<VenvSource>) -> String {
match source {
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
@@ -461,7 +731,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
std::path::absolute(path)
}
fn is_python_interpreter(interpreter: &str) -> bool {
pub fn is_python_interpreter(interpreter: &str) -> bool {
let name = Path::new(interpreter)
.file_name()
.and_then(|name| name.to_str())
@@ -551,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();
@@ -630,12 +924,57 @@ mod tests {
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
}
#[test]
fn injects_utf8_stdio_for_python_tasks() {
let temp = TempDir::new().unwrap();
let prepared = prepare_script_environment(
temp.path().to_str().unwrap(),
"python.exe",
None,
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
prepared.environment.get("PYTHONIOENCODING"),
Some(&"utf-8".to_owned())
);
assert_eq!(
prepared.environment.get("PYTHONUNBUFFERED"),
Some(&"1".to_owned())
);
}
#[test]
fn keeps_user_pythonioencoding_override() {
let temp = TempDir::new().unwrap();
let mut user_env = BTreeMap::new();
user_env.insert("PYTHONIOENCODING".to_owned(), "gbk".to_owned());
let prepared = prepare_script_environment(
temp.path().to_str().unwrap(),
"python.exe",
None,
&user_env,
)
.unwrap();
assert_eq!(
prepared.environment.get("PYTHONIOENCODING"),
Some(&"gbk".to_owned())
);
}
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]
@@ -682,6 +1021,32 @@ 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_eq!(response.can_create, response.uv_available);
}
#[test]
fn probe_reports_project_dependency_files_for_uv_creation() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
assert_eq!(response.status, VenvProbeStatus::Missing);
assert!(response.has_pyproject);
assert!(response.has_requirements);
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
assert_eq!(response.can_create, response.uv_available);
}
#[test]
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);
// 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。
assert_eq!(response.can_create, response.uv_available);
}
#[test]
+515
View File
@@ -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<String>,
}
#[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<String>,
/// 预装包,每行一个,例如 `numpy==2.1.0`
#[serde(default)]
pub packages: Vec<String>,
}
fn root() -> PathBuf {
std::path::absolute(Path::new(ENV_ROOT)).unwrap_or_else(|_| PathBuf::from(ENV_ROOT))
}
fn validate_name(raw: &str) -> ApiResult<String> {
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<Option<String>> {
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<Vec<String>> {
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<ManagedEnvSummary> {
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<PathBuf> {
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<ManagedEnvSummary>)
)
)]
pub async fn list_envs() -> ApiResult<Json<Vec<ManagedEnvSummary>>> {
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<AppState>,
ApiJson(request): ApiJson<CreateManagedEnvRequest>,
) -> ApiResult<(StatusCode, Json<ManagedEnvSummary>)> {
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<ManagedEnvPackage>),
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
)
)]
pub async fn list_env_packages(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> ApiResult<Json<Vec<ManagedEnvPackage>>> {
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::<Vec<serde_json::Value>>(&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<String>) -> ApiResult<StatusCode> {
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<u32> {
version
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect()
}
/// 只返回 uv 下载到平台目录里的解释器(忽略系统里已有的 Python)。
async fn installed_pythons(pool: &sqlx::SqlitePool) -> Result<Vec<ManagedPython>, 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<ManagedPython> = serde_json::from_str::<Vec<serde_json::Value>>(&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<ManagedPython>),
(status = 400, description = "uv unavailable", body = crate::error::ErrorResponse)
)
)]
pub async fn list_pythons(State(state): State<AppState>) -> ApiResult<Json<Vec<ManagedPython>>> {
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<ManagedPython>),
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
)
)]
pub async fn install_python(
State(state): State<AppState>,
ApiJson(request): ApiJson<InstallPythonRequest>,
) -> ApiResult<Json<Vec<ManagedPython>>> {
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<AppState> {
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());
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod repository;
pub mod routes;
pub mod service;
+52
View File
@@ -0,0 +1,52 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// 一个 Python 包索引镜像(pip / uv)。
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PythonMirrorResponse {
pub id: String,
pub name: String,
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
pub index_url: String,
/// pip 的 `--trusted-host`;https 源通常为空
pub trusted_host: Option<String>,
pub enabled: bool,
/// 当前默认镜像;同一时间最多一个
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
pub enabled: bool,
}
const fn default_enabled() -> bool {
true
}
/// 执行器每次运行 Python 任务时读取的默认镜像。
#[derive(Debug, Clone)]
pub struct DefaultPythonMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
}
+215
View File
@@ -0,0 +1,215 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
python_mirrors::{
model::{DefaultPythonMirror, PythonMirrorResponse},
service::ValidatedMirror,
},
};
#[derive(Debug, FromRow)]
struct MirrorRow {
id: String,
name: String,
index_url: String,
trusted_host: Option<String>,
enabled: bool,
is_default: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<MirrorRow> for PythonMirrorResponse {
fn from(row: MirrorRow) -> Self {
Self {
id: row.id,
name: row.name,
index_url: row.index_url,
trusted_host: row.trusted_host,
enabled: row.enabled,
is_default: row.is_default,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
let rows = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let row = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
Ok(row.into())
}
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, None).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let id = Uuid::new_v4().simple().to_string();
let now = Utc::now();
sqlx::query(
"INSERT INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(map_mirror_error)?;
get(pool, &id).await
}
pub async fn update(
pool: &SqlitePool,
id: &str,
input: ValidatedMirror,
) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, Some(id)).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let now = Utc::now();
let result = sqlx::query(
"UPDATE python_mirrors
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
updated_at = ?
WHERE id = ?",
)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(input.enabled)
.bind(now)
.bind(id)
.execute(pool)
.await
.map_err(map_mirror_error)?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
Ok(())
}
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let now = Utc::now();
let mut transaction = pool.begin().await?;
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if exists.is_none() {
transaction.rollback().await?;
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
.bind(now)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
get(pool, id).await
}
/// 执行器读取:只返回「启用且是默认」的镜像。
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT name, index_url, trusted_host
FROM python_mirrors
WHERE enabled = 1 AND is_default = 1
LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
name,
index_url,
trusted_host,
}),
)
}
async fn index_url_exists(
pool: &SqlitePool,
index_url: &str,
excluding_id: Option<&str>,
) -> ApiResult<bool> {
let existing: Option<(String,)> = match excluding_id {
Some(id) => {
sqlx::query_as(
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
)
.bind(index_url)
.bind(id)
.fetch_optional(pool)
.await?
}
None => {
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
.bind(index_url)
.fetch_optional(pool)
.await?
}
};
Ok(existing.is_some())
}
fn map_mirror_error(error: sqlx::Error) -> ApiError {
if let sqlx::Error::Database(database_error) = &error
&& database_error.is_unique_violation()
{
return ApiError::mirror_conflict("该索引地址已经存在");
}
ApiError::from(error)
}
+151
View File
@@ -0,0 +1,151 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use crate::{
error::{ApiJson, ApiResult},
python_mirrors::{
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
repository, service,
},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/v1/python/mirrors",
get(list_python_mirrors).post(create_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}",
get(get_python_mirror)
.put(update_python_mirror)
.delete(delete_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}/default",
post(set_default_python_mirror),
)
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
responses(
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
)
)]
pub async fn list_python_mirrors(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
Ok(Json(repository::list(&state.pool).await?))
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
request_body = CreatePythonMirrorRequest,
responses(
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn create_python_mirror(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
let mirror = repository::create(&state.pool, input).await?;
Ok((StatusCode::CREATED, Json(mirror)))
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::get(&state.pool, &id).await?))
}
#[utoipa::path(
put,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
request_body = UpdatePythonMirrorRequest,
responses(
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn update_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
) -> ApiResult<Json<PythonMirrorResponse>> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
Ok(Json(repository::update(&state.pool, &id, input).await?))
}
#[utoipa::path(
delete,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 204, description = "Mirror deleted"),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
repository::delete(&state.pool, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors/{id}/default",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn set_default_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::set_default(&state.pool, &id).await?))
}
+79
View File
@@ -0,0 +1,79 @@
use url::Url;
use crate::error::{ApiError, ApiResult};
const MAX_NAME_CHARS: usize = 64;
const MAX_URL_CHARS: usize = 2048;
/// 校验并规范化后的镜像输入。
#[derive(Debug)]
pub struct ValidatedMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
pub enabled: bool,
}
pub fn validate(
name: &str,
index_url: &str,
trusted_host: Option<&str>,
enabled: bool,
) -> ApiResult<ValidatedMirror> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::validation("name", "请输入镜像名称"));
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(ApiError::validation(
"name",
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
));
}
let index_url = index_url.trim();
if index_url.is_empty() {
return Err(ApiError::validation("index_url", "请输入索引地址"));
}
if index_url.chars().count() > MAX_URL_CHARS {
return Err(ApiError::validation("index_url", "索引地址过长"));
}
let parsed = Url::parse(index_url)
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(ApiError::validation(
"index_url",
"索引地址只支持 http 或 https",
));
}
if parsed.host_str().is_none() {
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ApiError::validation(
"index_url",
"索引地址不能包含用户名或密码",
));
}
let trusted_host = trusted_host
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
return Err(ApiError::validation(
"trusted_host",
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
));
}
Ok(value.to_owned())
})
.transpose()?;
Ok(ValidatedMirror {
name: name.to_owned(),
index_url: index_url.to_owned(),
trusted_host,
enabled,
})
}
+459 -52
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
process::Stdio,
sync::{
@@ -16,7 +16,7 @@ use reqwest::Method;
use serde::Serialize;
use sqlx::SqlitePool;
use tokio::{
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex as AsyncMutex, broadcast},
time::{Duration, sleep, timeout},
@@ -31,11 +31,16 @@ use crate::{
model::{RunStatus, RunTrigger, TaskRunResponse},
repository,
},
tasks::model::{HttpMethod, TaskAction, TaskResponse},
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
};
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
const MAX_LOG_LINE_BYTES: usize = 256 * 1024;
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
/// 合并推送的日志事件上限:攒够这么多字节就立即发一个事件。
const LOG_EVENT_BATCH_BYTES: usize = 16 * 1024;
/// 合并推送的时间窗口:超过这个时间还没攒满也发出去,保证低输出时也有实时感。
const LOG_EVENT_BATCH_INTERVAL: Duration = Duration::from_millis(100);
const TERMINATION_WAIT_SECS: u64 = 5;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DEFAULT_RETENTION_DAYS: u64 = 30;
@@ -174,13 +179,28 @@ struct RunningHandle {
#[derive(Clone)]
struct LogSink {
file: Arc<AsyncMutex<tokio::fs::File>>,
size: Arc<AtomicU64>,
truncated: Arc<AtomicBool>,
inner: Arc<LogSinkInner>,
}
struct LogSinkInner {
file: AsyncMutex<tokio::fs::File>,
size: AtomicU64,
truncated: AtomicBool,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Arc<Vec<String>>,
secrets: Vec<String>,
encoding: OutputEncoding,
/// 已经写进文件、但还没通过 SSE 推送出去的字节。
pending: Mutex<PendingBatch>,
}
/// 等待合并推送的一段日志。`offset` 是这段内容在日志文件中的起始偏移。
#[derive(Default)]
struct PendingBatch {
offset: u64,
bytes: usize,
opened_at: Option<Instant>,
text: String,
}
struct ScriptOutcome {
@@ -255,7 +275,7 @@ impl TaskExecutor {
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
Ok(run) => run,
Err(error) => {
lock_running(&self.inner.running).remove(&task.id);
remove_running_if(&self.inner.running, &task.id, None);
return Err(error);
}
};
@@ -301,6 +321,11 @@ impl TaskExecutor {
self.inner.events.subscribe()
}
/// 当前正在执行的任务数量(用于统计接口)。
pub fn running_count(&self) -> usize {
lock_running(&self.inner.running).len()
}
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
self.inner
.logs_dir
@@ -320,18 +345,44 @@ impl TaskExecutor {
let _guard = self.inner.cleanup_lock.lock().await;
let logs_dir = self.inner.logs_dir.clone();
let config = self.inner.log_config.clone();
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
let active = self.active_log_paths();
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config, &active))
.await
.map_err(std::io::Error::other)?
}
fn active_log_paths(&self) -> std::collections::HashSet<PathBuf> {
lock_running(&self.inner.running)
.iter()
.filter_map(|(task_id, handle)| {
handle.run_id.map(|run_id| {
self.inner
.logs_dir
.join(task_id)
.join(format!("{run_id}.log"))
})
})
.collect()
}
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
let started_at = Utc::now();
let started = Instant::now();
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
tracing::error!(run_id, error = %error, "failed to mark task run as running");
lock_running(&self.inner.running).remove(&task.id);
return;
match repository::mark_running(&self.inner.pool, run_id, started_at).await {
Ok(true) => {}
Ok(false) => {
tracing::warn!(
run_id,
"run row disappeared before it could be marked running"
);
remove_running_if(&self.inner.running, &task.id, Some(run_id));
return;
}
Err(error) => {
tracing::error!(run_id, error = %error, "failed to mark task run as running");
remove_running_if(&self.inner.running, &task.id, Some(run_id));
return;
}
}
self.emit_run_by_id(run_id).await;
@@ -339,7 +390,7 @@ impl TaskExecutor {
let finished_at = Utc::now();
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(error) = repository::complete(
match repository::complete(
&self.inner.pool,
run_id,
outcome.status,
@@ -350,11 +401,19 @@ impl TaskExecutor {
)
.await
{
tracing::error!(run_id, error = %error, "failed to complete task run");
Ok(true) => {}
Ok(false) => {
tracing::warn!(
run_id,
"run row disappeared before completion could be recorded"
);
}
Err(error) => tracing::error!(run_id, error = %error, "failed to complete task run"),
}
self.emit_run_by_id(run_id).await;
lock_running(&self.inner.running).remove(&task.id);
// 只清理属于自己的句柄:取消后立刻重跑时,旧 run 收尾不能删掉新 run 的闸门。
remove_running_if(&self.inner.running, &task.id, Some(run_id));
if let Err(error) = self.cleanup_logs().await {
tracing::warn!(%error, "failed to clean task logs");
}
@@ -370,12 +429,15 @@ impl TaskExecutor {
TaskAction::Script {
working_dir,
venv,
language,
interpreter,
script,
args,
env,
} => {
let prepared = match prepare_script_environment(
let script_language =
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
let mut prepared = match prepare_script_environment(
working_dir,
interpreter,
venv.as_deref(),
@@ -386,13 +448,58 @@ 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);
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
// 任务自己配的环境变量优先。
if script_language == ScriptLanguage::Python {
for (key, value) in uv.env() {
if !env_has(env, &key) {
command.env(key, value);
}
}
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);
}
}
}
}
if script_language == ScriptLanguage::Shell
&& is_cmd_interpreter(interpreter)
&& !script.trim_start().starts_with('/')
{
command.arg("/C");
}
command.arg(script).args(args).current_dir(working_dir);
let startup_note = prepared
.virtual_env
.as_ref()
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
self.run_command(task, run_id, cancellation, command, startup_note)
.await
}
@@ -426,7 +533,7 @@ impl TaskExecutor {
let secrets = sensitive_values(&task.action);
let sink = match LogSink::create(
&log_path,
task,
&task.id,
run_id,
self.inner.events.clone(),
secrets,
@@ -591,7 +698,7 @@ impl TaskExecutor {
let log_path = self.log_path(&task.id, run_id);
let sink = match LogSink::create(
&log_path,
task,
&task.id,
run_id,
self.inner.events.clone(),
sensitive_values(&task.action),
@@ -772,7 +879,7 @@ impl TaskExecutor {
impl LogSink {
async fn create(
path: &Path,
task: &TaskResponse,
task_id: &str,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Vec<String>,
@@ -782,8 +889,7 @@ impl LogSink {
tokio::fs::create_dir_all(parent).await?;
}
let header = format!(
"[system] TaskPilot run {run_id} for task {} started at {}\n",
task.id,
"[system] TaskPilot run {run_id} for task {task_id} started at {}\n",
Utc::now().to_rfc3339()
);
tokio::fs::write(path, header.as_bytes()).await?;
@@ -794,45 +900,59 @@ impl LogSink {
let mut secrets = secrets;
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
Ok(Self {
file: Arc::new(AsyncMutex::new(file)),
size: Arc::new(AtomicU64::new(header.len() as u64)),
truncated: Arc::new(AtomicBool::new(false)),
let inner = Arc::new(LogSinkInner {
file: AsyncMutex::new(file),
size: AtomicU64::new(header.len() as u64),
truncated: AtomicBool::new(false),
run_id,
events,
secrets: Arc::new(secrets),
secrets,
encoding,
})
pending: Mutex::new(PendingBatch::default()),
});
spawn_pending_flusher(&inner);
Ok(Self { inner })
}
fn decode_output(&self, bytes: &[u8]) -> String {
self.encoding.decode(bytes)
self.inner.encoding.decode(bytes)
}
async fn append(&self, text: &str) {
let redacted = redact_text(text, &self.secrets);
let redacted = redact_text(text, &self.inner.secrets);
let bytes = redacted.as_bytes();
let current = self.size.load(Ordering::Relaxed);
let mut file = self.inner.file.lock().await;
let current = self.inner.size.load(Ordering::Relaxed);
if current + bytes.len() as u64 > MAX_LOG_BYTES {
if !self.truncated.swap(true, Ordering::Relaxed) {
if !self.inner.truncated.swap(true, Ordering::Relaxed) {
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
let mut file = self.file.lock().await;
let _ = file.write_all(notice.as_bytes()).await;
}
return;
}
let mut file = self.file.lock().await;
if file.write_all(bytes).await.is_err() {
return;
}
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
let _ = self.events.send(ExecutionEvent::Log {
run_id: self.run_id,
offset: current,
next_offset: next,
content: redacted,
});
let next = current + bytes.len() as u64;
self.inner.size.store(next, Ordering::Relaxed);
// 文件写入是串行的,所以 pending 也在文件锁内更新,保证推送的偏移连续。
let mut pending = lock_pending(&self.inner.pending);
if pending.bytes == 0 {
pending.offset = current;
pending.opened_at = Some(Instant::now());
}
pending.bytes += bytes.len();
pending.text.push_str(&redacted);
let batch_full = pending.bytes >= LOG_EVENT_BATCH_BYTES;
let window_elapsed = pending
.opened_at
.is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL);
if batch_full || window_elapsed {
emit_pending(&self.inner, &mut pending);
}
}
async fn system(&self, message: &str) {
@@ -844,6 +964,57 @@ impl LogSink {
}
}
impl Drop for LogSinkInner {
fn drop(&mut self) {
let mut pending = lock_pending(&self.pending);
emit_pending(self, &mut pending);
}
}
fn lock_pending(pending: &Mutex<PendingBatch>) -> std::sync::MutexGuard<'_, PendingBatch> {
pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// 把待推送的日志合并成一个事件发出去,并清空缓冲区。
fn emit_pending(inner: &LogSinkInner, pending: &mut PendingBatch) {
if pending.bytes == 0 {
return;
}
let batch = std::mem::take(pending);
let _ = inner.events.send(ExecutionEvent::Log {
run_id: inner.run_id,
offset: batch.offset,
next_offset: batch.offset + batch.bytes as u64,
content: batch.text,
});
}
fn flush_pending_if_due(inner: &LogSinkInner) {
let mut pending = lock_pending(&inner.pending);
let due = pending
.opened_at
.is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL);
if due {
emit_pending(inner, &mut pending);
}
}
/// 低输出量时不会触发"攒满即发",靠这个后台任务按时间窗口把日志推出去。
fn spawn_pending_flusher(inner: &Arc<LogSinkInner>) {
let weak = Arc::downgrade(inner);
tokio::spawn(async move {
loop {
sleep(LOG_EVENT_BATCH_INTERVAL / 2).await;
let Some(inner) = weak.upgrade() else {
break;
};
flush_pending_if_due(&inner);
}
});
}
fn lock_running(
running: &Mutex<HashMap<String, RunningHandle>>,
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
@@ -852,6 +1023,21 @@ fn lock_running(
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// 只在句柄仍属于指定 run 时移除;`None` 表示"刚插入、还没拿到 run id"的占位句柄。
fn remove_running_if(
running: &Mutex<HashMap<String, RunningHandle>>,
task_id: &str,
run_id: Option<i64>,
) {
let mut running = lock_running(running);
if running
.get(task_id)
.is_some_and(|handle| handle.run_id == run_id)
{
running.remove(task_id);
}
}
fn spawn_log_reader<R>(
reader: R,
stream: &'static str,
@@ -864,12 +1050,17 @@ where
let mut reader = BufReader::new(reader);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) => break,
Ok(_) => {
match read_line_capped(&mut reader, &mut line, MAX_LOG_LINE_BYTES).await {
Ok((0, _)) => break,
Ok((_, truncated)) => {
let text = sink.decode_output(&line);
sink.append(&format!("[{stream}] {text}")).await;
let mut payload = format!("[{stream}] {text}");
if truncated {
payload.push_str(&format!(
"\n[system] 单行输出超过 {MAX_LOG_LINE_BYTES} 字节,已截断\n"
));
}
sink.append(&payload).await;
}
Err(error) => {
tracing::warn!(stream, %error, "failed to read task output");
@@ -880,6 +1071,38 @@ where
})
}
/// 读取一行,最多保留 `max` 字节。超出上限时丢弃剩余部分但继续读到换行,
/// 既保住内存上限,也不会让后续输出错位。
async fn read_line_capped<R>(
reader: &mut R,
buffer: &mut Vec<u8>,
max: usize,
) -> std::io::Result<(usize, bool)>
where
R: AsyncBufRead + Unpin,
{
buffer.clear();
let mut truncated = false;
loop {
let chunk = reader.fill_buf().await?;
if chunk.is_empty() {
return Ok((buffer.len(), truncated));
}
let newline = chunk.iter().position(|byte| *byte == b'\n');
let take = newline.map_or(chunk.len(), |index| index + 1);
let remaining = max.saturating_sub(buffer.len());
let copied = take.min(remaining);
buffer.extend_from_slice(&chunk[..copied]);
if copied < take {
truncated = true;
}
reader.consume(take);
if newline.is_some() {
return Ok((buffer.len(), truncated));
}
}
}
async fn wait_after_termination(child: &mut Child) {
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
.await
@@ -889,6 +1112,18 @@ async fn wait_after_termination(child: &mut Child) {
}
}
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
env.keys()
.any(|candidate| candidate.eq_ignore_ascii_case(key))
}
fn is_cmd_interpreter(interpreter: &str) -> bool {
Path::new(interpreter)
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
}
fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
@@ -935,13 +1170,19 @@ fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
}
}
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
fn cleanup_logs_sync(
root: &Path,
config: &LogRetentionConfig,
active: &std::collections::HashSet<PathBuf>,
) -> std::io::Result<()> {
if !root.exists() {
return Ok(());
}
let mut files = Vec::new();
collect_log_files(root, &mut files)?;
// 正在写入的日志不参与保留期/容量清理。
files.retain(|(path, _)| !active.contains(path));
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
let cutoff = SystemTime::now().checked_sub(retention);
@@ -1104,6 +1345,7 @@ mod tests {
retention_days: 30,
max_total_bytes: 10,
},
&std::collections::HashSet::new(),
)
.unwrap();
let total = std::fs::read_dir(temp.path())
@@ -1112,4 +1354,169 @@ mod tests {
.sum::<u64>();
assert!(total <= 10);
}
#[test]
fn cleanup_skips_logs_of_running_tasks() {
let temp = tempfile::TempDir::new().unwrap();
let active = temp.path().join("active.log");
std::fs::write(&active, vec![b'x'; 32]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
let stale = temp.path().join("stale.log");
std::fs::write(&stale, vec![b'x'; 32]).unwrap();
let mut protected = std::collections::HashSet::new();
protected.insert(active.clone());
cleanup_logs_sync(
temp.path(),
&LogRetentionConfig {
retention_days: 0,
max_total_bytes: 1,
},
&protected,
)
.unwrap();
assert!(active.exists(), "active log must survive cleanup");
assert!(!stale.exists(), "stale log should be removed");
}
#[tokio::test]
async fn capped_line_reader_truncates_but_keeps_reading() {
let payload = [b'a'; 64];
let mut reader = BufReader::new(&payload[..]);
let mut buffer = Vec::new();
let (bytes, truncated) = read_line_capped(&mut reader, &mut buffer, 16)
.await
.unwrap();
assert_eq!(bytes, 16);
assert!(truncated);
let (next, next_truncated) = read_line_capped(&mut reader, &mut buffer, 16)
.await
.unwrap();
assert_eq!(next, 0);
assert!(!next_truncated);
}
#[test]
fn removes_only_the_matching_run_handle() {
let running = Mutex::new(HashMap::from([(
"task".to_owned(),
RunningHandle {
run_id: Some(2),
cancellation: CancellationToken::new(),
},
)]));
remove_running_if(&running, "task", Some(1));
assert!(lock_running(&running).contains_key("task"));
remove_running_if(&running, "task", Some(2));
assert!(!lock_running(&running).contains_key("task"));
}
async fn test_sink() -> (
LogSink,
broadcast::Receiver<ExecutionEvent>,
tempfile::TempDir,
PathBuf,
) {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("run.log");
let (events, receiver) = broadcast::channel(32);
let sink = LogSink::create(
&path,
"task",
7,
events,
Vec::new(),
OutputEncoding::from_env(),
)
.await
.unwrap();
(sink, receiver, temp, path)
}
async fn recv_log(receiver: &mut broadcast::Receiver<ExecutionEvent>) -> (u64, u64, String) {
let event = timeout(Duration::from_secs(2), receiver.recv())
.await
.expect("log event should arrive")
.expect("log channel should stay open");
match event {
ExecutionEvent::Log {
run_id,
offset,
next_offset,
content,
} => {
assert_eq!(run_id, 7);
(offset, next_offset, content)
}
other => panic!("expected log event, got {other:?}"),
}
}
#[tokio::test]
async fn coalesces_rapid_output_into_a_single_log_event() {
let (sink, mut receiver, _temp, path) = test_sink().await;
sink.append("first line\n").await;
sink.append("second line\n").await;
drop(sink);
let (offset, next_offset, content) = recv_log(&mut receiver).await;
assert_eq!(content, "first line\nsecond line\n");
assert_eq!(next_offset - offset, content.len() as u64);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(on_disk.ends_with(&content));
}
#[tokio::test]
async fn flushes_the_pending_batch_when_the_sink_is_dropped() {
let (sink, mut receiver, _temp, _path) = test_sink().await;
sink.append("only line\n").await;
drop(sink);
let (offset, next_offset, content) = recv_log(&mut receiver).await;
assert_eq!(content, "only line\n");
assert_eq!(next_offset - offset, content.len() as u64);
}
#[tokio::test]
async fn flushes_a_full_batch_without_waiting_for_the_window() {
let (sink, mut receiver, _temp, _path) = test_sink().await;
sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await;
let (_offset, _next_offset, content) = recv_log(&mut receiver).await;
assert_eq!(content.len(), LOG_EVENT_BATCH_BYTES);
}
#[tokio::test]
async fn flushes_low_volume_output_within_the_time_window() {
let (sink, mut receiver, _temp, _path) = test_sink().await;
sink.append("quiet output\n").await;
let (_offset, _next_offset, content) = recv_log(&mut receiver).await;
assert_eq!(content, "quiet output\n");
}
#[tokio::test]
async fn batches_keep_contiguous_offsets_across_flushes() {
let (sink, mut receiver, _temp, _path) = test_sink().await;
sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await;
let (first_offset, first_next, first_content) = recv_log(&mut receiver).await;
sink.append("tail\n").await;
let (second_offset, second_next, second_content) = recv_log(&mut receiver).await;
assert_eq!(first_next - first_offset, first_content.len() as u64);
assert_eq!(second_offset, first_next);
assert_eq!(second_next - second_offset, second_content.len() as u64);
assert_eq!(second_content, "tail\n");
}
}
-2
View File
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
pub enum RunTrigger {
Schedule,
Manual,
Retry,
}
impl RunTrigger {
@@ -15,7 +14,6 @@ impl RunTrigger {
match self {
Self::Schedule => "schedule",
Self::Manual => "manual",
Self::Retry => "retry",
}
}
}
+23 -7
View File
@@ -119,20 +119,22 @@ pub async fn create_skipped(
})
}
/// 返回是否真的更新到了运行记录;记录被删除(例如任务被删)时返回 false。
pub async fn mark_running(
pool: &SqlitePool,
run_id: i64,
started_at: DateTime<Utc>,
) -> ApiResult<()> {
sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
) -> ApiResult<bool> {
let result = sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
.bind(RunStatus::Running.as_str())
.bind(started_at)
.bind(run_id)
.execute(pool)
.await?;
Ok(())
Ok(result.rows_affected() > 0)
}
/// 返回是否真的更新到了运行记录。
pub async fn complete(
pool: &SqlitePool,
run_id: i64,
@@ -141,8 +143,8 @@ pub async fn complete(
duration_ms: u64,
exit_code: Option<i32>,
error_message: Option<&str>,
) -> ApiResult<()> {
sqlx::query(
) -> ApiResult<bool> {
let result = sqlx::query(
"UPDATE task_runs SET
status = ?, finished_at = ?, duration_ms = ?,
exit_code = ?, error_message = ?
@@ -156,7 +158,22 @@ pub async fn complete(
.bind(run_id)
.execute(pool)
.await?;
Ok(())
Ok(result.rows_affected() > 0)
}
/// 启动时收敛上一次进程遗留下来的非终态运行,避免界面永远显示"运行中"。
pub async fn fail_interrupted_runs(pool: &SqlitePool) -> ApiResult<u64> {
let result = sqlx::query(
"UPDATE task_runs SET status = ?, finished_at = ?, error_message = ?
WHERE status IN ('queued', 'running')",
)
.bind(RunStatus::Cancelled.as_str())
.bind(Utc::now())
.bind("TaskPilot 进程重启,运行被中断")
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
@@ -238,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
match value {
"schedule" => Ok(RunTrigger::Schedule),
"manual" => Ok(RunTrigger::Manual),
"retry" => Ok(RunTrigger::Retry),
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
}
}
+52 -14
View File
@@ -14,18 +14,23 @@ use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
runs::{executor::TaskExecutor, model::RunTrigger},
tasks::{model::TaskAction, model::TaskResponse, repository},
tasks::{model::TaskResponse, repository},
};
struct ScheduledTask {
cancellation: CancellationToken,
handle: JoinHandle<()>,
/// 只用来持有任务;取消时通过令牌让循环自行退出,不直接 abort。
/// 字段以下划线开头是因为它只承担"保持任务存活"的作用。
_handle: JoinHandle<()>,
}
type NextRuns = Arc<Mutex<HashMap<String, DateTime<Utc>>>>;
struct SchedulerInner {
executor: TaskExecutor,
shutdown: CancellationToken,
jobs: Mutex<HashMap<String, ScheduledTask>>,
next_runs: NextRuns,
}
#[derive(Clone)]
@@ -40,19 +45,22 @@ impl Scheduler {
executor,
shutdown: CancellationToken::new(),
jobs: Mutex::new(HashMap::new()),
next_runs: Arc::new(Mutex::new(HashMap::new())),
}),
};
for task in repository::list_enabled_script_tasks(&pool).await? {
for task in repository::list_enabled_tasks(&pool).await? {
scheduler.refresh(task);
}
Ok(scheduler)
}
/// 重新注册任务:停用会取消调度,启用则按 cron 新建循环。
/// 三类 Action 都参与调度,执行器本身负责区分脚本、HTTP 与进程。
pub fn refresh(&self, task: TaskResponse) {
self.cancel(&task.id);
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
if !task.enabled {
return;
}
@@ -61,38 +69,52 @@ impl Scheduler {
let executor = self.inner.executor.clone();
let shutdown = self.inner.shutdown.clone();
let cancellation_for_loop = cancellation.clone();
let next_runs = Arc::clone(&self.inner.next_runs);
let handle = tokio::spawn(async move {
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
schedule_loop(
task_for_loop,
executor,
cancellation_for_loop,
shutdown,
next_runs,
)
.await;
});
lock_jobs(&self.inner.jobs).insert(
task.id,
ScheduledTask {
cancellation,
handle,
_handle: handle,
},
);
}
/// 取消注册。循环会在下一个 select 点退出,不会留下半途的 running 记录。
pub fn cancel(&self, task_id: &str) {
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
job.cancellation.cancel();
job.handle.abort();
}
lock_next_runs(&self.inner.next_runs).remove(task_id);
}
pub fn is_scheduled(&self, task_id: &str) -> bool {
lock_jobs(&self.inner.jobs).contains_key(task_id)
}
/// 下次触发时间;未注册或刚取消时返回 None。
pub fn next_run_at(&self, task_id: &str) -> Option<DateTime<Utc>> {
lock_next_runs(&self.inner.next_runs).get(task_id).copied()
}
pub fn shutdown(&self) {
self.inner.shutdown.cancel();
let mut jobs = lock_jobs(&self.inner.jobs);
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
jobs.clear();
lock_next_runs(&self.inner.next_runs).clear();
}
}
@@ -105,7 +127,6 @@ impl Drop for SchedulerInner {
.unwrap_or_else(|poisoned| poisoned.into_inner());
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
}
}
@@ -115,6 +136,7 @@ async fn schedule_loop(
executor: TaskExecutor,
cancellation: CancellationToken,
shutdown: CancellationToken,
next_runs: NextRuns,
) {
loop {
let now = Utc::now();
@@ -122,14 +144,22 @@ async fn schedule_loop(
Ok(next) => next,
Err(error) => {
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
lock_next_runs(&next_runs).remove(&task.id);
return;
}
};
lock_next_runs(&next_runs).insert(task.id.clone(), next);
let delay = (next - now).to_std().unwrap_or_default();
tokio::select! {
_ = cancellation.cancelled() => return,
_ = shutdown.cancelled() => return,
_ = cancellation.cancelled() => {
lock_next_runs(&next_runs).remove(&task.id);
return;
}
_ = shutdown.cancelled() => {
lock_next_runs(&next_runs).remove(&task.id);
return;
}
_ = tokio::time::sleep(delay) => {
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
@@ -156,11 +186,19 @@ fn lock_jobs(
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn lock_next_runs(
next_runs: &Mutex<HashMap<String, DateTime<Utc>>>,
) -> MutexGuard<'_, HashMap<String, DateTime<Utc>>> {
next_runs
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
use crate::tasks::model::{ScriptLanguage, TaskAction};
use super::*;
@@ -168,15 +206,15 @@ mod tests {
TaskResponse {
id: "schedule_test".to_owned(),
name: "Schedule test".to_owned(),
description: String::new(),
cron: cron.to_owned(),
timezone: timezone.to_owned(),
enabled: true,
timeout_secs: 30,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy::default(),
action: TaskAction::Script {
working_dir: ".".to_owned(),
venv: None,
language: Some(ScriptLanguage::Shell),
interpreter: "cmd.exe".to_owned(),
script: "/C".to_owned(),
args: vec!["exit".to_owned(), "0".to_owned()],
+7
View File
@@ -33,6 +33,13 @@ impl AppState {
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
}
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
let reconciled = crate::runs::repository::fail_interrupted_runs(&pool).await?;
if reconciled > 0 {
tracing::warn!(
count = reconciled,
"marked runs interrupted by the previous shutdown as cancelled"
);
}
executor.cleanup_logs().await.map_err(ApiError::internal)?;
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
+98 -27
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::{collections::BTreeMap, path::Path};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScriptLanguage {
Python,
Shell,
Node,
Lua,
}
impl ScriptLanguage {
pub const fn as_str(self) -> &'static str {
match self {
Self::Python => "python",
Self::Shell => "shell",
Self::Node => "node",
Self::Lua => "lua",
}
}
pub const fn default_interpreter(self) -> &'static str {
match self {
Self::Python => "python.exe",
Self::Shell => "cmd.exe",
Self::Node => "node.exe",
Self::Lua => "lua.exe",
}
}
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
pub fn from_interpreter(interpreter: &str) -> Self {
let stem = Path::new(interpreter)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(interpreter)
.to_ascii_lowercase();
if matches!(stem.as_str(), "node" | "nodejs") {
Self::Node
} else if stem.starts_with("lua") {
Self::Lua
} else if matches!(
stem.as_str(),
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
) {
Self::Shell
} else {
Self::Python
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskActionType {
@@ -34,22 +84,6 @@ pub enum HttpMethod {
Post,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyPolicy {
#[default]
Skip,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_attempts: u32,
#[serde(default)]
pub delay_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskAction {
@@ -57,6 +91,8 @@ pub enum TaskAction {
working_dir: String,
#[serde(default)]
venv: Option<String>,
#[serde(default)]
language: Option<ScriptLanguage>,
interpreter: String,
script: String,
#[serde(default)]
@@ -93,6 +129,18 @@ impl TaskAction {
Self::Process { .. } => TaskActionType::Process,
}
}
/// 脚本语言;旧任务未存 language 时从解释器推断。
pub fn script_language(&self) -> Option<ScriptLanguage> {
match self {
Self::Script {
language,
interpreter,
..
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
_ => None,
}
}
}
fn default_timezone() -> String {
@@ -112,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
pub struct CreateTaskRequest {
pub id: String,
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -119,10 +170,6 @@ pub struct CreateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -130,6 +177,9 @@ pub struct CreateTaskRequest {
#[serde(deny_unknown_fields)]
pub struct UpdateTaskRequest {
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -137,10 +187,6 @@ pub struct UpdateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -148,12 +194,12 @@ pub struct UpdateTaskRequest {
pub struct TaskResponse {
pub id: String,
pub name: String,
/// 一句话说明
pub description: String,
pub cron: String,
pub timezone: String,
pub enabled: bool,
pub timeout_secs: u64,
pub concurrency_policy: ConcurrencyPolicy,
pub retry_policy: RetryPolicy,
pub action: TaskAction,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -176,3 +222,28 @@ pub struct ListTasksQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
}
/// 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。
#[derive(Debug, Serialize, ToSchema)]
pub struct TaskStatsResponse {
/// 任务总数
pub total: u64,
/// 已启用任务数
pub enabled: u64,
/// 当前正在执行的任务数
pub running: u64,
/// 脚本类任务数
pub script: u64,
/// HTTP 类任务数
pub http: u64,
/// 进程类任务数
pub process: u64,
}
/// 已注册调度的任务及其下次触发时间。
#[derive(Debug, Serialize, ToSchema)]
pub struct ScheduledTaskResponse {
pub task_id: String,
/// 下次触发时间;调度正在重建时可能为空
pub next_run_at: Option<DateTime<Utc>>,
}
+52 -42
View File
@@ -4,17 +4,48 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
";
#[derive(Debug, Clone, Copy, Default)]
pub struct TaskCounts {
pub total: u64,
pub enabled: u64,
pub script: u64,
pub http: u64,
pub process: u64,
}
/// 单条 SQL 统计全部指标,供仪表盘使用。
pub async fn counts(pool: &SqlitePool) -> ApiResult<TaskCounts> {
let (total, enabled, script, http, process): (i64, i64, i64, i64, i64) = sqlx::query_as(
"SELECT
COUNT(*),
COALESCE(SUM(enabled), 0),
COALESCE(SUM(action_type = 'script'), 0),
COALESCE(SUM(action_type = 'http'), 0),
COALESCE(SUM(action_type = 'process'), 0)
FROM tasks",
)
.fetch_one(pool)
.await?;
Ok(TaskCounts {
total: u64::try_from(total).unwrap_or_default(),
enabled: u64::try_from(enabled).unwrap_or_default(),
script: u64::try_from(script).unwrap_or_default(),
http: u64::try_from(http).unwrap_or_default(),
process: u64::try_from(process).unwrap_or_default(),
})
}
pub struct ListFilters {
pub q: Option<String>,
pub enabled: Option<bool>,
@@ -27,13 +58,11 @@ pub struct ListFilters {
struct TaskRow {
id: String,
name: String,
description: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
@@ -44,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
@@ -62,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
Ok(Self {
id: row.id,
name: row.name,
description: row.description,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -83,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -117,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
description: request.description.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
@@ -132,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE id = ?",
)
@@ -154,19 +167,16 @@ pub async fn update(
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -233,14 +243,14 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
})
}
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
/// 启动时恢复调度用的列表:所有启用任务都参与调度,不再只限脚本。
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE enabled = 1 AND action_type = 'script'
WHERE enabled = 1
ORDER BY updated_at DESC, id ASC",
)
.fetch_all(pool)
+46 -22
View File
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_DESCRIPTION_LEN: usize = 500;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
@@ -26,16 +27,18 @@ const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_task_id(&request.id)?;
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -55,15 +58,17 @@ pub async fn update(
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -129,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
Ok(())
}
fn validate_description(description: &str) -> ApiResult<()> {
if description.chars().count() > MAX_DESCRIPTION_LEN {
return Err(crate::error::ApiError::validation(
"description",
format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"),
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
@@ -148,42 +162,52 @@ fn validate_common(
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
})?;
let cron = Cron::from_str(cron_expression).map_err(|error| {
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
tracing::debug!(error = %error, "invalid cron expression");
let message =
crate::cron::CronPreviewError::InvalidExpression(error.to_string()).user_message();
crate::error::ApiError::validation("cron", message)
})?;
cron.find_next_occurrence(&Utc::now(), false)
.map_err(|error| {
crate::error::ApiError::validation(
"cron",
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
)
tracing::debug!(error = %error, "cron has no future occurrence");
crate::error::ApiError::validation("cron", "Cron 表达式没有可计算的未来执行时间")
})?;
Ok(())
}
fn normalize_action(action: &mut TaskAction) {
if let TaskAction::Script {
venv,
language,
interpreter,
..
} = action
{
let resolved =
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
*language = Some(resolved);
if interpreter.trim().is_empty() {
*interpreter = resolved.default_interpreter().to_owned();
}
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
*venv = None;
}
}
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
venv,
language: _,
interpreter,
script,
args,
@@ -331,6 +355,7 @@ mod tests {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
language: Some(ScriptLanguage::Python),
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
@@ -346,7 +371,6 @@ mod tests {
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
@@ -359,7 +383,6 @@ mod tests {
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
@@ -371,6 +394,7 @@ mod tests {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
language: Some(ScriptLanguage::Python),
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
+894
View File
@@ -0,0 +1,894 @@
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 url::Url;
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";
const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR";
/// 内置的 uv 下载镜像根(中科大维护的 GitHub Release 镜像);可在「镜像源」里改成别的镜像。
const DEFAULT_MIRROR_BASE: &str = "https://mirrors.ustc.edu.cn/github-release";
/// 镜像根下 uv 自己的发布目录。
const UV_RELEASE_PATH: &str = "astral-sh/uv/LatestRelease";
/// 镜像根下 python-build-standalone 的发布目录(uv 的 `UV_PYTHON_INSTALL_MIRROR`)。
const PYTHON_RELEASE_PATH: &str = "astral-sh/python-build-standalone/releases/download";
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
const MAX_UV_ENV_ENTRIES: usize = 32;
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
const MAX_MIRROR_URL_CHARS: usize = 2048;
/// 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<Self> {
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<String>,
pub extra_env: BTreeMap<String, String>,
/// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像
pub mirror_url: Option<String>,
}
impl Default for UvSettings {
fn default() -> Self {
Self {
source: UvSource::Managed,
custom_path: None,
extra_env: BTreeMap::new(),
mirror_url: None,
}
}
}
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 会变,相对路径不能直接交给它。
/// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。
pub fn env(&self) -> BTreeMap<String, String> {
let mut env = self.extra_env.clone();
if !env
.keys()
.any(|key| key.eq_ignore_ascii_case(PYTHON_MIRROR_ENV))
{
env.insert(PYTHON_MIRROR_ENV.to_owned(), self.python_mirror_url());
}
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 mirror_base(&self) -> String {
self.mirror_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MIRROR_BASE)
.trim_end_matches('/')
.to_owned()
}
/// uv 自己那个压缩包的下载地址前缀。
pub fn uv_download_base(&self) -> String {
format!("{}/{}", self.mirror_base(), UV_RELEASE_PATH)
}
/// `UV_PYTHON_INSTALL_MIRROR`:uv 下载 Python 解释器的地址前缀。
pub fn python_mirror_url(&self) -> String {
format!("{}/{}", self.mirror_base(), PYTHON_RELEASE_PATH)
}
/// 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("."))
}
/// 运行 `<program> --version`;不是可用的 uv 时返回 `None`。
pub fn probe_version(program: &Path) -> Option<String> {
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"
}
}
/// 从 `<zip>.sha256` 里取第一段十六进制摘要。
fn parse_sha256(text: &str) -> Option<String> {
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<String, String> {
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(base: &str) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.user_agent("TaskPilot/0.1")
.build()
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
let url = format!("{}/{}", base.trim_end_matches('/'), 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<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct UvSettingsResponse {
pub source: UvSource,
pub custom_path: Option<String>,
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
pub extra_env: BTreeMap<String, String>,
/// 程序目录下的 uv 目录
pub managed_dir: String,
/// 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)
pub python_dir: String,
/// 实际生效的 uv 缓存目录(绝对路径)
pub cache_dir: String,
/// 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认
pub mirror_url: String,
/// uv 自身压缩包的下载地址前缀
pub uv_download_url: String,
/// Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)
pub python_mirror_url: String,
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
pub env_defaults: BTreeMap<String, String>,
/// 当前设置指向的 uv;不可用时 `version` 为空
pub executable: Option<String>,
pub version: Option<String>,
/// 各来源的探测结果
pub candidates: Vec<UvCandidate>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateUvSettingsRequest {
pub source: UvSource,
#[serde(default)]
pub custom_path: Option<String>,
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
#[serde(default)]
pub extra_env: BTreeMap<String, String>,
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
#[serde(default)]
pub mirror_url: Option<String>,
}
/// 执行期使用的 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(),
mirror_url: settings.mirror_base(),
uv_download_url: settings.uv_download_base(),
python_mirror_url: settings
.extra_env
.get(PYTHON_MIRROR_ENV)
.cloned()
.unwrap_or_else(|| settings.python_mirror_url()),
env_defaults: {
let mut defaults: BTreeMap<String, String> = UV_PATH_DEFAULTS
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect();
defaults.insert(
PYTHON_MIRROR_ENV.to_owned(),
settings.python_mirror_url(),
);
defaults
},
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<String, String>) -> ApiResult<BTreeMap<String, String>> {
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<UvSettings> {
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)?,
mirror_url: validate_mirror_url(request.mirror_url)?,
})
}
fn validate_mirror_url(value: Option<String>) -> ApiResult<Option<String>> {
let Some(raw) = value else {
return Ok(None);
};
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > MAX_MIRROR_URL_CHARS {
return Err(ApiError::validation("url", "镜像地址过长"));
}
let parsed = Url::parse(trimmed)
.map_err(|_| ApiError::validation("url", "镜像地址不是有效的 URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(ApiError::validation("url", "镜像地址只支持 http 或 https"));
}
if parsed.host_str().is_none() {
return Err(ApiError::validation("url", "镜像地址缺少主机名"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ApiError::validation("url", "镜像地址不能包含用户名或密码"));
}
Ok(Some(trimmed.to_owned()))
}
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
let row = sqlx::query_as::<_, (String, Option<String>, String, Option<String>)>(
"SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1",
)
.fetch_optional(pool)
.await?;
let Some((source, custom_path, extra_env, mirror_url)) = 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(),
mirror_url,
})
}
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, mirror_url, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
source = excluded.source,
custom_path = excluded.custom_path,
extra_env = excluded.extra_env,
mirror_url = excluded.mirror_url,
updated_at = excluded.updated_at",
)
.bind(settings.source.as_str())
.bind(settings.custom_path.as_deref())
.bind(extra_env)
.bind(settings.mirror_url.as_deref())
.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<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
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<AppState>,
ApiJson(request): ApiJson<UpdateUvSettingsRequest>,
) -> ApiResult<Json<UvSettingsResponse>> {
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<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
let mut settings = load(&state.pool).await?;
let version = download_and_install(&settings.uv_download_base())
.await
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
tracing::info!(%version, "downloaded uv into the managed directory");
// 下载的目的就是让它生效,所以直接切到程序目录 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<AppState> {
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(),
mirror_url: None,
}
}
#[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(),
mirror_url: None,
};
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(),
mirror_url: None,
};
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,
mirror_url: None,
})
.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,
mirror_url: None,
});
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,
mirror_url: None,
})
.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 derives_uv_and_python_urls_from_the_mirror_base() {
let settings = UvSettings {
mirror_url: Some("https://mirror.example/github-release/".to_owned()),
..UvSettings::default()
};
assert_eq!(settings.mirror_base(), "https://mirror.example/github-release");
assert_eq!(
settings.uv_download_base(),
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
settings.python_mirror_url(),
"https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download"
);
}
#[test]
fn falls_back_to_the_builtin_mirror() {
let settings = UvSettings::default();
assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE);
assert_eq!(
settings.uv_download_base(),
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
settings.python_mirror_url(),
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
}
#[test]
fn validates_mirror_urls() {
assert_eq!(validate_mirror_url(None).unwrap(), None);
assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None);
assert_eq!(
validate_mirror_url(Some(
"https://mirrors.ustc.edu.cn/github-release/".to_owned()
))
.unwrap()
.as_deref(),
Some("https://mirrors.ustc.edu.cn/github-release")
);
assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err());
assert!(validate_mirror_url(Some("not a url".to_owned())).is_err());
assert!(validate_mirror_url(Some("https://user:[email protected]".to_owned())).is_err());
}
#[test]
fn injects_the_python_mirror_and_keeps_user_overrides() {
let settings = UvSettings::default();
assert_eq!(
settings
.env()
.get("UV_PYTHON_INSTALL_MIRROR")
.map(String::as_str),
Some(
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
)
);
let mut extra_env = BTreeMap::new();
extra_env.insert(
"UV_PYTHON_INSTALL_MIRROR".to_owned(),
"https://my.mirror/python".to_owned(),
);
let custom = UvSettings {
extra_env,
..UvSettings::default()
};
assert_eq!(
custom
.env()
.get("UV_PYTHON_INSTALL_MIRROR")
.map(String::as_str),
Some("https://my.mirror/python")
);
}
#[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");
}
}
+24
View File
@@ -131,6 +131,30 @@ async fn directory_listing_endpoint_returns_subdirectories() {
);
}
#[tokio::test]
async fn directory_listing_endpoint_includes_files_when_requested() {
let (app, temp_dir) = test_app().await;
let tools = temp_dir.path().join("tools");
fs::create_dir_all(tools.join("bin")).expect("bin directory should be created");
fs::write(tools.join("uv.exe"), "x").expect("file should be written");
let uri = format!(
"/api/v1/system/directories?path={}&include_files=true",
encode_query_path(&tools)
);
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
let entries = body["entries"]
.as_array()
.expect("entries should be an array");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["name"], "bin");
assert_eq!(entries[0]["is_dir"], true);
assert_eq!(entries[1]["name"], "uv.exe");
assert_eq!(entries[1]["is_dir"], false);
}
#[tokio::test]
async fn directory_listing_endpoint_rejects_relative_paths() {
let (app, _temp_dir) = test_app().await;
+243
View File
@@ -0,0 +1,243 @@
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<Value>,
) -> (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();
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
(status, json)
}
#[tokio::test]
async fn seeds_python_mirrors_with_one_default() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let mirrors = body.as_array().expect("mirrors should be an array");
assert_eq!(mirrors.len(), 4);
let defaults: Vec<&Value> = mirrors
.iter()
.filter(|mirror| mirror["is_default"] == true)
.collect();
assert_eq!(defaults.len(), 1);
assert_eq!(defaults[0]["id"], "tsinghua");
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
}
#[tokio::test]
async fn mirrors_crud_and_default_switch() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, created) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "内网镜像",
"index_url": "https://pypi.example.local/simple",
"trusted_host": "pypi.example.local",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let id = created["id"]
.as_str()
.expect("created mirror should have an id")
.to_owned();
assert_eq!(created["is_default"], false);
assert_eq!(created["trusted_host"], "pypi.example.local");
let (status, mirror) = send_json(
&app,
Method::POST,
&format!("/api/v1/python/mirrors/{id}/default"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(mirror["is_default"], true);
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let defaults = list
.as_array()
.expect("mirrors should be an array")
.iter()
.filter(|mirror| mirror["is_default"] == true)
.count();
assert_eq!(defaults, 1);
let (status, updated) = send_json(
&app,
Method::PUT,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
Some(json!({
"name": "内网镜像(停用)",
"index_url": "https://pypi.example.local/simple",
"trusted_host": null,
"enabled": false
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["name"], "内网镜像(停用)");
assert_eq!(updated["enabled"], false);
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
assert_eq!(updated["is_default"], false);
let (status, _) = send_json(
&app,
Method::DELETE,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = send_json(
&app,
Method::GET,
&format!("/api/v1/python/mirrors/{id}"),
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
}
#[tokio::test]
async fn rejects_invalid_mirror_url_and_duplicate_index() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "坏地址",
"index_url": "ftp://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "index_url");
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "重复镜像",
"index_url": "https://pypi.org/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["code"], "MIRROR_CONFLICT");
}
#[tokio::test]
async fn mirror_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
None,
Some(json!({
"name": "未授权",
"index_url": "https://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+88 -2
View File
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
}
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv", token, payload).await
}
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv/create", token, payload).await
}
async fn post_json(
app: &Router,
uri: &str,
token: Option<&str>,
payload: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri("/api/v1/python/venv")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
.into_body()
.collect()
.await
.expect("probe body should be readable")
.expect("response body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
@@ -179,3 +192,76 @@ async fn venv_probe_reports_missing_working_directory() {
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_probe_reports_project_dependency_files() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let project = temp_dir.path().join("deps-project");
fs::create_dir_all(&project).expect("project directory should be created");
fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"demo\"\n",
)
.expect("pyproject.toml should be written");
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8"),
"interpreter": "python.exe"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "missing");
assert_eq!(body["has_pyproject"], true);
assert_eq!(body["has_requirements"], true);
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
assert_eq!(body["can_create"], body["uv_available"]);
}
#[tokio::test]
async fn venv_create_rejects_missing_working_directory() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let absent = temp_dir.path().join("absent-project");
let (status, body) = post_create(
&app,
Some(&token),
json!({
"working_dir": absent.to_str().expect("absent path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VENV_CREATE_FAILED");
assert!(
body["message"]
.as_str()
.expect("message should be a string")
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_create_requires_management_token() {
let (app, temp_dir) = test_app().await;
let (status, body) = post_create(
&app,
None,
json!({
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+174
View File
@@ -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<Value>,
) -> (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");
}
+247 -8
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::{fs, time::Duration};
use axum::{
Json, Router,
@@ -201,6 +201,98 @@ 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;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
fs::write(
app._temp_dir.path().join("hello.bat"),
"@echo off\r\necho shell-language-ok\r\n",
)
.expect("batch file should be written");
let mut task = script_task(
"shell_task",
&working_dir,
"cmd.exe",
"hello.bat",
vec![],
"0 0 2 * * *",
10,
);
task["action"]["language"] = json!("shell");
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/shell_task/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let run_id = run["id"].as_i64().expect("run id should be an integer");
let completed = wait_for_run(&app.router, run_id).await;
assert_eq!(completed["status"], "succeeded");
let log_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs"),
None,
)
.await;
let log = response_json(log_response).await;
assert!(
log["content"]
.as_str()
.expect("log content should be a string")
.contains("shell-language-ok")
);
}
#[tokio::test]
async fn manual_script_run_captures_exit_code_and_logs() {
let app = test_app().await;
@@ -573,7 +665,12 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
&working_dir,
"powershell.exe",
"-NoProfile",
vec!["-File", &script_path.to_string_lossy()],
vec![
"-ExecutionPolicy",
"Bypass",
"-File",
&script_path.to_string_lossy(),
],
"0 0 2 * * *",
1,
);
@@ -603,7 +700,10 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
assert_eq!(second_run["status"], "skipped");
let completed = wait_for_run(&app.router, first_run_id).await;
assert_eq!(completed["status"], "timed_out");
assert_eq!(
completed["status"], "timed_out",
"unexpected run: {completed}"
);
assert!(
completed["error_message"]
.as_str()
@@ -646,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;
}
@@ -821,3 +922,141 @@ async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
);
app.state.shutdown();
}
#[tokio::test]
async fn stats_endpoint_aggregates_task_counts() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let script = script_task(
"stats_script",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let http = http_task("http://127.0.0.1:9/never");
for payload in [script.clone(), http.clone()] {
let response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(payload)).await;
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = send_json(&app.router, Method::GET, "/api/v1/stats", None).await;
assert_eq!(response.status(), StatusCode::OK);
let stats = response_json(response).await;
assert_eq!(stats["total"], 2);
assert_eq!(stats["enabled"], 2);
assert_eq!(stats["script"], 1);
assert_eq!(stats["http"], 1);
assert_eq!(stats["process"], 0);
assert_eq!(stats["running"], 0);
app.state.shutdown();
}
#[tokio::test]
async fn schedule_endpoint_reports_next_run_for_enabled_tasks() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"scheduled_stats",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"*/30 * * * * *",
5,
);
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
let response = send_json(&app.router, Method::GET, "/api/v1/schedule", None).await;
assert_eq!(response.status(), StatusCode::OK);
let schedule = response_json(response).await;
let entries = schedule.as_array().expect("schedule should be an array");
let entry = entries
.iter()
.find(|item| item["task_id"] == "scheduled_stats")
.expect("enabled task should appear in the schedule");
assert!(
entry["next_run_at"].is_string(),
"next_run_at should be computed for a registered task"
);
app.state.shutdown();
}
#[tokio::test]
async fn http_tasks_are_registered_for_scheduling() {
let app = test_app().await;
let task = http_task("http://127.0.0.1:9/never");
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
assert!(
app.state.scheduler.is_scheduled("http_task"),
"HTTP tasks must take part in scheduled runs"
);
let disabled = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/http_task/disable",
None,
)
.await;
assert_eq!(disabled.status(), StatusCode::OK);
assert!(!app.state.scheduler.is_scheduled("http_task"));
app.state.shutdown();
}
#[tokio::test]
async fn startup_marks_runs_interrupted_by_restart_as_cancelled() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"interrupted_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
// 模拟上一次进程被强杀后遗留的运行记录。
sqlx::query(
"INSERT INTO task_runs (task_id, trigger_type, status, created_at)
VALUES ('interrupted_task', 'manual', 'running', ?)",
)
.bind(chrono::Utc::now())
.execute(&app.state.pool)
.await
.expect("stale run should be inserted");
let reconciled = task_pilot_backend::runs::repository::fail_interrupted_runs(&app.state.pool)
.await
.expect("reconciliation should run");
assert_eq!(reconciled, 1);
let runs = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/interrupted_task/runs",
None,
)
.await;
let body = response_json(runs).await;
let item = &body["items"][0];
assert_eq!(item["status"], "cancelled");
assert!(
item["error_message"]
.as_str()
.unwrap_or_default()
.contains("重启")
);
app.state.shutdown();
}
+14
View File
@@ -113,6 +113,20 @@ fn script_task(id: &str, name: &str) -> Value {
})
}
#[tokio::test]
async fn empty_interpreter_defaults_to_python() {
let (app, _temp_dir) = test_app().await;
let mut payload = script_task("default_interpreter", "Default interpreter");
payload["action"]["interpreter"] = json!("");
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let created = response_json(response).await;
assert_eq!(created["action"]["interpreter"], "python.exe");
assert_eq!(created["action"]["language"], "python");
}
#[tokio::test]
async fn task_crud_flow_works_end_to_end() {
let (app, _temp_dir) = test_app().await;
+283
View File
@@ -0,0 +1,283 @@
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<Value>,
) -> (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::<Vec<_>>();
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_mirror_is_saved_with_the_settings_and_drives_both_urls() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["mirror_url"],
"https://mirrors.ustc.edu.cn/github-release"
);
assert_eq!(
body["uv_download_url"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
body["python_mirror_url"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
// 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖
assert_eq!(
body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
// 非法镜像地址会被拒绝
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"mirror_url": "ftp://mirror.example"
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "url");
// 保存自定义镜像:两个下载地址都由它派生
let (status, saved) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"mirror_url": "https://mirror.example/github-release/"
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved["mirror_url"], "https://mirror.example/github-release");
assert_eq!(
saved["uv_download_url"],
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
);
// 传 null 恢复内置默认
let (status, restored) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "managed", "mirror_url": null })),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
restored["mirror_url"],
"https://mirrors.ustc.edu.cn/github-release"
);
}
#[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");
}
+1143 -41
View File
File diff suppressed because it is too large Load Diff
+32 -4
View File
@@ -16,8 +16,27 @@
## 数据目录
- SQLite 数据库:`data/task_pilot.db`
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
- 运行日志:`logs/{task_id}/{run_id}.log`
- 托管虚拟环境:`data/envs/<名称>/`(「虚拟环境」弹窗创建,删除环境就是删掉整个目录)
- uv 下载的 Python 解释器:`python/<版本>/`
- uv 缓存:`uv-cache/`(首次装包/下载 Python 后才会出现)
- 程序下载的 uv:`uv/uv.exe`
`UV_PYTHON_INSTALL_DIR` 与 `UV_CACHE_DIR` 默认是**相对本目录**的 `python` / `uv-cache`,
整个发布目录可以整体搬走;想复用机器上的全局缓存,在 uv 配置弹窗里把这两个变量改成绝对路径。
## Python 与 uv
用 uv 创建虚拟环境时,uv 的来源在 uv 配置弹窗(任务调度页右栏「uv → 配置」)切换(不读系统 PATH):
- 本目录下的 `uv/uv.exe`(默认):没有 uv 时会出现「下载 uv」,会从科大镜像下载并校验 sha256 后放进来;也可以自己下载后放到这里
- 任意位置的 `uv.exe`:选「指定路径」,保存时会先校验能运行;本机已装 uv 就把它的路径填进来
同一弹窗还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),会注入到每一次 uv 调用;
任务运行时会把这些变量和默认镜像 `UV_DEFAULT_INDEX` 一并注入任务进程(任务自己的环境变量优先)。
「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;离线环境请自己准备 uv 并指定路径。
## 配置
@@ -33,16 +52,25 @@ $env:TASK_PILOT_STATIC_DIR = "web"
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
单次运行日志上限 5 MiB,单行上限 256 KiB;日志按保留期与总容量清理,正在写入的文件不会被删除。
## 安全边界
- 服务只监听 `127.0.0.1`。
- 浏览器跨站 Origin 会被拒绝。
- 写操作需要运行期随机管理令牌。
- 管理令牌在进程重启后自动更换。
- 写操作需要运行期随机管理令牌,进程重启后自动更换。
- 请求体上限 2 MiB。
## 离线使用
前端资源、字体(`LXGW WenKai` / `Outfit-Medium`)与所有脚本都随包自带,不需要联网即可打开界面与执行任务。
## 故障排查
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
- 页面无法打开时,确认 `web/index.html` 存在。
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
- 任务未执行时,确认任务已启用、Cron 正确,并在任务表的「调度周期」列查看下次触发时间。
- PowerShell 脚本任务报「不是数字签名」时,是执行策略拦截未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录执行 `Unblock-File`。
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
- 日志里出现 `����`:改用 UTF-8 输出,或设置 `TASK_PILOT_LOG_ENCODING=gbk`。
- 进程被强杀后,遗留的运行会在下次启动时标记为「已取消(进程重启中断)」,不是数据损坏。
@@ -0,0 +1,42 @@
# ADR-0006:视觉风格对齐主题并自托管字体
- 状态:已接受
- 日期:2026-10-25
## 背景
产品要求前端视觉与参考站点(Alright / nicetheme)一致,包括字体、配色和交互,但不改变既有布局。参考站点使用 Bootstrap 5 + 自定义变量,正文依赖 CDN 的 `lxgw-wenkai-webfont`,标题使用自托管的 `Outfit-Medium`。
本项目的目标是"本机、离线可复现":依赖 CDN 会在断网、内网或 CDN 故障时导致字体回退,从而破坏视觉一致性。
## 决策
1. 从参考站点抓取其 CSS 的变量定义,把真实色值映射到本项目既有语义变量(`--primary`、`--text-main`、`--border`、`--success` 等),布局相关声明一律不改。
2. 交互细节对齐主题:`0.3s ease-in-out` 过渡、主/危险按钮 hover 使用 `opacity: .85`、输入框聚焦使用主色半透明光环、选中项使用主色浅染。
3. 字重遵循主题的 `--font-weight-bold: 500`;标题与指标数字使用 `Outfit-Medium`,正文使用 `LXGW WenKai`,等宽使用主题的 `SFMono-Regular…Consolas` 栈。
4. 字体全部自托管:`Outfit-Medium.ttf` 直接取自主题;`LXGW WenKai` 由官方 webfont 的 97 个 woff2 子集合并后,再裁剪到 GB2312 + 常用符号子集,输出简体中文 Regular 与 Bold 两个文件。
5. 移除所有 Google Fonts / CDN 字体链接,`index.html` 只 preload 本地字体文件。
6. 新增端到端用例"renders fully offline":拦截并拒绝一切非 `127.0.0.1` 请求,断言页面正常渲染、`document.fonts.check` 命中两种字体且没有任何外部请求。
## 原因
- 直接用主题的变量值,颜色不会"看起来像"而是确实一致。
- 字体子集化把 8.8 MB 的 194 个子集压到 6.7 MB 的两个文件,仓库与安装包体积可控,浏览器只需一次请求。
- 离线断言把"可复现"变成可回归的测试,而不是口头承诺。
## 边界
- 字体子集覆盖 GB2312(6763 字)+ Latin + CJK 标点 + 全角符号;更生僻的汉字(GB18030 扩展区)会按浏览器逐字回退到系统字体,显示正常但字形风格不一致。
- 只包含 400 / 700 两个字重,500 / 600 由 CSS 字重匹配落到 400 / 700;主题的 Light(300)未包含。
- 未实现参考站点的暗色模式(`data-scheme="dark"`)。
- 字体文件随 `frontend/public/fonts` 一起进入安装包,体积增加约 6.7 MB。
## 后续调整(2026-10-26)
仓库托管的远程仓库对单次推送体积有上限,6.7 MB 的字体二进制无法一次推上去。因此:
- 中文字体二进制 **不再纳入版本库**(`/frontend/public/fonts/LXGWWenKai-*.woff2` 已加入 `.gitignore`);
- 新增 `tools/build-fonts.py`,一条命令即可从官方 CDN 重新下载、合并并子集化生成这两个文件;
- `@font-face` 仍然保留,字体缺失时浏览器回退到系统字体,功能不受影响;
- 对应的端到端用例改为「无外部网络渲染」:始终断言没有外部请求,只有字体文件存在时才断言字体命中;
- `Outfit-Medium.ttf`(55 KB)体积很小,继续随仓库提供。
@@ -0,0 +1,41 @@
# ADR-0007:运行期可靠性优化
- 状态:已接受
- 日期:2026-10-25
## 背景
代码评审与端到端自检暴露出五类问题,都会让"看起来正常"的功能在边界条件下失真:
1. 取消任务时执行器直接删除 `running` 句柄,而旧运行的收尾逻辑又无条件删除同任务句柄。取消后立刻重跑时,旧运行会把新运行的闸门删掉,跳过策略失效,同一任务可能并发执行。
2. 调度器用 `JoinHandle::abort()` 取消循环。若恰好卡在 `executor.trigger()` 的数据库 await 中,`running` 表项和 `queued` 运行会永久残留,该任务此后永远被判定为"运行中"并跳过。
3. 进程被强杀后,数据库里的 `queued`/`running` 运行没有对账,前端会一直显示"运行中"并持续轮询。
4. `mark_running`/`complete` 不检查 `rows_affected`,运行记录被级联删除后收尾静默失效。
5. 调度器只注册脚本任务,HTTP 与进程任务"启用"后返回 200 却永远不会被定时触发。
## 决策
1. 句柄带运行 ID,收尾使用 `remove_running_if(task_id, run_id)`:只有仍属于本次运行的句柄才会被移除;取消时依旧立即释放闸门,允许马上重跑。
2. 调度循环只用 `CancellationToken` 退出,不再 `abort`;`ScheduledTask` 的 `JoinHandle` 仅用于保活。
3. 启动时执行 `fail_interrupted_runs`:把遗留的 `queued`/`running` 统一改为 `cancelled`,并写入"进程重启,运行被中断"。
4. `mark_running`/`complete` 返回是否更新成功,执行器对 0 行更新记警告并同步清理内存句柄。
5. 三类 Action 统一参与调度:`list_enabled_tasks` 不再按 `action_type` 过滤,`Scheduler::refresh` 只判断 `enabled`。
6. SQLite 启用 WAL + `busy_timeout(5s)` + `synchronous=NORMAL`,避免执行器写日志时前端查询撞上 `database is locked`。
7. 日志清理跳过正在写入的文件;单行输出上限 256 KiB,超出后截断并继续读行尾。
8. 请求体上限 2 MiB;新增 `/api/v1/stats`(一次查询拿到全部计数)与 `/api/v1/schedule`(下次触发时间)。
9. 前端缓存会话令牌,写请求收到 403 时丢弃缓存并重试一次(覆盖后端重启轮换令牌)。
10. Python 任务默认注入 `PYTHONIOENCODING=utf-8`(用户显式设置优先),`PYTHONUNBUFFERED=1` 保持强制。
## 原因
- 句柄竞态与 `abort` 泄漏都会造成"任务不再执行"且无任何报错,是排障成本最高的一类缺陷。
- 对账让崩溃恢复变成确定行为,前端轮询也能正常收敛。
- 统一调度消除了"启用但永不触发"的假能力。
- 统计与调度接口把仪表盘从 4 次请求降到 2 次,并让"下次执行时间"成为可见信息。
## 边界
- 取消后立即可重跑,意味着旧进程的终止与新进程的启动可能短暂重叠(取决于 Job Object 终止速度)。
- 没有全局并发上限:多个任务在同一时刻触发会各自启动一个进程。
- 对账只针对 `queued`/`running`,不会尝试恢复重试或补偿错过的调度。
- 单行截断后该行内容不完整,日志里会多一条 `[system]` 说明。
@@ -0,0 +1,46 @@
# ADR-0008:日志合并推送与虚拟滚动
- 状态:已接受
- 日期:2026-10-27
## 背景
日志链路原本是"一行一个事件、一行一次渲染":
1. 子进程每输出一行,`LogSink::append` 就向 512 容量的 broadcast 通道发一条 `log` 事件。
输出密集时事件按行堆积,SSE 订阅者很容易 `Lagged`,触发 `lagged` → 前端重新拉取,
在日志最密集的时刻反而放大数据库与文件读取压力。
2. 前端 `RunLogOutput` 把每条事件字符串拼到 `content` state,React 每次都要重渲染整个
`<pre>` 文本;开销随行数线性增长,单次运行 5 MiB 上限意味着最坏情况下数万行 ×
数万次更新,是典型的 O(n²)。
3. 全部日志行都作为 DOM 文本存在,长日志下文本节点、布局与滚动都变得昂贵。
## 决策
1. **后端按窗口合并**:`LogSink` 新增 `PendingBatch`,行仍然**立即写文件**(轮询读到的偏移不受影响),
但 SSE 事件按"**攒满 16 KiB** 或 **满 100 ms**"合并成一条 `log` 事件,携带连续的
`offset..next_offset`。
2. **按时兜底**:每个 sink 起一个 50 ms tick 的后台任务,低输出量时也保证 100 ms 内推送;
任务只持有 `Weak<LogSinkInner>`,sink 释放后自行退出。
3. **析构 flush**:`Drop for LogSinkInner` 同步把残留批次发出去,运行结束时不会丢最后几行。
4. **前端按帧合并**:增量先进行缓冲,`requestAnimationFrame` 内最多触发一次重渲染,
并且在同一次渲染里把新行并入。
5. **虚拟滚动**:`LogViewer` 以固定行高 20 px 换算可见区间,只渲染视口 ± 24 行的窗口;
行内不折行(`white-space: pre`),超长行横向滚动,从而避免逐行测量高度。
6. **跟随策略**:默认贴底跟随;用户上滚超过 24 px 即停止跟随并显示「回到最新」,
点击后回到底部并恢复跟随。
## 原因
- 合并推送把事件数量从"每行一条"降到"每 100 ms 最多一条",SSE 背压与前端更新次数同时下降,
而实时感没有可感知的损失(100 ms 窗口)。
- 文件写入保持即时,轮询接口与下载接口的语义完全不变,合并只影响事件粒度。
- 固定行高让滚动位置与行号一一对应,虚拟滚动不需要动态测量,代码量与出错面都小。
- 行缓冲原地追加 + 一帧一次渲染,避免了复制整个字符串/数组的二次开销。
## 边界
- 合并意味着**事件粒度不再是行**:依赖"一条事件 = 一行"的消费者需要按 `content` 自行切分。
- 不折行是显式取舍:超过面板宽度的行需要横向滚动;折行会让行高不定,虚拟滚动需要测量。
- 单次运行仍受 5 MiB 总上限、单行 256 KiB 上限约束;虚拟滚动只解决渲染,不会保留更多日志。
- `Drop` 里的 flush 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。
@@ -0,0 +1,32 @@
# ADR-0009:uv 与托管 Python 环境
- 状态:已接受
- 日期:2026-11-07
## 背景
Python 任务需要一个可复现的运行环境,但宿主机上的工具链状态不可控:PATH 上的 uv 版本随启动方式变化(终端 / 快捷方式 / 计划任务各不相同),也可能根本没装;pip、conda 与项目 `.venv` 的组合更是无法预设。之前的实现直接依赖 PATH 里的 uv,并要求用户先手动准备好虚拟环境,出了问题要到第一次运行时才暴露。
## 决策
1. **uv 是唯一的环境工具**:建环境、装依赖、跑预装都走 uv,不依赖 `pip.exe`。
2. **uv 来源只认平台副本或指定路径**:默认 `程序目录\uv\uv.exe`,或用户指定的 `uv.exe`;**不读系统 PATH**。缺失时从科大镜像 `mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构压缩包,校验 sha256 后安装到程序目录。
3. **镜像只在装包时生效**:建环境 / 预装依赖时注入 `UV_DEFAULT_INDEX`(以及兼容的 `UV_INDEX_URL`);任务运行时不再注入 `PIP_*` 或镜像变量。
4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。
5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。
6. **托管环境**:`data/envs/<名称>` 预创建常用环境,Python 版本来自 `uv python install` 下载到平台的列表,可预装包、查看已装包。
7. **解释器下载归 uv 配置**:`uv python install` / `uv python list` 的入口放在「uv 配置」弹窗(解释器由 uv 管理、落在 `UV_PYTHON_INSTALL_DIR`);「虚拟环境」弹窗只负责从已下载列表里选版本创建环境。
## 原因
- PATH 上的 uv 版本与可用性不可控;平台副本随包分发,行为可预期。
- 相对默认值让整个发布目录可以整体搬迁;注入前解析成绝对路径,是因为 uv 的 cwd 在调用间会变化(项目目录 / 环境目录),相对路径会漂。
- 把装依赖限制在保存任务阶段,问题能当场暴露;运行期保持"只运行、不改环境"。
- 单机场景下,用户自己调试好的虚拟环境就是最可信的运行环境,应当优先复用而不是覆盖。
## 边界
- 托管环境不会自动升级依赖,也不跟随 `pyproject.toml` 变更重建。
- `uv python install` 走 uv 自己的 Python 发行源(python-build-standalone),和 PyPI 镜像不是一回事;uv 配置弹窗里配「下载镜像」统一给 uv 本体与解释器用,解释器还可以用 `UV_PYTHON_INSTALL_MIRROR` 单独覆盖。
- 删除托管环境是直接删除目录,不可恢复。
- 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。
+217 -4
View File
@@ -77,7 +77,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
// Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。
await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible()
await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click()
const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' })
const directoryPicker = page.getByRole('dialog', { name: '选择工作目录' })
await expect(directoryPicker).toBeVisible()
const directoryPathInput = directoryPicker.getByLabel('目录路径')
await directoryPathInput.fill(workingDir)
@@ -140,7 +140,14 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
path: 'test-results/dashboard-log-drawer.png',
fullPage: true,
})
await drawer.getByRole('button', { name: '关闭运行日志' }).click()
await drawer.getByRole('button', { name: '关闭' }).click()
// 运行已结束时再打开日志,也要能回放(曾因 rAF 句柄未归零停在空态)。
await page.getByRole('button', { name: '日志' }).click()
const replayDrawer = page.getByRole('dialog', { name: 'E2E echo' })
await expect(replayDrawer).toBeVisible()
await expect(replayDrawer.getByText('e2e-output')).toBeVisible()
await replayDrawer.getByRole('button', { name: '关闭' }).click()
await page.getByRole('button', { name: '编辑' }).click()
const editor = page.getByRole('dialog', {
@@ -161,10 +168,67 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
await page.getByRole('button', { name: '删除' }).click()
await page
.getByRole('alertdialog')
.getByRole('dialog', { name: /删除任务/ })
.getByRole('button', { name: '确认删除' })
.click()
await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible()
await expect(page.getByText('还没有任务')).toBeVisible()
})
test('switches script language and adapts the form fields', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: '+ 新建任务' }).click()
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Python', exact: true }).click()
await page.getByRole('option', { name: 'Shell (cmd)' }).click()
await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0)
await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat')
await expect(dialog.locator('.cli-line-cmd')).toContainText(
'cmd.exe /C run.bat',
)
await dialog.getByRole('button', { name: 'Shell (cmd)' }).click()
await page.getByRole('option', { name: 'Node.js' }).click()
await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue(
'index.js',
)
await expect(dialog.locator('.cli-line-cmd')).toContainText(
'node.exe index.js',
)
await page.screenshot({
path: 'test-results/dashboard-script-language.png',
fullPage: true,
})
await dialog.getByRole('button', { name: '取消' }).click()
})
test('avoids a transient invalid venv when switching directories', async ({
page,
}) => {
const venvDir = createVenvFixture()
const emptyProject = join(venvDir, '..', 'empty-project')
mkdirSync(emptyProject, { recursive: true })
await page.goto('/')
await page.getByRole('button', { name: '+ 新建任务' }).click()
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
const workingDir = dialog.getByLabel(/项目工作目录/)
const bar = dialog.locator('.env-detector-bar')
await workingDir.fill(venvDir)
await expect(bar).toContainText('已关联虚拟环境')
await workingDir.fill(emptyProject)
const states = new Set<string>()
for (let index = 0; index < 30; index += 1) {
states.add(await bar.innerText())
await page.waitForTimeout(80)
}
expect([...states].some((text) => text.includes('缺少'))).toBe(false)
await expect(bar).toContainText('未检测到虚拟环境')
})
test('keeps the dashboard usable on a narrow viewport', async ({
@@ -221,3 +285,152 @@ test('cancels a running task from the log drawer', async ({
await drawer.getByRole('button', { name: '取消运行' }).click()
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
})
test('renders without external network access', async ({ page }) => {
const blocked: string[] = []
await page.route('**/*', (route) => {
const url = route.request().url()
const isLocal =
url.startsWith('http://127.0.0.1:5173') ||
url.startsWith('http://localhost:5173') ||
url.startsWith('data:') ||
url.startsWith('blob:')
if (isLocal) {
return route.continue()
}
blocked.push(url)
return route.abort()
})
await page.goto('/')
await expect(
page.getByRole('heading', { name: '任务调度' }),
).toBeVisible()
const state = await page.evaluate(async () => {
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', {
method: 'HEAD',
})
await document.fonts.ready
return {
wenkaiAvailable: probe.ok,
wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'),
displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'),
}
})
expect(blocked).toEqual([])
expect(state.displayLoaded).toBe(true)
if (state.wenkaiAvailable) {
expect(state.wenkaiLoaded).toBe(true)
}
await page.screenshot({
path: 'test-results/dashboard-offline.png',
fullPage: true,
})
})
test('keeps a long run log scrollable without rendering every line', async ({
page,
request,
}) => {
const headers = await authHeaders(request)
await request.delete('/api/v1/tasks/e2e_long_log', { headers })
const response = await request.post('/api/v1/tasks', {
headers,
data: {
...taskPayload,
id: 'e2e_long_log',
name: 'Long log task',
timeout_secs: 60,
action: {
...taskPayload.action,
args: ['for /L %i in (1,1,1500) do @echo line-%i'],
},
},
})
expect(response.status()).toBe(201)
await page.goto('/')
await page
.getByRole('row', { name: /Long log task/ })
.getByRole('button', { name: '运行' })
.click()
const drawer = page.getByRole('dialog', { name: 'Long log task' })
await expect(drawer).toBeVisible()
await expect(drawer.locator('.status-succeeded').first()).toBeVisible({
timeout: 60_000,
})
// 自动跟随到底部,最后几行在视口内。
await expect(drawer.getByText('line-1500')).toBeVisible()
// 虚拟滚动:DOM 中只保留视口附近的少量行,而不是 1500 行全量渲染。
const rendered = await drawer.locator('.log-line').count()
expect(rendered).toBeLessThan(200)
expect(rendered).toBeGreaterThan(5)
await drawer.locator('.log-viewer').evaluate((node) => {
node.scrollTop = node.scrollHeight / 2
})
const jump = drawer.getByRole('button', { name: '回到最新 ↓' })
await expect(jump).toBeVisible()
const logBox = await drawer.locator('.log-viewer').boundingBox()
const jumpBox = await jump.boundingBox()
expect(logBox).not.toBeNull()
expect(jumpBox).not.toBeNull()
if (logBox && jumpBox) {
// 按钮必须落在日志面板可视范围内,而不是被面板裁剪到外面。
expect(jumpBox.y + jumpBox.height).toBeLessThanOrEqual(
logBox.y + logBox.height + 1,
)
}
await page.screenshot({
path: 'test-results/dashboard-log-drawer-long.png',
fullPage: true,
})
})
test('manages python package mirrors', async ({ page, request }) => {
const headers = await authHeaders(request)
// 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。
const existing = await request.get('/api/v1/python/mirrors')
expect(existing.ok()).toBeTruthy()
const existingMirrors = (await existing.json()) as Array<{
id: string
index_url: string
}>
for (const mirror of existingMirrors) {
if (mirror.index_url.includes('pypi.e2e.example')) {
await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers })
}
}
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
await page.goto('/mirrors')
await expect(
page.getByRole('heading', { name: 'Python 镜像' }),
).toBeVisible()
await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible()
await page.getByRole('button', { name: '+ 新增镜像' }).click()
const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' })
await dialog.getByLabel('名称').fill('E2E 镜像')
await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple')
await dialog.getByRole('button', { name: '保存' }).click()
const row = page.getByRole('row', { name: /E2E 镜像/ })
await expect(row).toBeVisible()
await row.getByRole('button', { name: '设为默认' }).click()
await expect(row.getByText('默认', { exact: true })).toBeVisible()
await row.getByRole('button', { name: '删除' }).click()
await page
.getByRole('dialog', { name: /删除镜像/ })
.getByRole('button', { name: '确认删除' })
.click()
await expect(row).toHaveCount(0)
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
})
+5 -4
View File
@@ -7,11 +7,12 @@
name="description"
content="TaskPilot - Windows 本地定时任务管理平台"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
rel="preload"
href="/fonts/Outfit-Medium.ttf"
as="font"
type="font/ttf"
crossorigin
/>
<title>TaskPilot</title>
</head>
+1
View File
@@ -17,6 +17,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.9.2",
"@stash/ui": "file:../../stash-ui",
"@tanstack/react-query": "^5.104.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+14
View File
@@ -11,6 +11,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.9.2
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
'@stash/ui':
specifier: file:../../stash-ui
version: file:../../stash-ui([email protected]([email protected]))([email protected])
'@tanstack/react-query':
specifier: ^5.104.3
version: 5.104.3([email protected])
@@ -477,6 +480,12 @@ packages:
'@standard-schema/[email protected]':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stash/ui@file:../../stash-ui':
resolution: {directory: ../../stash-ui, type: directory}
peerDependencies:
react: '>=19'
react-dom: '>=19'
'@tanstack/[email protected]':
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
@@ -1361,6 +1370,11 @@ snapshots:
'@standard-schema/[email protected]': {}
'@stash/ui@file:../../stash-ui([email protected]([email protected]))([email protected])':
dependencies:
react: 19.3.1
react-dom: 19.3.1([email protected])
'@tanstack/[email protected]': {}
'@tanstack/[email protected]([email protected])':
Binary file not shown.
+1295 -1005
View File
File diff suppressed because it is too large Load Diff
+560 -18
View File
@@ -9,15 +9,11 @@ import App from './App'
const task: Task = {
id: 'echo_task',
name: 'Echo task',
description: 'Echo the configured message',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 300,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: {
type: 'script',
working_dir: 'D:\\Projects\\echo',
@@ -46,9 +42,74 @@ 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<string, Array<{ name: string; version: string }>>
pythons?: Array<{ version: string; path: string }>
/** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */
uvVersion?: string
}
function uvSettingsPayload(
source: string,
customPath: string | null,
managedVersion: string | null = null,
mirrorUrl: string | null = null,
) {
const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release'
return {
source,
custom_path: customPath,
extra_env: {},
env_defaults: {
UV_CACHE_DIR: 'uv-cache',
UV_PYTHON_INSTALL_DIR: 'python',
UV_PYTHON_INSTALL_MIRROR: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
},
managed_dir: 'D:\\TaskPilot\\uv',
python_dir: 'D:\\TaskPilot\\python',
cache_dir: 'D:\\TaskPilot\\uv-cache',
mirror_url: mirrorBase,
uv_download_url: `${mirrorBase}/astral-sh/uv/LatestRelease`,
python_mirror_url: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
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 = options.uvVersion ?? null
let uvMirror: 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<Response> => {
const url =
@@ -68,6 +129,114 @@ function createFetchMock(options: FetchMockOptions = {}) {
if (url.endsWith('/api/v1/session')) {
return jsonResponse({ token: 'test-token' })
}
if (url.endsWith('/api/v1/stats')) {
return jsonResponse({
total: 1,
enabled: 1,
running: 0,
script: 1,
http: 0,
process: 0,
})
}
if (url.endsWith('/api/v1/schedule')) {
return jsonResponse([
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
])
}
if (url.endsWith('/api/v1/python/mirrors')) {
return jsonResponse([
{
id: 'tsinghua',
name: '清华 TUNA',
index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple',
trusted_host: null,
enabled: true,
is_default: true,
created_at: '2026-10-18T10:00:00Z',
updated_at: '2026-10-18T10:00:00Z',
},
])
}
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,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/uv')) {
const request = init?.body
? (JSON.parse(String(init.body)) as {
source: string
custom_path?: string | null
mirror_url?: string | null
})
: null
if (request) {
uvSource = request.source
uvCustomPath = request.custom_path ?? null
if (request.mirror_url !== undefined) {
uvMirror = request.mirror_url
}
}
return jsonResponse(
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/cron/preview')) {
return jsonResponse({
timezone: 'Asia/Shanghai',
@@ -77,11 +246,27 @@ 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
}
const linked = probe.working_dir.toLowerCase().includes('sync')
const creatable = probe.working_dir.toLowerCase().includes('uvproject')
return jsonResponse({
status: linked ? 'linked' : 'missing',
interpreter_kind: 'python',
@@ -90,7 +275,14 @@ function createFetchMock(options: FetchMockOptions = {}) {
source: linked ? 'working_directory' : null,
message: linked
? '已自动关联工作目录中的虚拟环境'
: '未检测到虚拟环境,将直接使用系统解释器',
: creatable
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
uv_available: creatable,
has_pyproject: creatable,
has_requirements: false,
requirements_files: [],
can_create: creatable,
})
}
if (url.includes('/api/v1/system/directories')) {
@@ -146,10 +338,342 @@ describe('TaskDashboard', () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
// 任务名会同时出现在卡片和左下角「最近触发」里
expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0)
expect(
screen.getByText('Echo the configured message'),
).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
// 顶部品牌栏和用户卡片都会显示引擎状态
expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0)
})
it('opens the uv settings dialog from the right column', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('button', { name: '配置 uv' }),
)
expect(
await screen.findByRole('heading', { name: 'uv 配置' }),
).toBeInTheDocument()
// 三段配置改用标签页切换:可执行文件 / 环境变量 / Python 解释器
expect(screen.getByRole('tab', { name: 'uv 可执行文件' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /环境变量/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Python 解释器/ })).toBeInTheDocument()
// 顶部不再重复首页已有的版本 / 数量 / 镜像地址
expect(screen.queryByText(/uv 下载/)).toBeNull()
})
it('saves the uv download mirror from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
const input = await screen.findByLabelText('下载镜像')
await user.clear(input)
await user.type(input, 'https://mirror.example/github-release')
await user.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
),
).toBe(true)
})
const saved = fetchMock.mock.calls.find(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
)
expect(JSON.parse(String(saved?.[1]?.body)).mirror_url).toBe(
'https://mirror.example/github-release',
)
})
it('saves the uv executable and env vars from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
expect(
await screen.findByRole('button', { name: '下载 uv' }),
).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.type(
screen.getByLabelText('uv 可执行文件'),
'D:\\tools\\uv.exe',
)
// 环境变量在第二个标签页里
await user.click(screen.getByRole('tab', { name: /环境变量/ }))
// 三个关键变量(两个路径 + 解释器镜像)固定在列表里,不能删除
const uvDialog = await screen.findByRole('dialog', { name: 'uv 配置' })
expect(
within(uvDialog).queryByRole('button', { name: '删除' }),
).toBeNull()
await user.click(screen.getByRole('button', { name: '+ 添加变量' }))
await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT')
await user.type(screen.getByLabelText('环境变量值 4'), '60')
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_HTTP_TIMEOUT: '60' },
mirror_url: 'https://mirrors.ustc.edu.cn/github-release',
})
})
it('picks the uv executable through the file browser', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
directories: (path) =>
jsonResponse(
path
? {
path,
parent: 'C:\\',
entries: [
{
name: 'uv.exe',
path: `${path}${path.endsWith('\\') ? '' : '\\'}uv.exe`,
is_dir: false,
},
],
truncated: false,
}
: {
path: null,
parent: null,
entries: [{ name: 'C:\\', path: 'C:\\', is_dir: true }],
truncated: false,
},
),
}),
)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.click(screen.getByRole('button', { name: '浏览' }))
expect(
await screen.findByRole('heading', { name: '选择 uv 可执行文件' }),
).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'C:\\' }))
await user.click(await screen.findByRole('button', { name: 'uv.exe' }))
await user.click(screen.getByRole('button', { name: '使用此文件' }))
expect(screen.getByLabelText('uv 可执行文件')).toHaveValue('C:\\uv.exe')
})
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: '配置 uv' }))
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('0.9.0')).length).toBeGreaterThan(0)
})
it('downloads a python version from the uv dialog', async () => {
const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' })
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('tab', { name: /Python 解释器/ }))
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: '管理' }))
const envDialog = await screen.findByRole('dialog', {
name: '虚拟环境管理',
})
expect(
await within(envDialog).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('opens the task details from the card', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '详情' }))
const dialog = await screen.findByRole('dialog', { name: '任务详情' })
expect(within(dialog).getByText('echo_task')).toBeInTheDocument()
expect(
within(dialog).getByText('Echo the configured message'),
).toBeInTheDocument()
expect(within(dialog).getByText('D:\\Projects\\echo')).toBeInTheDocument()
})
it('shows the next run time reported by the schedule endpoint', async () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
// 卡片里直接显示排好的触发时间(MM/DD HH:mm)
expect(
(await screen.findAllByText(/^\d{2}\/\d{2} \d{2}:\d{2}$/)).length,
).toBeGreaterThan(0)
})
it('creates a script task with the visible form values', async () => {
@@ -211,7 +735,7 @@ describe('TaskDashboard', () => {
).toBeInTheDocument()
})
it('falls back to the system interpreter when no venv is detected', async () => {
it('shows an error when no venv or dependency files exist', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
@@ -223,11 +747,29 @@ describe('TaskDashboard', () => {
)
expect(
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
timeout: 3000,
}),
).toBeInTheDocument()
})
it('offers uv environment creation when pyproject.toml exists', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.type(
screen.getByLabelText(/项目工作目录/),
'D:\\Projects\\uvproject',
)
expect(
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
await screen.findByRole('button', { name: '用 uv 创建环境' }, {
timeout: 3000,
}),
).toBeInTheDocument()
expect(screen.getByText('pyproject.toml')).toBeInTheDocument()
})
it('browses folders in the page and fills the working directory', async () => {
@@ -277,7 +819,7 @@ describe('TaskDashboard', () => {
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
const dialog = await screen.findByRole('dialog', {
name: '选择项目工作目录',
name: '选择工作目录',
})
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
@@ -285,7 +827,7 @@ describe('TaskDashboard', () => {
expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: '使用此目录' }))
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull()
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull()
expect(screen.getByLabelText(/项目工作目录/)).toHaveValue(
'D:\\Projects\\sync',
)
@@ -324,7 +866,7 @@ describe('TaskDashboard', () => {
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
const dialog = await screen.findByRole('dialog', {
name: '选择项目工作目录',
name: '选择工作目录',
})
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
@@ -358,12 +900,12 @@ describe('TaskDashboard', () => {
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
const dialog = await screen.findByRole('dialog', {
name: '选择项目工作目录',
name: '选择工作目录',
})
await user.click(within(dialog).getByRole('button', { name: '取消' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(),
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull(),
)
expect(input).toHaveValue('D:\\Manual\\path')
})
@@ -391,7 +933,7 @@ describe('TaskDashboard', () => {
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
const dialog = await screen.findByRole('dialog', {
name: '选择项目工作目录',
name: '选择工作目录',
})
expect(
await within(dialog).findByText('目录不存在: Z:\\nope'),
+27 -17
View File
@@ -1,30 +1,40 @@
import { Link, Route, Routes } from 'react-router-dom'
import { Button, Layout, Result } from '@stash/ui'
import { Route, Routes, useNavigate } from 'react-router-dom'
import { AppHeader } from './components/AppHeader'
import { TaskDashboard } from './pages/TaskDashboard'
import './App.css'
function NotFoundPage() {
const navigate = useNavigate()
return (
<main className="app-shell">
<header className="dashboard-header">
<div>
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
<h1>页面不存在</h1>
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
</div>
</header>
<Link className="button button-primary" to="/">
返回任务列表
</Link>
</main>
<div className="page-stack">
<Result
status="info"
title="页面不存在"
description="该地址尚未配置到 TaskPilot 路由。"
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
/>
</div>
)
}
function App() {
return (
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Layout
className="app-root"
header={<AppHeader />}
headerVariant="solid"
maxWidth="full"
contentPadding="none"
>
<div className="page-container">
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Layout>
)
}
+195 -19
View File
@@ -18,12 +18,32 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
export type CreatePythonVenvRequest =
components['schemas']['CreatePythonVenvRequest']
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
export type VenvSource = components['schemas']['VenvSource']
export type InterpreterKind = components['schemas']['InterpreterKind']
export type DirectoryEntry = components['schemas']['DirectoryEntry']
export type DirectoryListingResponse =
components['schemas']['DirectoryListingResponse']
export type TaskStats = components['schemas']['TaskStatsResponse']
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
export type PythonMirror = components['schemas']['PythonMirrorResponse']
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
@@ -77,20 +97,27 @@ async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
const method = (init.method ?? 'GET').toUpperCase()
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const send = async (): Promise<Response> => {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (mutating) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
}
return fetch(path, { ...init, headers })
}
const response = await fetch(path, {
...init,
headers,
})
let response = await send()
if (mutating && response.status === 403) {
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
invalidateSessionToken()
response = await send()
}
if (!response.ok) {
let body: ErrorResponse | undefined
@@ -108,15 +135,34 @@ async function requestJson<T>(
return (await response.json()) as T
}
let cachedSessionToken: string | null = null
let pendingSessionToken: Promise<string> | null = null
export function invalidateSessionToken(): void {
cachedSessionToken = null
}
async function fetchSessionToken(): Promise<string> {
const response = await fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new ApiRequestError(response.status)
if (cachedSessionToken) {
return cachedSessionToken
}
const session = (await response.json()) as SessionResponse
return session.token
if (!pendingSessionToken) {
pendingSessionToken = fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
.then(async (response) => {
if (!response.ok) {
throw new ApiRequestError(response.status)
}
const session = (await response.json()) as SessionResponse
cachedSessionToken = session.token
return session.token
})
.finally(() => {
pendingSessionToken = null
})
}
return pendingSessionToken
}
function withQuery(path: string, values: object): string {
@@ -252,16 +298,146 @@ export function probePythonVenv(
})
}
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
export function createPythonVenv(
request: CreatePythonVenvRequest,
): Promise<VenvProbeResponse> {
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
method: 'POST',
body: JSON.stringify(request),
})
}
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
*/
export function listDirectories(
path?: string,
options: { includeFiles?: boolean } = {},
signal?: AbortSignal,
): Promise<DirectoryListingResponse> {
return requestJson<DirectoryListingResponse>(
withQuery('/api/v1/system/directories', { path }),
withQuery('/api/v1/system/directories', {
path,
include_files: options.includeFiles ? 'true' : undefined,
}),
{ signal },
)
}
/** 仪表盘计数:一次请求拿到全部指标。 */
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
return requestJson<TaskStats>('/api/v1/stats', { signal })
}
/** 已启用任务的下次触发时间。 */
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
}
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
export function listPythonMirrors(
signal?: AbortSignal,
): Promise<PythonMirror[]> {
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
}
export function createPythonMirror(
request: CreatePythonMirrorRequest,
): Promise<PythonMirror> {
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
method: 'POST',
body: JSON.stringify(request),
})
}
export function updatePythonMirror(
id: string,
request: UpdatePythonMirrorRequest,
): Promise<PythonMirror> {
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
method: 'PUT',
body: JSON.stringify(request),
})
}
export function deletePythonMirror(id: string): Promise<void> {
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
method: 'DELETE',
})
}
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
return requestJson<PythonMirror>(
`/api/v1/python/mirrors/${id}/default`,
{ method: 'POST' },
)
}
/** uv 设置:只用程序目录 uv 或自己指定的可执行文件(不读系统 PATH)。 */
export function getUvSettings(signal?: AbortSignal): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv', { signal })
}
export function updateUvSettings(
request: UpdateUvSettingsRequest,
): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv', {
method: 'PUT',
body: JSON.stringify(request),
})
}
/** 本机没有 uv 时,从镜像下载到程序目录并切换过去。 */
export function downloadUv(): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv/download', { method: 'POST' })
}
/** 预创建的托管虚拟环境(放在 data/envs 下)。 */
export function listManagedEnvs(
signal?: AbortSignal,
): Promise<ManagedEnv[]> {
return requestJson<ManagedEnv[]>('/api/v1/python/envs', { signal })
}
export function createManagedEnv(
request: CreateManagedEnvRequest,
): Promise<ManagedEnv> {
return requestJson<ManagedEnv>('/api/v1/python/envs', {
method: 'POST',
body: JSON.stringify(request),
})
}
export function listManagedEnvPackages(
name: string,
signal?: AbortSignal,
): Promise<ManagedEnvPackage[]> {
return requestJson<ManagedEnvPackage[]>(
`/api/v1/python/envs/${encodeURIComponent(name)}/packages`,
{ signal },
)
}
export function deleteManagedEnv(name: string): Promise<void> {
return requestJson<void>(
`/api/v1/python/envs/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
}
/** uv 下载到平台目录(`python\`)里的解释器。 */
export function listPythons(signal?: AbortSignal): Promise<ManagedPython[]> {
return requestJson<ManagedPython[]>('/api/v1/python/pythons', { signal })
}
export function installPython(
request: InstallPythonRequest,
): Promise<ManagedPython[]> {
return requestJson<ManagedPython[]>('/api/v1/python/pythons', {
method: 'POST',
body: JSON.stringify(request),
})
}
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
import { Brand, Icon, IconButton, toast } from '@stash/ui'
import { useEffect, useState } from 'react'
type Scheme = 'light' | 'dark'
const THEME_STORAGE_KEY = 'taskpilot.theme'
function readScheme(): Scheme {
const saved = window.localStorage.getItem(THEME_STORAGE_KEY)
return saved === 'dark' ? 'dark' : 'light'
}
/**
* 顶部栏:左边品牌、右边操作图标。
*
* 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。
* 明暗切换只写本机偏好(`<html data-scheme>`),刷新会重拉页面数据;
* 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。
*/
export function AppHeader() {
const queryClient = useQueryClient()
const [scheme, setScheme] = useState<Scheme>(readScheme)
const fetching = useIsFetching() > 0
useEffect(() => {
document.documentElement.dataset.scheme = scheme
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
}, [scheme])
return (
<div className="app-header">
<Brand name="TaskPilot" mark="TP" />
<div className="app-header-actions">
<IconButton
label={scheme === 'dark' ? '切换到浅色' : '切换到深色'}
icon={<Icon name={scheme === 'dark' ? 'sun_line' : 'moon_line'} />}
onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}
/>
<IconButton
label="刷新"
icon={<Icon name="lightning_line" />}
spinning={fetching}
onClick={() => void queryClient.invalidateQueries()}
/>
<IconButton
label="配置"
icon={<Icon name="settings_1_line" />}
onClick={() => toast('配置入口还在任务卡片里(uv / 镜像源 / 虚拟环境)')}
/>
<IconButton
label="退出"
icon={<Icon name="exit_line" />}
onClick={() => toast('本地单机版本没有登录,可以直接关掉窗口')}
/>
</div>
</div>
)
}
+25 -57
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef } from 'react'
import { useFocusTrap } from '../lib/useFocusTrap'
import { Button, Modal, Text } from '@stash/ui'
interface ConfirmDialogProps {
open: boolean
@@ -10,6 +9,10 @@ interface ConfirmDialogProps {
onCancel: () => void
}
/**
* 危险操作确认。外壳交给组件库的 `Modal`(原生 dialog:焦点陷阱 / Esc / 背景 inert),
* 这里只负责文案与「取消在左、危险操作在右」的按钮顺序。
*/
export function ConfirmDialog({
open,
title,
@@ -18,66 +21,31 @@ export function ConfirmDialog({
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef)
useEffect(() => {
if (!open) {
return
const requestClose = () => {
if (!busy) {
onCancel()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busy) {
onCancel()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [busy, onCancel, open])
if (!open) {
return null
}
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
onCancel()
}
}}
>
<section
ref={dialogRef}
className="confirm-card"
tabIndex={-1}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-description"
>
<p className="eyebrow">CONFIRM ACTION</p>
<h2 id="confirm-title">{title}</h2>
<p className="muted-copy">{description}</p>
<div className="modal-actions">
<button
type="button"
className="button button-secondary"
onClick={onCancel}
disabled={busy}
>
<Modal
open={open}
onClose={requestClose}
title={title}
size="sm"
closeOnOverlay={!busy}
footer={
<>
<Button variant="secondary" onClick={requestClose} disabled={busy}>
取消
</button>
<button
type="button"
className="button button-danger"
onClick={onConfirm}
disabled={busy}
>
</Button>
<Button variant="danger" onClick={onConfirm} loading={busy}>
{busy ? '删除中...' : '确认删除'}
</button>
</div>
</section>
</div>
</Button>
</>
}
>
<Text tone="sub">{description}</Text>
</Modal>
)
}
+171 -140
View File
@@ -1,29 +1,57 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import {
Button,
Empty,
Icon,
Input,
Modal,
Notice,
Spinner,
Text,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import { listDirectories } from '../api/client'
import { useFocusTrap } from '../lib/useFocusTrap'
interface DirectoryPickerDialogProps {
open: boolean
initialPath: string
onSelect: (path: string) => void
onClose: () => void
/** directory:选目录(默认);file:选单个文件 */
mode?: 'directory' | 'file'
/** file 模式下只显示这些扩展名的文件,例如 ['.exe'];目录始终显示 */
fileExtensions?: string[]
/** 覆盖弹窗标题 */
title?: string
}
function isFilePath(value: string): boolean {
return /[^\\/]+\.[A-Za-z0-9]+$/.test(value.trim())
}
/**
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
*
* `mode="file"` 时同一套浏览逻辑也用来挑单个文件(例如 `uv.exe`):
* 目录可以继续进入,文件点击选中后点「使用此文件」。
*
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
* `Input` / `Button` / `Empty` / `Notice`。
*/
export function DirectoryPickerDialog({
open,
initialPath,
onSelect,
onClose,
mode = 'directory',
fileExtensions,
title,
}: DirectoryPickerDialogProps) {
const isFileMode = mode === 'file'
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [draftPath, setDraftPath] = useState('')
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef, '[data-picker-path]')
const [selectedFile, setSelectedFile] = useState<string | null>(null)
useEffect(() => {
if (!open) {
@@ -32,11 +60,17 @@ export function DirectoryPickerDialog({
const start = initialPath.trim()
setCurrentPath(start || null)
setDraftPath(start)
setSelectedFile(null)
}, [initialPath, open])
const listing = useQuery({
queryKey: ['directories', currentPath],
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
queryKey: ['directories', currentPath, isFileMode],
queryFn: ({ signal }) =>
listDirectories(
currentPath ?? undefined,
{ includeFiles: isFileMode },
signal,
),
enabled: open,
retry: false,
})
@@ -47,162 +81,159 @@ export function DirectoryPickerDialog({
}
}, [listing.data?.path])
useEffect(() => {
if (!open) {
return
const entries = (listing.data?.entries ?? []).filter((entry) => {
// 老接口条目没有 is_dir 时按目录处理,行为与之前一致。
if (!isFileMode || entry.is_dir !== false || !fileExtensions?.length) {
return true
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose, open])
if (!open) {
return null
}
const entries = listing.data?.entries ?? []
const lower = entry.name.toLowerCase()
return fileExtensions.some((extension) => lower.endsWith(extension))
})
const currentDirectory = listing.data?.path ?? null
const parent = listing.data?.parent ?? null
const typedFile = isFileMode && isFilePath(draftPath) ? draftPath.trim() : null
const chosen = isFileMode ? (selectedFile ?? typedFile) : currentDirectory
const navigate = (path: string | null) => {
setCurrentPath(path)
setDraftPath(path ?? '')
setSelectedFile(null)
}
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
>
<section
ref={dialogRef}
className="modal-card directory-picker"
role="dialog"
aria-modal="true"
aria-labelledby="directory-picker-title"
tabIndex={-1}
>
<header className="modal-header">
<div>
<p className="eyebrow">WORKING DIRECTORY</p>
<h2 id="directory-picker-title">选择项目工作目录</h2>
<Modal
open={open}
onClose={onClose}
title={title ?? (isFileMode ? '选择文件' : '选择工作目录')}
size="lg"
className="directory-picker-modal"
footer={
<>
<div className="directory-picker-summary">
<Text size="xs" tone="muted">
将使用
</Text>
<Text size="sm" tone="sub" truncate>
{chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')}
</Text>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="关闭目录选择"
>
×
</button>
</header>
<div className="directory-picker-toolbar">
<button
type="button"
className="button button-secondary button-small"
onClick={() => navigate(parent)}
disabled={!parent}
>
上一级
</button>
<button
type="button"
className="button button-secondary button-small"
onClick={() => navigate(null)}
>
盘符
</button>
<form
className="directory-path-form"
onSubmit={(event) => {
event.preventDefault()
navigate(draftPath.trim() || null)
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
disabled={!chosen}
onClick={() => {
if (chosen) {
onSelect(chosen)
onClose()
}
}}
>
<input
className="field-input mono"
value={draftPath}
onChange={(event) => setDraftPath(event.target.value)}
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
aria-label="目录路径"
data-picker-path
spellCheck={false}
/>
</form>
</div>
{isFileMode ? '使用此文件' : '使用此目录'}
</Button>
</>
}
>
<div className="directory-picker-toolbar">
<Button variant="secondary" onClick={() => navigate(parent)} disabled={!parent}>
上一级
</Button>
<Button variant="secondary" onClick={() => navigate(null)}>
盘符
</Button>
<form
className="directory-path-form"
onSubmit={(event) => {
event.preventDefault()
navigate(draftPath.trim() || null)
}}
>
<Input
className="directory-path-input"
value={draftPath}
onChange={(event) => setDraftPath(event.target.value)}
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
aria-label="目录路径"
spellCheck={false}
/>
</form>
</div>
<div className="directory-picker-body">
{listing.isPending ? (
<p className="muted-copy">正在读取目录…</p>
) : null}
{listing.isError ? (
<div className="inline-alert inline-alert-error" role="alert">
{listing.error.message}
</div>
) : null}
{!listing.isPending && !listing.isError && entries.length === 0 ? (
<p className="empty-inline">
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
</p>
) : null}
{entries.length > 0 ? (
<ul className="directory-list">
{entries.map((entry) => (
<div className="directory-picker-body">
{listing.isPending ? (
<div className="picker-status">
<Spinner label="正在读取目录" />
<Text tone="sub" size="sm">
正在读取目录…
</Text>
</div>
) : null}
{listing.isError ? (
<Notice
tone="error"
title="目录读取失败"
className="directory-picker-error"
>
{listing.error.message}
</Notice>
) : null}
{!listing.isPending && !listing.isError && entries.length === 0 ? (
<Empty
text={
currentDirectory
? isFileMode
? fileExtensions?.length
? `该目录下没有 ${fileExtensions.join(' / ')} 文件`
: '该目录下没有文件'
: '该目录下没有子文件夹'
: '没有可用的盘符'
}
/>
) : null}
{entries.length > 0 ? (
<ul className="directory-list">
{entries.map((entry) => {
const file = entry.is_dir === false
const selected = file && entry.path === selectedFile
return (
<li key={entry.path}>
<button
type="button"
className="directory-row"
onClick={() => navigate(entry.path)}
className={
selected
? 'directory-row directory-row-selected'
: 'directory-row'
}
onClick={() =>
file ? setSelectedFile(entry.path) : navigate(entry.path)
}
title={entry.path}
>
<span className="directory-icon" aria-hidden="true">
📁
</span>
<Icon
className="directory-icon"
name={file ? 'box_line' : 'folder_more_line'}
/>
<span className="directory-name">{entry.name}</span>
{selected ? (
<Icon className="directory-check" name="check_line" />
) : null}
</button>
</li>
))}
</ul>
) : null}
{listing.data?.truncated ? (
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
) : null}
</div>
)
})}
</ul>
) : null}
<footer className="directory-picker-footer">
<div className="directory-picker-summary">
<span className="section-label">将使用</span>
<code>{currentDirectory ?? '尚未选择目录'}</code>
</div>
<div className="modal-actions directory-picker-actions">
<button type="button" className="button button-secondary" onClick={onClose}>
取消
</button>
<button
type="button"
className="button button-primary"
disabled={!currentDirectory}
onClick={() => {
if (currentDirectory) {
onSelect(currentDirectory)
onClose()
}
}}
>
使用此目录
</button>
</div>
</footer>
</section>
</div>
{listing.data?.truncated ? (
<Text size="xs" tone="muted">
文件夹过多,仅列出前 500 个。
</Text>
) : null}
</div>
</Modal>
)
}
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { LogViewer } from './LogViewer'
describe('LogViewer', () => {
it('shows a placeholder before the first chunk arrives', () => {
render(<LogViewer lines={[]} version={0} />)
expect(screen.getByText('等待日志输出...')).toBeInTheDocument()
})
it('only renders the visible window of a long log', () => {
const lines = Array.from({ length: 5000 }, (_, index) => `line ${index}`)
const { container } = render(<LogViewer lines={lines} version={1} />)
const rendered = container.querySelectorAll('.log-line')
expect(rendered.length).toBeGreaterThan(0)
expect(rendered.length).toBeLessThan(100)
expect(screen.getByText('line 0')).toBeInTheDocument()
expect(screen.queryByText('line 4999')).toBeNull()
})
it('keeps the row height fixed so offsets stay computable', () => {
const lines = ['alpha', 'beta'] as const
const { container } = render(<LogViewer lines={lines} version={2} />)
const canvas = container.querySelector('.log-canvas')
expect(canvas).not.toBeNull()
expect((canvas as HTMLElement).style.height).toBe('52px')
})
})
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { computeLogWindow, isNearBottom } from '../lib/logWindow'
/** 每行固定高度(与 CSS 中的 .log-line 一致),虚拟滚动据此换算位置。 */
export const LOG_ROW_HEIGHT = 20
const LOG_VERTICAL_PADDING = 6
const OVERSCAN_ROWS = 24
const FOLLOW_THRESHOLD_PX = 24
interface LogViewerProps {
/** 已经切好的日志行;内容会被原地追加,靠 version 触发重渲染。 */
lines: readonly string[]
/** 每次内容变化递增,用于在"跟随中"时滚到底部。 */
version: number
emptyLabel?: string
}
/**
* 虚拟滚动的日志面板:只渲染视口附近的行,长日志下 DOM 节点数量恒定。
*
* 行高固定、不自动换行(长行横向滚动),这样滚动位置可以直接由行号换算,
* 不需要逐行测量高度。用户往上翻时停止跟随新日志,并给出"回到最新"按钮。
*/
export function LogViewer({
lines,
version,
emptyLabel = '等待日志输出...',
}: LogViewerProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const frameRef = useRef(0)
const followingRef = useRef(true)
const lastScrollTopRef = useRef(0)
const [following, setFollowing] = useState(true)
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
useEffect(() => {
const node = viewportRef.current
if (!node) {
return
}
const sync = () => {
setMetrics((previous) =>
previous.height === node.clientHeight
? previous
: { ...previous, height: node.clientHeight },
)
}
sync()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(sync)
observer.observe(node)
return () => observer.disconnect()
}, [])
useEffect(() => {
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
}
}
}, [])
const handleScroll = useCallback(() => {
const node = viewportRef.current
if (!node) {
return
}
// 只有「用户往上翻」才停止跟随。日志流式追加时 scrollTop 不变但内容变高,
// 此时不能把 following 关掉,否则后半段日志不会再自动贴底。
const previousTop = lastScrollTopRef.current
lastScrollTopRef.current = node.scrollTop
const atBottom = isNearBottom(
node.scrollTop,
node.clientHeight,
node.scrollHeight,
FOLLOW_THRESHOLD_PX,
)
if (atBottom) {
followingRef.current = true
setFollowing(true)
} else if (node.scrollTop < previousTop) {
followingRef.current = false
setFollowing(false)
}
// 可见窗口的重算仍然按帧节流,避免滚动时每像素都 setState。
if (frameRef.current) {
return
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
})
}, [])
// 有新内容时贴住底部;同步更新滚动位置,避免窗口还停在旧位置闪一下。
useLayoutEffect(() => {
if (!followingRef.current) {
return
}
const node = viewportRef.current
if (!node) {
return
}
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
}, [version])
const jumpToLatest = useCallback(() => {
const node = viewportRef.current
if (!node) {
return
}
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
followingRef.current = true
setFollowing(true)
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
}, [])
const { start, end } = computeLogWindow(
lines.length,
metrics.scrollTop,
metrics.height,
LOG_ROW_HEIGHT,
OVERSCAN_ROWS,
)
const canvasHeight =
lines.length * LOG_ROW_HEIGHT + LOG_VERTICAL_PADDING * 2
return (
<div className="log-viewer-shell">
<div
className="log-viewer"
ref={viewportRef}
onScroll={handleScroll}
role="log"
aria-label="运行日志"
tabIndex={0}
>
{lines.length === 0 ? (
<p className="log-empty">{emptyLabel}</p>
) : (
<div className="log-canvas" style={{ height: canvasHeight }}>
<div
className="log-window"
style={{ transform: `translateY(${start * LOG_ROW_HEIGHT}px)` }}
>
{lines.slice(start, end).map((line, index) => (
<div className="log-line" key={start + index}>
{line}
</div>
))}
</div>
</div>
)}
</div>
{lines.length > 0 && !following ? (
<button
type="button"
className="log-jump-latest"
onClick={jumpToLatest}
>
回到最新 ↓
</button>
) : null}
</div>
)
}
@@ -0,0 +1,343 @@
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`,包列表按需查询。
*
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
*/
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<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ManagedEnv | null>(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: <Text strong>{env.name}</Text>,
python_version: env.python_version ? (
<Tag shape="rounded">{env.python_version}</Tag>
) : (
<Text size="sm" tone="muted">
—
</Text>
),
path: (
<code className="code-chip" title={env.path}>
{env.path}
</code>
),
actions: (
<Space justify="end" gap="xs" wrap>
<TextButton onClick={() => setSelected(env.name)}>查看包</TextButton>
<TextButton tone="danger" onClick={() => setDeleteTarget(env)}>
删除
</TextButton>
</Space>
),
}))
return (
<>
<Card padding="md">
<div className="env-manager-head">
<Space direction="column" gap="xs" align="start">
<Text strong>虚拟环境</Text>
<Text size="xs" tone="muted">
用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
</Text>
</Space>
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
</div>
</Card>
<Card padding="none">
{envsQuery.isError ? (
<Result
status="error"
title="虚拟环境列表加载失败"
description={envsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void envsQuery.refetch()}
>
重试
</Button>
}
/>
) : envsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取虚拟环境" />
<Text tone="sub">正在读取虚拟环境...</Text>
</div>
) : envs.length === 0 ? (
<Empty
className="env-manager-empty"
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
/>
) : (
<Table columns={ENV_COLUMNS} rows={rows} />
)}
</Card>
{selected ? (
<Card padding="none">
<div className="env-packages-head">
<Text strong>{selected} 已安装的包</Text>
<Space gap="xs" align="center">
<Text size="xs" tone="muted">
共 {packagesQuery.data?.length ?? 0} 个
</Text>
<TextButton onClick={() => setSelected(null)}>收起</TextButton>
</Space>
</div>
{packagesQuery.isError ? (
<Result
status="error"
title="包列表加载失败"
description={packagesQuery.error.message}
/>
) : packagesQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取已安装的包" />
</div>
) : (packagesQuery.data?.length ?? 0) === 0 ? (
<Empty className="empty-state" text="这个环境里没有安装第三方包" />
) : (
<Table
columns={PACKAGE_COLUMNS}
rows={packagesQuery.data.map((item) => ({
id: item.name,
name: <Text className="mono">{item.name}</Text>,
version: <Text className="mono">{item.version}</Text>,
}))}
/>
)}
</Card>
) : null}
<Notice title="环境创建后不会自动更新">
「新建环境」会用 uv 建一个空环境并按需预装包(需要网络);之后不会自动升级依赖。
运行任务不会改动这些环境。
</Notice>
<Modal
open={createOpen}
onClose={() => {
if (!createMutation.isPending) {
setCreateOpen(false)
}
}}
title="新建虚拟环境"
size="md"
closeOnOverlay={!createMutation.isPending}
footer={
<>
<Button
variant="secondary"
onClick={() => setCreateOpen(false)}
disabled={createMutation.isPending}
>
取消
</Button>
<Button
type="submit"
form="create-env-form"
loading={createMutation.isPending}
>
创建
</Button>
</>
}
>
<form
id="create-env-form"
className="mirror-form"
noValidate
onSubmit={(event) => {
event.preventDefault()
if (!name.trim()) {
toast.error('请填写环境名')
return
}
createMutation.mutate()
}}
>
<FormField
label="环境名"
htmlFor="env-name"
hint="只能包含字母、数字、点、下划线、连字符;同时是 data\envs 下的目录名"
>
<Input
id="env-name"
value={name}
onChange={(event) => setName(event.target.value)}
className="mono-input"
placeholder="data-science"
spellCheck={false}
/>
</FormField>
<FormField
label="Python 版本(可选)"
hint={
(pythonsQuery.data?.length ?? 0) === 0
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
}
>
<Select
options={pythonOptions}
value={pythonVersion}
onChange={setPythonVersion}
/>
</FormField>
<FormField
label="预装包(可选)"
htmlFor="env-packages"
hint="每行一个,可带版本,例如 numpy==2.1.0"
>
<Textarea
id="env-packages"
value={packagesText}
onChange={(event) => setPackagesText(event.target.value)}
rows={5}
placeholder={'numpy==2.1.0\npandas\nrequests'}
spellCheck={false}
/>
</FormField>
</form>
</Modal>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除环境 ${deleteTarget?.name ?? ''}`}
description="会直接删除 data\envs 下的整个环境目录,里面安装的包一并删除;正在使用它的任务下次运行会失败。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</>
)
}
@@ -0,0 +1,214 @@
import { zodResolver } from '@hookform/resolvers/zod'
import {
Button,
Checkbox,
FormField,
Input,
Modal,
Notice,
Text,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { z } from 'zod'
import type { PythonMirror } from '../api/client'
const mirrorSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入镜像名称')
.max(64, '名称不能超过 64 个字符'),
index_url: z
.string()
.trim()
.min(1, '请输入索引地址')
.refine((value) => {
try {
const url = new URL(value)
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
Boolean(url.hostname) &&
!url.username &&
!url.password
)
} catch {
return false
}
}, '请输入 http(s) 开头的有效索引地址'),
trusted_host: z
.string()
.trim()
.refine(
(value) =>
!value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)),
'只填主机名,可带端口,例如 mirrors.aliyun.com',
),
enabled: z.boolean(),
})
export type PythonMirrorFormValues = z.infer<typeof mirrorSchema>
function emptyValues(): PythonMirrorFormValues {
return { name: '', index_url: '', trusted_host: '', enabled: true }
}
function toValues(mirror: PythonMirror): PythonMirrorFormValues {
return {
name: mirror.name,
index_url: mirror.index_url,
trusted_host: mirror.trusted_host ?? '',
enabled: mirror.enabled,
}
}
interface PythonMirrorModalProps {
open: boolean
mirror: PythonMirror | null
saving: boolean
onClose: () => void
onSubmit: (values: PythonMirrorFormValues) => Promise<void>
}
export function PythonMirrorModal({
open,
mirror,
saving,
onClose,
onSubmit,
}: PythonMirrorModalProps) {
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PythonMirrorFormValues>({
resolver: zodResolver(mirrorSchema),
defaultValues: emptyValues(),
mode: 'onSubmit',
})
useEffect(() => {
if (!open) {
return
}
reset(mirror ? toValues(mirror) : emptyValues())
setSubmitError(null)
}, [mirror, open, reset])
const requestClose = () => {
if (!saving) {
onClose()
}
}
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
await onSubmit(values)
} catch (error) {
setSubmitError(error instanceof Error ? error.message : '保存镜像失败')
}
})
return (
<Modal
open={open}
onClose={requestClose}
title={mirror ? '编辑 Python 镜像' : '新增 Python 镜像'}
size="md"
closeOnOverlay={!saving}
footer={
<>
<Button variant="secondary" onClick={requestClose} disabled={saving}>
取消
</Button>
<Button
type="submit"
form="python-mirror-form"
loading={saving}
>
保存
</Button>
</>
}
>
<form
id="python-mirror-form"
className="mirror-form"
onSubmit={submit}
noValidate
>
{submitError ? (
<Notice tone="error" title="保存失败">
{submitError}
</Notice>
) : null}
<FormField label="名称" htmlFor="mirror-name" error={errors.name?.message}>
<Input
id="mirror-name"
{...register('name')}
placeholder="例如:清华 TUNA"
aria-invalid={Boolean(errors.name)}
/>
</FormField>
<FormField
label="索引地址"
htmlFor="mirror-url"
hint="pip 的 index-url,例如 https://pypi.tuna.tsinghua.edu.cn/simple"
error={errors.index_url?.message}
>
<Input
id="mirror-url"
{...register('index_url')}
className="mono-input"
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
spellCheck={false}
aria-invalid={Boolean(errors.index_url)}
/>
</FormField>
<FormField
label="信任主机(可选)"
htmlFor="mirror-trusted-host"
hint="只有 http 源需要;https 源留空。会注入 PIP_TRUSTED_HOST"
error={errors.trusted_host?.message}
>
<Input
id="mirror-trusted-host"
{...register('trusted_host')}
className="mono-input"
placeholder="mirrors.aliyun.com"
spellCheck={false}
aria-invalid={Boolean(errors.trusted_host)}
/>
</FormField>
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Checkbox
checked={field.value}
onChange={field.onChange}
label={
<span className="mirror-enabled-copy">
<Text strong size="sm">
启用此镜像
</Text>
<Text size="xs" tone="muted">
停用后不会参与默认镜像选择
</Text>
</span>
}
/>
)}
/>
</form>
</Modal>
)
}
@@ -0,0 +1,251 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Empty,
Notice,
Result,
Space,
Spinner,
Table,
Tag,
Text,
TextButton,
toast,
type TableColumn,
} from '@stash/ui'
import { useState } from 'react'
import {
createPythonMirror,
deletePythonMirror,
listPythonMirrors,
setDefaultPythonMirror,
updatePythonMirror,
type PythonMirror,
} from '../api/client'
import { ConfirmDialog } from './ConfirmDialog'
import {
PythonMirrorModal,
type PythonMirrorFormValues,
} from './PythonMirrorModal'
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称', width: 180 },
{ key: 'index_url', title: '索引地址', width: 330 },
{ key: 'trusted_host', title: '信任主机', width: 130 },
{ key: 'status', title: '状态', width: 100 },
{ key: 'actions', title: '操作', align: 'right', width: 220 },
]
function toRequest(values: PythonMirrorFormValues) {
return {
name: values.name.trim(),
index_url: values.index_url.trim(),
trusted_host: values.trusted_host.trim() || null,
enabled: values.enabled,
}
}
/**
* 「Python 环境」页的包索引镜像区块。
*
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
*/
export function PythonMirrorsSection() {
const queryClient = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
const [deleteTarget, setDeleteTarget] = useState<PythonMirror | null>(null)
const mirrorsQuery = useQuery({
queryKey: ['python-mirrors'],
queryFn: ({ signal }) => listPythonMirrors(signal),
})
const saveMutation = useMutation({
mutationFn: ({
mirror,
values,
}: {
mirror: PythonMirror | null
values: PythonMirrorFormValues
}) =>
mirror
? updatePythonMirror(mirror.id, toRequest(values))
: createPythonMirror(toRequest(values)),
onSuccess: (mirror, variables) => {
setEditorOpen(false)
setEditingMirror(null)
toast.success(
variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`,
)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
})
const defaultMutation = useMutation({
mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id),
onSuccess: (mirror) => {
toast.success(`默认镜像已切换为 ${mirror.name}`)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
onError: (error) => toast.error(error.message),
})
const deleteMutation = useMutation({
mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id),
onSuccess: (_data, mirror) => {
setDeleteTarget(null)
toast.success(`已删除 ${mirror.name}`)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
onError: (error) => toast.error(error.message),
})
const mirrors = mirrorsQuery.data ?? []
const openCreate = () => {
setEditingMirror(null)
setEditorOpen(true)
}
const openEdit = (mirror: PythonMirror) => {
setEditingMirror(mirror)
setEditorOpen(true)
}
const rows = mirrors.map((mirror) => ({
id: mirror.id,
name: (
<Space gap="xs" align="center" wrap>
<Text strong>{mirror.name}</Text>
{mirror.is_default ? (
<Tag tone="primary" shape="rounded">
默认
</Tag>
) : null}
</Space>
),
index_url: (
<code className="code-chip mirror-url" title={mirror.index_url}>
{mirror.index_url}
</code>
),
trusted_host: mirror.trusted_host ? (
<Text className="mono" size="sm">
{mirror.trusted_host}
</Text>
) : (
<Text size="sm" tone="muted">
—
</Text>
),
status: (
<Tag tone={mirror.enabled ? 'success' : 'neutral'} dot>
{mirror.enabled ? '已启用' : '已停用'}
</Tag>
),
actions: (
<Space justify="end" gap="xs" wrap>
<TextButton
disabled={mirror.is_default}
onClick={() => defaultMutation.mutate(mirror)}
>
设为默认
</TextButton>
<TextButton onClick={() => openEdit(mirror)}>编辑</TextButton>
<TextButton tone="danger" onClick={() => setDeleteTarget(mirror)}>
删除
</TextButton>
</Space>
),
}))
return (
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs" align="start">
<Text strong>包索引镜像</Text>
<Text size="xs" tone="muted">
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
</Text>
</Space>
<Button onClick={openCreate}>+ 新增镜像</Button>
</Space>
</Card>
<Notice title="镜像只影响任务运行,不会自动安装依赖">
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
优先。修改后对下一次运行生效。
</Notice>
<Card padding="none" className="mirror-card">
{mirrorsQuery.isError ? (
<Result
status="error"
title="镜像列表加载失败"
description={mirrorsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void mirrorsQuery.refetch()}
>
重试
</Button>
}
/>
) : mirrorsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取镜像" />
<Text tone="sub">正在读取镜像...</Text>
</div>
) : mirrors.length === 0 ? (
<Empty
className="empty-state"
text="还没有 Python 镜像"
action={<Button onClick={openCreate}>新增镜像</Button>}
/>
) : (
<Table
className="mirror-table"
columns={TABLE_COLUMNS}
rows={rows}
/>
)}
</Card>
<PythonMirrorModal
open={editorOpen}
mirror={editingMirror}
saving={saveMutation.isPending}
onClose={() => {
if (!saveMutation.isPending) {
setEditorOpen(false)
}
}}
onSubmit={async (values) => {
await saveMutation.mutateAsync({ mirror: editingMirror, values })
}}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除镜像 ${deleteTarget?.name ?? ''}`}
description="删除后如果它正是默认镜像,Python 任务将不再注入镜像环境变量;正在运行的任务不受影响。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</>
)
}
+252 -227
View File
@@ -1,5 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import {
Button,
Empty,
Modal,
Notice,
Select,
Spinner,
Text,
Title,
} from '@stash/ui'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
cancelRun,
getRun,
@@ -17,7 +27,8 @@ import {
formatDuration,
isTerminalRunStatus,
} from '../lib/format'
import { useFocusTrap } from '../lib/useFocusTrap'
import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines'
import { LogViewer } from './LogViewer'
import { RunStatusBadge } from './StatusBadge'
interface RunLogDrawerProps {
@@ -27,6 +38,30 @@ interface RunLogDrawerProps {
onClose: () => void
}
const STATUS_FILTERS = [
{ value: '', label: '全部状态' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'succeeded', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'timed_out', label: '已超时' },
{ value: 'cancelled', label: '已取消' },
{ value: 'skipped', label: '已跳过' },
]
const TRIGGER_FILTERS = [
{ value: '', label: '全部触发' },
{ value: 'schedule', label: '定时' },
{ value: 'manual', label: '手动' },
]
/**
* 运行历史 + 日志面板。
*
* 外壳用组件库的 `Modal`(原生 dialog:焦点陷阱、Esc、背景 inert),
* 左栏是运行列表(`Select` 筛选 + 状态标签),右栏是详情与虚拟滚动日志。
* 日志读取本身仍是页面逻辑(SSE + 轮询兜底),不放进组件库。
*/
export function RunLogDrawer({
open,
task,
@@ -41,15 +76,9 @@ export function RunLogDrawer({
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
const [sseConnected, setSseConnected] = useState(false)
const [logEvents] = useState(() => new EventTarget())
const drawerRef = useRef<HTMLElement>(null)
useFocusTrap(open, drawerRef)
const runsQuery = useQuery({
queryKey: [
'task-runs',
task?.id,
historyStatus,
historyTrigger,
],
queryKey: ['task-runs', task?.id, historyStatus, historyTrigger],
queryFn: ({ signal }) =>
listTaskRuns(
task!.id,
@@ -115,9 +144,7 @@ export function RunLogDrawer({
try {
const payload = JSON.parse(event.data) as ExecutionEvent
if (payload.event === 'log') {
logEvents.dispatchEvent(
new CustomEvent('log', { detail: payload }),
)
logEvents.dispatchEvent(new CustomEvent('log', { detail: payload }))
}
} catch {
setSseConnected(false)
@@ -140,201 +167,164 @@ export function RunLogDrawer({
}
}, [logEvents, open, queryClient, task])
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose, open])
if (!open || !task) {
return null
}
return (
<div
className="modal-overlay drawer-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
<Modal
open={open}
onClose={onClose}
title={task.name}
size="lg"
className="run-log-modal"
closeOnOverlay
>
<aside
ref={drawerRef}
className="run-drawer"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="run-drawer-title"
>
<header className="modal-header">
<div>
<p className="eyebrow">RUN HISTORY</p>
<h2 id="run-drawer-title">{task.name}</h2>
<p className="drawer-subtitle mono">{task.id}</p>
<div className="run-log-body">
<section className="run-history-panel">
<div className="section-heading">
<div>
<Text className="section-label" size="xs" tone="muted">
RUN HISTORY
</Text>
<Title level={3}>最近运行</Title>
<Text className="drawer-subtitle" size="xs" tone="muted">
{task.id}
</Text>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void runsQuery.refetch()}
loading={runsQuery.isFetching}
>
刷新
</Button>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="关闭运行日志"
>
×
</button>
</header>
<div className="run-drawer-body">
<section className="run-history-panel">
<div className="section-heading">
<div>
<p className="section-label">RUNS</p>
<h3>最近运行</h3>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => void runsQuery.refetch()}
disabled={runsQuery.isFetching}
>
刷新
</button>
<div className="history-filters">
<Select
className="history-select"
options={STATUS_FILTERS}
value={historyStatus}
onChange={(value) => {
setHistoryStatus(value as RunStatus | '')
setSelectedRunId(null)
}}
/>
<Select
className="history-select"
options={TRIGGER_FILTERS}
value={historyTrigger}
onChange={(value) => {
setHistoryTrigger(value as RunTrigger | '')
setSelectedRunId(null)
}}
/>
</div>
{runsQuery.isPending ? (
<div className="panel-status">
<Spinner label="正在加载运行记录" />
<Text size="sm" tone="sub">
正在加载运行记录...
</Text>
</div>
<div className="history-filters">
<select
className="field-input compact-select"
aria-label="运行状态筛选"
value={historyStatus}
onChange={(event) => {
setHistoryStatus(event.target.value as RunStatus | '')
setSelectedRunId(null)
}}
>
<option value="">全部状态</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="succeeded">成功</option>
<option value="failed">失败</option>
<option value="timed_out">已超时</option>
<option value="cancelled">已取消</option>
<option value="skipped">已跳过</option>
</select>
<select
className="field-input compact-select"
aria-label="触发方式筛选"
value={historyTrigger}
onChange={(event) => {
setHistoryTrigger(event.target.value as RunTrigger | '')
setSelectedRunId(null)
}}
>
<option value="">全部触发</option>
<option value="schedule">定时</option>
<option value="manual">手动</option>
<option value="retry">重试</option>
</select>
) : runsQuery.isError ? (
<Notice tone="error" title="运行记录加载失败">
{runsQuery.error.message}
</Notice>
) : runs.length === 0 ? (
<Empty text="该任务还没有运行记录。" />
) : (
<div className="run-list">
{runs.map((run) => (
<button
type="button"
className={`run-list-item ${
run.id === effectiveRunId ? 'is-selected' : ''
}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
>
<span className="run-list-main">
<span className="mono">#{run.id}</span>
<RunStatusBadge status={run.status} />
</span>
<span className="run-list-meta">
{formatDateTime(run.created_at)} · {run.trigger}
</span>
</button>
))}
</div>
)}
</section>
{runsQuery.isPending ? (
<p className="muted-copy">正在加载运行记录...</p>
) : runsQuery.isError ? (
<div className="inline-alert inline-alert-error">
{runsQuery.error.message}
</div>
) : runs.length === 0 ? (
<p className="empty-inline">该任务还没有运行记录。</p>
) : (
<div className="run-list">
{runs.map((run) => (
<button
type="button"
className={`run-list-item ${
run.id === effectiveRunId ? 'is-selected' : ''
}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
>
<span className="run-list-main">
<span className="mono">#{run.id}</span>
<RunStatusBadge status={run.status} />
</span>
<span className="run-list-meta">
{formatDateTime(run.created_at)} · {run.trigger}
</span>
</button>
))}
</div>
)}
</section>
<section className="run-detail-panel">
<div className="section-heading">
<div>
<p className="section-label">DETAIL</p>
<h3>
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
</h3>
</div>
{selectedRun ? (
<div className="run-detail-actions">
<RunStatusBadge status={selectedRun.status} />
{!isTerminalRunStatus(selectedRun.status) ? (
<button
type="button"
className="button button-danger button-small"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === selectedRun.id
}
onClick={() => cancelMutation.mutate(selectedRun.id)}
>
{cancelMutation.isPending ? '取消中...' : '取消运行'}
</button>
) : null}
<a
className="button button-secondary button-small"
href={runLogDownloadUrl(selectedRun.id)}
>
下载日志
</a>
</div>
) : null}
<section className="run-detail-panel">
<div className="section-heading">
<div>
<Text className="section-label" size="xs" tone="muted">
DETAIL
</Text>
<Title level={3}>
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
</Title>
</div>
{selectedRunQuery.isError ? (
<div className="inline-alert inline-alert-error">
{selectedRunQuery.error.message}
</div>
) : null}
{selectedRun ? (
<RunMetadata run={selectedRun} />
) : (
<p className="empty-inline">选择一条运行记录查看日志。</p>
)}
{effectiveRunId ? (
<RunLogOutput
key={effectiveRunId}
runId={effectiveRunId}
logEvents={logEvents}
sseConnected={sseConnected}
runComplete={
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
}
/>
<div className="run-detail-actions">
<RunStatusBadge status={selectedRun.status} />
{!isTerminalRunStatus(selectedRun.status) ? (
<Button
size="sm"
variant="danger"
loading={
cancelMutation.isPending &&
cancelMutation.variables === selectedRun.id
}
onClick={() => cancelMutation.mutate(selectedRun.id)}
>
取消运行
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => {
window.location.href = runLogDownloadUrl(selectedRun.id)
}}
>
下载日志
</Button>
</div>
) : null}
</section>
</div>
</aside>
</div>
</div>
{selectedRunQuery.isError ? (
<Notice tone="error" title="运行详情加载失败">
{selectedRunQuery.error.message}
</Notice>
) : null}
{selectedRun ? (
<RunMetadata run={selectedRun} />
) : (
<Empty text="选择一条运行记录查看日志。" />
)}
{effectiveRunId ? (
<RunLogOutput
key={effectiveRunId}
runId={effectiveRunId}
logEvents={logEvents}
sseConnected={sseConnected}
runComplete={
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
}
/>
) : null}
</section>
</div>
</Modal>
)
}
@@ -380,15 +370,52 @@ function RunLogOutput({
sseConnected,
runComplete,
}: RunLogOutputProps) {
const [content, setContent] = useState('')
const [version, setVersion] = useState(0)
const [lines, setLines] = useState<readonly string[]>([])
const [pollComplete, setPollComplete] = useState(runComplete)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [reconcileKey, setReconcileKey] = useState(0)
const bufferRef = useRef(createLogBuffer())
const offsetRef = useRef(0)
const outputRef = useRef<HTMLPreElement>(null)
const frameRef = useRef(0)
const complete = runComplete || pollComplete
// 增量先并进缓冲区,一帧最多触发一次重渲染,避免逐行 setState 的 O(n²)。
const scheduleRender = useCallback(() => {
if (frameRef.current) {
return
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
setLines(bufferRef.current.lines)
setVersion((value) => value + 1)
})
}, [])
const ingest = useCallback(
(text: string) => {
if (!text) {
return
}
appendLogChunk(bufferRef.current, text)
scheduleRender()
},
[scheduleRender],
)
useEffect(() => {
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
// StrictMode 会「挂载 → 清理 → 再挂载」,取消后必须把句柄归零,
// 否则下一次 scheduleRender 会误以为还有帧在排队而不再安排渲染,
// 已结束的 run(complete 在首帧就为 true)会一直停在「等待日志输出」。
frameRef.current = 0
}
}
}, [])
useEffect(() => {
let cancelled = false
const controller = new AbortController()
@@ -404,12 +431,12 @@ function RunLogOutput({
if (offsetRef.current !== startOffset) {
continue
}
if (chunk.content) {
setContent((current) => current + chunk.content)
}
ingest(chunk.content)
offsetRef.current = chunk.next_offset
if (chunk.complete) {
flushLogTail(bufferRef.current)
setPollComplete(true)
scheduleRender()
return
}
await new Promise((resolve) => window.setTimeout(resolve, 600))
@@ -431,21 +458,16 @@ function RunLogOutput({
cancelled = true
controller.abort()
}
}, [reconcileKey, reloadKey, runId])
}, [ingest, reconcileKey, reloadKey, runId, scheduleRender])
useEffect(() => {
const onLog = (event: Event) => {
const payload = (event as CustomEvent<ExecutionEvent>).detail
if (
payload.event !== 'log' ||
payload.data.run_id !== runId
) {
if (payload.event !== 'log' || payload.data.run_id !== runId) {
return
}
if (payload.data.offset === offsetRef.current) {
if (payload.data.content) {
setContent((current) => current + payload.data.content)
}
ingest(payload.data.content)
offsetRef.current = payload.data.next_offset
} else if (payload.data.offset > offsetRef.current) {
setReconcileKey((value) => value + 1)
@@ -453,13 +475,15 @@ function RunLogOutput({
}
logEvents.addEventListener('log', onLog)
return () => logEvents.removeEventListener('log', onLog)
}, [logEvents, runId])
}, [ingest, logEvents, runId])
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
if (!complete) {
return
}
}, [content])
flushLogTail(bufferRef.current)
scheduleRender()
}, [complete, scheduleRender])
return (
<div className="log-viewer-wrap">
@@ -476,26 +500,27 @@ function RunLogOutput({
</span>
</div>
{error ? (
<div className="inline-alert inline-alert-error">
{error}
<button
type="button"
className="text-button"
onClick={() => {
setContent('')
<Notice
tone="error"
title="日志读取失败"
className="log-viewer-error"
action={{
label: '重试',
onClick: () => {
bufferRef.current = createLogBuffer()
offsetRef.current = 0
setPollComplete(false)
setError(null)
offsetRef.current = 0
setLines(bufferRef.current.lines)
setVersion((value) => value + 1)
setReloadKey((value) => value + 1)
}}
>
重试
</button>
</div>
},
}}
>
{error}
</Notice>
) : null}
<pre className="log-viewer" ref={outputRef} aria-live="polite">
{content || '等待日志输出...'}
</pre>
<LogViewer lines={lines} version={version} />
</div>
)
}
+29 -8
View File
@@ -1,30 +1,51 @@
import { Tag, type TagTone } from '@stash/ui'
import type { RunStatus } from '../api/client'
import { runStatusLabels } from '../lib/format'
const RUN_STATUS_TONE: Record<RunStatus, TagTone> = {
queued: 'primary',
running: 'warning',
succeeded: 'success',
failed: 'danger',
timed_out: 'danger',
cancelled: 'neutral',
skipped: 'neutral',
}
interface RunStatusBadgeProps {
status: RunStatus
}
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
return (
<span className={`status-badge status-${status}`}>
<span className="status-dot" aria-hidden="true" />
<Tag
tone={RUN_STATUS_TONE[status]}
dot
className={`status-${status}`}
>
{runStatusLabels[status]}
</span>
</Tag>
)
}
interface EnabledBadgeProps {
enabled: boolean
className?: string
}
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
export function EnabledBadge({ enabled, className }: EnabledBadgeProps) {
return (
<span
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
<Tag
tone={enabled ? 'success' : 'neutral'}
dot
className={[
enabled ? 'status-enabled' : 'status-disabled',
className,
]
.filter(Boolean)
.join(' ')}
>
<span className="status-dot" aria-hidden="true" />
{enabled ? '活跃中' : '已暂停'}
</span>
</Tag>
)
}
+175
View File
@@ -0,0 +1,175 @@
import { Button, Divider, Modal, Space, Tag, Text } from '@stash/ui'
import type { ReactNode } from 'react'
import type { Task } from '../api/client'
import {
actionTypeLabel,
formatDateTime,
formatShortDateTime,
} from '../lib/format'
import { EnabledBadge } from './StatusBadge'
interface TaskDetailModalProps {
task: Task | null
nextRunAt?: string
onClose: () => void
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<Text size="xs" tone="muted">
{label}
</Text>
<div className="detail-value">{children}</div>
</div>
)
}
function envSummary(env: Record<string, string> | undefined): string {
const entries = Object.entries(env ?? {})
if (entries.length === 0) {
return '无'
}
return entries.map(([key, value]) => `${key}=${value}`).join('\n')
}
/**
* 任务详情:只读展示任务配置,入口是任务卡片上的「详情」。
*
* 和编辑表单分开:这里不做校验,也不会因为字段缺失而崩,方便核对线上实际配置。
*/
export function TaskDetailModal({
task,
nextRunAt,
onClose,
}: TaskDetailModalProps) {
return (
<Modal
open={task !== null}
onClose={onClose}
title="任务详情"
size="lg"
footer={
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
}
>
{task ? (
<Space direction="column" gap="md" align="stretch">
<Space direction="column" gap="xs" align="start">
<Space gap="xs" align="center" wrap>
<Text size="lg" strong>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
<EnabledBadge enabled={task.enabled} />
</Space>
<Text className="mono" size="xs" tone="muted">
{task.id}
</Text>
{task.description ? (
<Text size="sm" tone="sub">
{task.description}
</Text>
) : null}
</Space>
<Divider spacing="sm" />
<Text strong size="sm">
基本信息
</Text>
<div className="detail-grid">
<DetailRow label="Cron">
<code className="code-chip">{task.cron}</code>
</DetailRow>
<DetailRow label="时区">{task.timezone}</DetailRow>
<DetailRow label="下次触发">
{task.enabled
? nextRunAt
? formatShortDateTime(nextRunAt)
: '调度中…'
: '未调度'}
</DetailRow>
<DetailRow label="超时">{task.timeout_secs} 秒</DetailRow>
</div>
<Divider spacing="sm" />
<Text strong size="sm">
执行配置
</Text>
<div className="detail-grid">
{task.action.type === 'script' ? (
<>
<DetailRow label="工作目录">
{task.action.working_dir}
</DetailRow>
<DetailRow label="解释器">{task.action.interpreter}</DetailRow>
<DetailRow label="脚本">{task.action.script}</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
{task.action.type === 'http' ? (
<>
<DetailRow label="请求 URL">{task.action.url}</DetailRow>
<DetailRow label="请求方式">{task.action.method}</DetailRow>
<DetailRow label="期望状态码">
{task.action.expected_status.length > 0
? task.action.expected_status.join(', ')
: '2xx'}
</DetailRow>
<DetailRow label="请求 Header">
{envSummary(task.action.headers)}
</DetailRow>
<DetailRow label="Body">{task.action.body || '无'}</DetailRow>
</>
) : null}
{task.action.type === 'process' ? (
<>
<DetailRow label="可执行文件">
{task.action.executable}
</DetailRow>
<DetailRow label="工作目录">
{task.action.working_dir ?? '继承程序目录'}
</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
</div>
<Divider spacing="sm" />
<Text strong size="sm">
时间
</Text>
<div className="detail-grid">
<DetailRow label="创建时间">
{formatDateTime(task.created_at)}
</DetailRow>
<DetailRow label="更新时间">
{formatDateTime(task.updated_at)}
</DetailRow>
</div>
</Space>
) : null}
</Modal>
)
}
File diff suppressed because it is too large Load Diff
+601
View File
@@ -0,0 +1,601 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Empty,
FormField,
Icon,
Input,
Modal,
Notice,
RadioGroup,
Result,
Space,
Spinner,
Tabs,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
installPython,
listPythons,
updateUvSettings,
type ManagedPython,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
const PYTHON_VERSION_HINT = '3.12'
type UvTab = 'binary' | 'env' | 'python'
interface EnvRow {
name: string
value: string
}
function rowsFrom(
extraEnv: Record<string, string> | null | undefined,
envDefaults: Record<string, string> | null | undefined,
): EnvRow[] {
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
name,
value,
}))
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改值;
// 这两个变量决定解释器和缓存的位置,界面里不允许删除。
for (const [name, value] of Object.entries(envDefaults ?? {})) {
const configured = rows.some(
(row) => row.name.toUpperCase() === name.toUpperCase(),
)
if (!configured) {
rows.unshift({ name, value })
}
}
return rows
}
function envMap(rows: EnvRow[]): Record<string, string> {
const map: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name && !value) {
continue
}
map[name] = value
}
return map
}
/** `D:\tools\uv.exe` → `D:\tools`;拿不到目录时返回空串(让浏览器从盘符开始)。 */
function directoryOf(path: string): string {
const trimmed = path.trim()
const index = Math.max(
trimmed.lastIndexOf('\\'),
trimmed.lastIndexOf('/'),
)
if (index <= 0) {
return ''
}
const directory = trimmed.slice(0, index)
return directory.endsWith(':') ? `${directory}\\` : directory
}
interface UvSettingsModalProps {
open: boolean
onClose: () => void
}
/**
* uv 配置弹窗:三段配置用标签页切换——可执行文件 / `UV_*` 环境变量 / Python 解释器。
*
* - 来源二选一:程序目录(只读路径 + 缺失时「下载 uv」)或指定路径(可编辑 + 「浏览」挑 exe)。
* - 下载镜像也在这一步:uv 本体与解释器都从它派生;解释器还可以用
* `UV_PYTHON_INSTALL_MIRROR` 单独覆盖(它就在环境变量里)。
* - 环境变量里 `UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_MIRROR`
* 是 uv 的关键变量,只能改值、不能删除;其余 `UV_*` 变量照常增删。
* - 解释器由 uv 下载到 `UV_PYTHON_INSTALL_DIR`;虚拟环境弹窗只从这份列表里挑版本。
* - 顶部不再放状态汇总:版本、解释器数量、镜像地址在首页的 uv 卡片里已经有了。
*/
export function UvSettingsModal({ open, onClose }: UvSettingsModalProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
// 解释器列表由 uv 自己列举;uv 不可用时接口会报错,先不请求。
const uvReady = Boolean(settingsQuery.data?.version)
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: uvReady,
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftMirror, setDraftMirror] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const [pythonVersion, setPythonVersion] = useState('')
const [pickerOpen, setPickerOpen] = useState(false)
const [activeTab, setActiveTab] = useState<UvTab>('binary')
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const mirrorUrl = draftMirror ?? settings?.mirror_url ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const pythons = pythonsQuery.data ?? []
const managedPath = `${settings?.managed_dir ?? 'uv'}\\uv.exe`
const lockedNames = Object.keys(settings?.env_defaults ?? {}).map((name) =>
name.toUpperCase(),
)
const isLockedName = (name: string) =>
lockedNames.includes(name.trim().toUpperCase())
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(null)
setDraftMirror(null)
setDraftEnv(null)
}
const saveMutation = useMutation({
mutationFn: () => {
const extraEnv = envMap(envRows)
// 和默认值一样就不写死,删掉等于回到默认。
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
const configured = Object.keys(extraEnv).some(
(key) => key.toUpperCase() === name.toUpperCase(),
)
if (configured && extraEnv[name]?.trim() === value) {
delete extraEnv[name]
}
}
return updateUvSettings({
source,
custom_path: customPath.trim() || null,
extra_env: extraEnv,
mirror_url: mirrorUrl.trim() || null,
})
},
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
saved.version
? `uv 设置已保存(${shortUvVersion(saved.version)})`
: '已保存 uv 设置,但当前 uv 不可用',
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const downloadMutation = useMutation({
mutationFn: () => downloadUv(),
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: pythonVersion.trim() }),
onSuccess: (installed: ManagedPython[]) => {
setPythonVersion('')
queryClient.setQueryData(['managed-pythons'], installed)
toast.success(`Python 已下载(当前 ${installed.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const submit = () => {
const seen = new Set<string>()
for (const row of envRows) {
const name = row.name.trim()
if (!name) {
if (row.value.trim()) {
toast.error('环境变量名不能为空')
return
}
continue
}
if (!name.toUpperCase().startsWith('UV_')) {
toast.error(`环境变量名 ${name} 需要以 UV_ 开头`)
return
}
const key = name.toUpperCase()
if (seen.has(key)) {
toast.error(`环境变量 ${name} 重复`)
return
}
seen.add(key)
}
// 两个路径变量决定解释器与缓存的位置,清空会让 uv 找不到目录。
for (const name of lockedNames) {
const row = envRows.find(
(candidate) => candidate.name.trim().toUpperCase() === name,
)
if (!row || !row.value.trim()) {
toast.error(`${name} 不能为空,它决定 uv 的 Python / 缓存目录`)
return
}
}
saveMutation.mutate()
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Modal
open={open}
onClose={onClose}
title="uv 配置"
size="lg"
className="uv-settings-modal"
footer={
<>
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
<Button
type="submit"
form="uv-settings-form"
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存设置
</Button>
</>
}
>
{settingsQuery.isError ? (
<Result
status="error"
title="uv 设置加载失败"
description={settingsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void settingsQuery.refetch()}
>
重试
</Button>
}
/>
) : settingsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 uv 设置" />
<Text tone="sub">正在检测 uv...</Text>
</div>
) : (
<form
id="uv-settings-form"
className="uv-settings"
noValidate
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Tabs
className="uv-tabs"
variant="line"
items={[
{ key: 'binary', label: 'uv 可执行文件' },
{ key: 'env', label: `环境变量(${envRows.length})` },
{ key: 'python', label: `Python 解释器(${pythons.length})` },
]}
activeKey={activeTab}
onChange={(key) => setActiveTab(key as UvTab)}
/>
{activeTab === 'binary' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="uv 可执行文件设置"
>
<RadioGroup
direction="row"
options={SOURCE_OPTIONS}
value={source}
onChange={(value) => setDraftSource(value as UvSource)}
/>
<div className="uv-path-row">
{source === 'managed' ? (
// 程序目录的 uv 是固定位置,只展示、不可编辑;只有「指定路径」能改。
<div className="uv-path-static" title={managedPath}>
<Icon name="lock_line" />
<span className="uv-path-static-text">{managedPath}</span>
</div>
) : (
<Input
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
aria-label="uv 可执行文件"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
)}
{source === 'managed' ? (
uvReady ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)
) : (
<Button
size="sm"
variant="secondary"
onClick={() => setPickerOpen(true)}
>
浏览
</Button>
)}
</div>
<Text size="xs" tone="muted">
{source === 'managed'
? uvReady
? '程序目录里的 uv,随发布目录整体搬走。'
: '没装 uv 时可以从下面的镜像下载当前架构的版本,校验 sha256 后放进程序目录并自动切换。'
: '填 uv.exe 的完整路径,或点「浏览」选择;保存时会校验它能否运行。'}
</Text>
<FormField
label="下载镜像"
htmlFor="uv-mirror-url"
hint="GitHub Release 镜像根:uv 本体与 Python 解释器都从这里下载,留空用内置默认。解释器地址也可以用下面的 UV_PYTHON_INSTALL_MIRROR 单独覆盖。"
>
<Input
id="uv-mirror-url"
value={mirrorUrl}
onChange={(event) => setDraftMirror(event.target.value)}
className="mono-input"
placeholder="https://mirrors.ustc.edu.cn/github-release"
spellCheck={false}
/>
</FormField>
</section>
) : activeTab === 'env' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="UV 环境变量"
>
<div className="uv-panel-head">
<Text size="xs" tone="muted">
共 {envRows.length} 项
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</div>
<Text size="xs" tone="muted">
UV_PYTHON_INSTALL_DIR、UV_CACHE_DIR 决定解释器和缓存放在哪,
UV_PYTHON_INSTALL_MIRROR 决定解释器从哪下载;这三项只能改值、不能删除。
其余 UV_* 变量会注入到每一次 uv 调用,路径类可填相对程序目录的值。
</Text>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => {
const locked = isLockedName(row.name)
const isMirror =
row.name.trim().toUpperCase() ===
'UV_PYTHON_INSTALL_MIRROR'
return (
<div className="uv-env-row" key={index}>
{locked ? (
<span
className="uv-env-locked"
title={
isMirror
? '解释器的下载源,不能删除;改值即可换镜像'
: '决定 uv 的 Python / 缓存目录,不能删除'
}
>
<Icon name="lock_line" />
<span className="uv-env-locked-name">{row.name}</span>
</span>
) : (
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_HTTP_TIMEOUT"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
)}
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder={
locked && !isMirror
? '相对于程序目录,或绝对路径'
: '值'
}
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
{locked ? (
<Text size="xs" tone="muted">
固定
</Text>
) : (
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter(
(_, position) => position !== index,
),
)
}
>
删除
</TextButton>
)}
</div>
)
})}
</div>
)}
</section>
) : (
<section
className="uv-section"
role="tabpanel"
aria-label="Python 解释器设置"
>
<Text size="xs" tone="muted">
由 uv 下载,共 {pythons.length} 个版本。解释器放在{' '}
<Text className="mono" size="xs" tone="sub">
{settings?.python_dir ?? 'python\\'}
</Text>
(UV_PYTHON_INSTALL_DIR);新建虚拟环境时直接选这里的版本,不用重复下载。
</Text>
<Space gap="xs" align="center" wrap>
<Input
value={pythonVersion}
onChange={(event) => setPythonVersion(event.target.value)}
className="mono-input uv-version-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
disabled={!uvReady}
title={uvReady ? undefined : '请先安装或指定 uv'}
onClick={() => {
if (!pythonVersion.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Notice
tone="error"
title="解释器列表读取失败"
action={{
label: '重试',
onClick: () => void pythonsQuery.refetch(),
}}
>
{pythonsQuery.error.message}
</Notice>
) : pythonsQuery.isPending ? (
uvReady ? (
<div className="uv-inline-state">
<Spinner size="sm" label="正在读取解释器" />
<Text size="sm" tone="muted">
正在读取解释器...
</Text>
</div>
) : (
<Text size="sm" tone="muted">
先在上面安装或指定 uv,装好之后才能下载解释器。
</Text>
)
) : pythons.length === 0 ? (
<Empty
className="uv-empty"
text="还没有下载解释器。填一个版本号(例如 3.12)后点「下载 Python」。"
/>
) : (
<ul className="uv-python-list">
{pythons.map((python) => (
<li className="uv-python-item" key={python.version}>
<Text strong className="uv-python-version">
{python.version}
</Text>
<code
className="code-chip uv-python-path"
title={python.path}
>
{python.path}
</code>
</li>
))}
</ul>
)}
</section>
)}
</form>
)}
<DirectoryPickerDialog
open={pickerOpen}
initialPath={directoryOf(customPath)}
mode="file"
fileExtensions={['.exe']}
title="选择 uv 可执行文件"
onSelect={(path) => setDraftCustomPath(path)}
onClose={() => setPickerOpen(false)}
/>
</Modal>
)
}
+84 -55
View File
@@ -1,41 +1,42 @@
:root {
--bg-color: #f7f7f8;
--card-bg: #ffffff;
--text-main: #111827;
--text-muted: #8c939d;
--text-light: #b5b9c0;
--primary: #111827;
--primary-hover: #262e3d;
--border-color: #f0f1f3;
--badge-bg: #f3f4f6;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
--background: var(--bg-color);
--surface: var(--card-bg);
--surface-muted: #fafafa;
--text: var(--text-main);
--text-secondary: var(--text-muted);
--text-tertiary: #a4aab3;
--border: #eceef1;
--success: #10b981;
--success-bg: #eefbf5;
--danger: #f43f5e;
--danger-bg: #fff1f2;
--warning: #d97706;
--warning-bg: #fffbeb;
--shadow: var(--shadow-card);
--font-sans:
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', Consolas, 'Courier New', monospace;
@font-face {
font-family: 'Outfit-Medium';
src: url('/fonts/Outfit-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
color: var(--text);
background: var(--background);
font-family: var(--font-sans);
/* 正文中文字体:LXGW WenKai(自托管,GB2312 + 常用符号子集) */
@font-face {
font-family: 'LXGW WenKai';
src: url('/fonts/LXGWWenKai-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'LXGW WenKai';
src: url('/fonts/LXGWWenKai-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/*
* 页面级基础:字体 / 重置 / 全局焦点兜底。
* 颜色、间距、圆角、阴影一律取自 @stash/ui 的 --su-* 令牌,这里不再定义主题色。
*/
:root {
/* 展示字体:标题用 Outfit,不参与令牌体系,页面自己持有 */
--font-display: 'Outfit-Medium', var(--su-font-sans);
color: var(--su-text-main);
background-color: var(--su-bg-page);
font-family: var(--su-font-sans);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color-scheme: light;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -44,19 +45,23 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
min-width: 320px;
background: var(--background);
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
line-height: 1.5;
font-size: 14px;
}
a {
color: inherit;
text-decoration: none;
}
button,
@@ -66,7 +71,13 @@ textarea {
font: inherit;
}
/* 组件库的「无底色按钮」依赖宿主这份 reset(Select 选项、Segmented、Tabs、Menu
都只声明选中态底色)。少了它,浏览器默认的 ButtonFace 灰底和按钮边框会露出来,
下拉选项会变成一块块灰条。 */
button {
border: none;
background: none;
color: inherit;
cursor: pointer;
}
@@ -74,16 +85,34 @@ button:disabled {
cursor: not-allowed;
}
h1,
h2,
h3 {
font-family: var(--font-display);
}
ul,
ol {
list-style: none;
}
/* 键盘焦点环由组件库统一提供;这里只给「非表单控件」补兜底。
表单控件不能加全局 outline:输入框点击时也会命中 :focus-visible,
会和组件自己的边框态叠成「双框」。 */
a:focus-visible,
button:focus-visible,
[tabindex]:not(input):focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: none;
}
::selection {
background: #111827;
color: #fff;
}
:focus-visible {
outline: 3px solid rgba(17, 24, 39, 0.25);
outline-offset: 3px;
}
#root {
min-height: 100vh;
background: var(--su-color-primary-soft);
color: var(--su-text-main);
}
+26
View File
@@ -17,6 +17,21 @@ export function formatDateTime(value: string | null | undefined): string {
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
}
const shortDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
export function formatShortDateTime(value: string | null | undefined): string {
if (!value) {
return '--'
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '--' : shortDateTimeFormatter.format(date)
}
export function formatDuration(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -38,6 +53,17 @@ export function actionTypeLabel(type: TaskActionType): string {
}[type]
}
/** uv --version 是 `uv 0.13.7 (hash 日期 架构)`,界面只显示版本号。 */
export function shortUvVersion(
version: string | null | undefined,
): string | undefined {
if (!version) {
return undefined
}
const number = version.replace(/^uv\s+/i, '').split('(')[0]?.trim()
return number || version
}
export const runStatusLabels: Record<RunStatus, string> = {
queued: '排队中',
running: '运行中',
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
appendLogChunk,
createLogBuffer,
flushLogTail,
logLineCount,
} from './logLines'
describe('appendLogChunk', () => {
it('splits complete lines and keeps the unfinished tail', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'first\nsecond\npart')
expect(buffer.lines).toEqual(['first', 'second'])
expect(buffer.tail).toBe('part')
expect(logLineCount(buffer)).toBe(3)
})
it('joins chunks that split a line in the middle', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'hel')
appendLogChunk(buffer, 'lo\nnext')
expect(buffer.lines).toEqual(['hello'])
expect(buffer.tail).toBe('next')
})
it('drops the carriage return of Windows line endings', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'one\r\ntwo\r\n')
expect(buffer.lines).toEqual(['one', 'two'])
expect(buffer.tail).toBe('')
})
it('ignores empty chunks', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, '')
appendLogChunk(buffer, 'x\n')
expect(buffer.lines).toEqual(['x'])
})
})
describe('flushLogTail', () => {
it('promotes an unterminated last line when the run ends', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'line\nno-newline')
flushLogTail(buffer)
expect(buffer.lines).toEqual(['line', 'no-newline'])
expect(buffer.tail).toBe('')
})
it('does nothing when the log ends with a newline', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'line\n')
flushLogTail(buffer)
expect(buffer.lines).toEqual(['line'])
expect(logLineCount(buffer)).toBe(1)
})
})
+44
View File
@@ -0,0 +1,44 @@
/**
* 运行日志的行缓冲。
*
* 日志按块到达(SSE 合并推送或轮询分片),块边界可能落在任意字符之间,
* 所以只把完整行放进 `lines`,剩下的残行留在 `tail` 等下一块拼接。
* 直接改数组而不是每块复制一份,避免长日志上的 O(n²) 拷贝。
*/
export interface LogBuffer {
lines: string[]
tail: string
}
export function createLogBuffer(): LogBuffer {
return { lines: [], tail: '' }
}
/** 把一段增量日志并进缓冲区,只切出完整的行。 */
export function appendLogChunk(buffer: LogBuffer, chunk: string): void {
if (!chunk) {
return
}
const combined = buffer.tail + chunk
const parts = combined.split('\n')
buffer.tail = parts.pop() ?? ''
for (const line of parts) {
// Windows 子进程输出以 \r\n 结尾,去掉行尾的 \r 免得显示成方框。
buffer.lines.push(line.endsWith('\r') ? line.slice(0, -1) : line)
}
}
/** 运行结束:没有以换行符收尾的残行也要作为最后一行显示。 */
export function flushLogTail(buffer: LogBuffer): void {
if (buffer.tail) {
buffer.lines.push(
buffer.tail.endsWith('\r') ? buffer.tail.slice(0, -1) : buffer.tail,
)
buffer.tail = ''
}
}
/** 供界面展示的日志行数(包含尚未换行的最后一行)。 */
export function logLineCount(buffer: LogBuffer): number {
return buffer.lines.length + (buffer.tail ? 1 : 0)
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { computeLogWindow, isNearBottom } from './logWindow'
describe('computeLogWindow', () => {
it('renders a bounded window for a long log', () => {
const window = computeLogWindow(1000, 0, 400, 20, 5)
expect(window.start).toBe(0)
expect(window.end).toBe(25)
})
it('moves the window with the scroll position', () => {
const window = computeLogWindow(1000, 400, 400, 20, 5)
expect(window.start).toBe(15)
expect(window.end).toBe(45)
})
it('clamps the window at the end of the log', () => {
const window = computeLogWindow(1000, 20_000, 400, 20, 5)
expect(window.start).toBe(995)
expect(window.end).toBe(1000)
})
it('returns an empty window for an empty log', () => {
expect(computeLogWindow(0, 0, 400, 20, 5)).toEqual({ start: 0, end: 0 })
})
})
describe('isNearBottom', () => {
it('treats the bottom edge as following', () => {
expect(isNearBottom(1600, 400, 2000, 24)).toBe(true)
})
it('reports scrolling up as not following', () => {
expect(isNearBottom(1200, 400, 2000, 24)).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
/** 虚拟滚动的可见窗口(左闭右开)。 */
export interface LogWindow {
start: number
end: number
}
/**
* 计算需要渲染的行区间。
*
* `scrollTop` 以像素计,行高固定;`overscan` 是窗口上下额外渲染的行数,
* 用来遮住快速滚动时的空白。
*/
export function computeLogWindow(
total: number,
scrollTop: number,
viewportHeight: number,
rowHeight: number,
overscan: number,
): LogWindow {
if (total <= 0 || rowHeight <= 0) {
return { start: 0, end: 0 }
}
const first = clamp(Math.floor(Math.max(0, scrollTop) / rowHeight), 0, total)
const visible = Math.max(1, Math.ceil(Math.max(0, viewportHeight) / rowHeight))
const start = Math.max(0, first - overscan)
const end = Math.min(total, first + visible + overscan)
return { start, end }
}
/** 视口是否已经贴着底部(留一点像素容差)。 */
export function isNearBottom(
scrollTop: number,
clientHeight: number,
scrollHeight: number,
threshold: number,
): boolean {
return scrollHeight - scrollTop - clientHeight <= threshold
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
+20
View File
@@ -28,6 +28,26 @@ describe('task form payload', () => {
})
})
it('submits the selected script language and interpreter', () => {
const values = createEmptyTaskFormValues()
values.id = 'shell_task'
values.name = 'Shell task'
values.script.language = 'shell'
values.script.working_dir = 'D:\\Projects\\shell-task'
values.script.interpreter = 'cmd.exe'
values.script.script = 'run.bat'
const payload = valuesToCreateRequest(values)
expect(payload.action).toMatchObject({
type: 'script',
language: 'shell',
working_dir: 'D:\\Projects\\shell-task',
interpreter: 'cmd.exe',
script: 'run.bat',
})
})
it('creates an HTTP action instead of forcing script type', () => {
const values = createEmptyTaskFormValues()
values.id = 'health_check'
+85 -14
View File
@@ -12,9 +12,79 @@ export interface KeyValueFormValue {
value: string
}
export type ScriptLanguage = 'python' | 'shell' | 'node' | 'lua'
export const SCRIPT_LANGUAGE_META: Record<
ScriptLanguage,
{
label: string
defaultInterpreter: string
defaultScript: string
scriptLabel: string
scriptPlaceholder: string
hint: string
}
> = {
python: {
label: 'Python',
defaultInterpreter: '',
defaultScript: 'main.py',
scriptLabel: '脚本文件或相对路径',
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
},
shell: {
label: 'Shell (cmd)',
defaultInterpreter: 'cmd.exe',
defaultScript: 'run.bat',
scriptLabel: '批处理文件或相对路径',
scriptPlaceholder: 'run.bat 或 scripts\\start.cmd',
hint: '通过 cmd.exe /C 执行批处理文件,不需要虚拟环境。',
},
node: {
label: 'Node.js',
defaultInterpreter: 'node.exe',
defaultScript: 'index.js',
scriptLabel: 'JavaScript 文件或相对路径',
scriptPlaceholder: 'index.js 或 scripts\\worker.js',
hint: '通过 node.exe 执行,不需要虚拟环境;需已安装 Node.js。',
},
lua: {
label: 'Lua',
defaultInterpreter: 'lua.exe',
defaultScript: 'main.lua',
scriptLabel: 'Lua 文件或相对路径',
scriptPlaceholder: 'main.lua 或 scripts\\task.lua',
hint: '通过 lua.exe 执行,不需要虚拟环境;需已安装 Lua。',
},
}
export function detectScriptLanguage(interpreter: string): ScriptLanguage {
const name = interpreter
.trim()
.split(/[\\/]/)
.pop()
?.toLowerCase()
.replace(/\.exe$/, '')
if (name === 'node' || name === 'nodejs') {
return 'node'
}
if (name?.startsWith('lua')) {
return 'lua'
}
if (
name &&
['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh'].includes(name)
) {
return 'shell'
}
return 'python'
}
export interface TaskFormValues {
id: string
name: string
description: string
cron: string
timezone: string
timeout_secs: number
@@ -23,6 +93,7 @@ export interface TaskFormValues {
script: {
working_dir: string
venv: string
language: ScriptLanguage
interpreter: string
script: string
args: KeyValueFormValue[]
@@ -72,6 +143,10 @@ export const taskFormSchema = z
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
description: z
.string()
.trim()
.max(500, '任务说明不能超过 500 个字符'),
cron: z
.string()
.trim()
@@ -88,6 +163,7 @@ export const taskFormSchema = z
script: z.object({
working_dir: z.string(),
venv: z.string(),
language: z.enum(['python', 'shell', 'node', 'lua']),
interpreter: z.string(),
script: z.string(),
args: z.array(keyValueSchema),
@@ -116,13 +192,6 @@ export const taskFormSchema = z
message: '请输入工作目录',
})
}
if (!values.script.interpreter.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'interpreter'],
message: '请输入解释器或可执行程序',
})
}
if (!values.script.script.trim()) {
context.addIssue({
code: 'custom',
@@ -163,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
description: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
@@ -171,7 +241,8 @@ export function createEmptyTaskFormValues(): TaskFormValues {
script: {
working_dir: '',
venv: '',
interpreter: 'python.exe',
language: 'python',
interpreter: '',
script: 'main.py',
args: [],
env: [],
@@ -196,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.description = task.description ?? ''
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
@@ -203,9 +275,11 @@ export function taskToFormValues(task: Task): TaskFormValues {
values.actionType = task.action.type
if (task.action.type === 'script') {
const language = task.action.language ?? detectScriptLanguage(task.action.interpreter)
values.script = {
working_dir: task.action.working_dir,
venv: task.action.venv ?? '',
venv: language === 'python' ? (task.action.venv ?? '') : '',
language,
interpreter: task.action.interpreter,
script: task.action.script,
args: argsToRows(task.action.args ?? []),
@@ -245,6 +319,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
type: 'script',
working_dir: values.script.working_dir.trim(),
venv: values.script.venv.trim() || null,
language: values.script.language,
interpreter: values.script.interpreter.trim(),
script: values.script.script.trim(),
args: argumentsToArray(values.script.args),
@@ -291,15 +366,11 @@ export function valuesToUpdateRequest(
): UpdateTaskRequest {
return {
name: values.name.trim(),
description: values.description.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
-64
View File
@@ -1,64 +0,0 @@
import { useEffect, type RefObject } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function useFocusTrap(
open: boolean,
containerRef: RefObject<HTMLElement | null>,
initialFocusSelector?: string,
) {
useEffect(() => {
if (!open || !containerRef.current) {
return
}
const container = containerRef.current
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusFirst = () => {
const initial = initialFocusSelector
? container.querySelector<HTMLElement>(initialFocusSelector)
: null
const first =
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
;(first ?? container).focus()
}
const frame = window.requestAnimationFrame(focusFirst)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return
}
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.offsetParent !== null)
if (focusable.length === 0) {
event.preventDefault()
container.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [containerRef, initialFocusSelector, open])
}
+3
View File
@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from '@stash/ui'
import '@stash/ui/style.css'
import './index.css'
import App from './App.tsx'
@@ -20,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
File diff suppressed because it is too large Load Diff
+15
View File
@@ -2,6 +2,21 @@ import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
/**
* jsdom 30 还没有实现 `<dialog>` 的 showModal / close,
* 而 @stash/ui 的 Modal / Drawer 依赖它们(浏览器原生支持)。
* 这里补一个最小的开关实现:只维护 open 属性,焦点与 inert 由真实浏览器负责。
*/
const dialogPrototype = window.HTMLDialogElement?.prototype
if (dialogPrototype && typeof dialogPrototype.showModal !== 'function') {
dialogPrototype.showModal = function showModal(this: HTMLDialogElement) {
this.setAttribute('open', '')
}
dialogPrototype.close = function close(this: HTMLDialogElement) {
this.removeAttribute('open')
}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
+4
View File
@@ -3,6 +3,10 @@ import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
host: '127.0.0.1',
port: 5173,
+60
View File
@@ -0,0 +1,60 @@
"""TaskPilot 演示任务。
在 TaskPilot 里把「项目工作目录」指向本目录、解释器填 ``python.exe``、脚本填
``main.py``,后端会自动关联同级的 ``.venv`` 并以虚拟环境解释器运行。
常用参数:
--lines N 输出 N 行中文(默认 3)
--sleep N 执行前等待 N 秒,用于验证超时与取消
--fail 以退出码 3 结束,用于验证失败状态
--append PATH 把一行结果追加到指定文件,用于验证副作用
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description="TaskPilot python demo task")
parser.add_argument("--lines", type=int, default=3)
parser.add_argument("--sleep", type=float, default=0.0)
parser.add_argument("--fail", action="store_true")
parser.add_argument("--append", type=Path)
args = parser.parse_args()
started = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(f"[demo] 启动时间 {started}")
print(f"[demo] 解释器 {sys.executable}")
print(f"[demo] VIRTUAL_ENV {os.environ.get('VIRTUAL_ENV', '<未设置>')}")
print(f"[demo] 工作目录 {Path.cwd()}")
print(f"[demo] 参数 lines={args.lines} sleep={args.sleep} fail={args.fail}")
for index in range(args.lines):
print(f"[demo] 第 {index + 1} 行:中文输出与勾选 √")
if args.append is not None:
args.append.parent.mkdir(parents=True, exist_ok=True)
with args.append.open("a", encoding="utf-8") as handle:
handle.write(f"{started} ok\n")
print(f"[demo] 已追加写入 {args.append}")
if args.sleep > 0:
print(f"[demo] 开始等待 {args.sleep} 秒", flush=True)
time.sleep(args.sleep)
if args.fail:
print("[demo] 按要求返回失败", file=sys.stderr)
return 3
print("[demo] 完成")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
[project]
name = "taskpilot-python-demo"
version = "0.1.0"
description = "TaskPilot 演示任务:验证虚拟环境、中文输出与退出码"
requires-python = ">=3.10"
dependencies = []
+18
View File
@@ -7,6 +7,12 @@ if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::Ord
throw "Refusing to build outside release directory: $releaseRoot"
}
# 与 check.ps1 一致:临时目录放在仓库内,避免系统 %TEMP% 权限策略让 aws-lc-sys 的 cc 构建失败。
$scratch = Join-Path $projectRoot '.tmp'
[void](New-Item -ItemType Directory -Path $scratch -Force)
$env:TMP = $scratch
$env:TEMP = $scratch
function Assert-ExitCode {
param([string]$Step)
@@ -17,6 +23,12 @@ function Assert-ExitCode {
Push-Location $projectRoot
try {
# 前端依赖同级 stash-ui(file:../../stash-ui):发布包要求先有组件库产物
$stashUiDist = [System.IO.Path]::GetFullPath((Join-Path $projectRoot '..\stash-ui\dist\stash-ui.js'))
if (-not (Test-Path -LiteralPath $stashUiDist)) {
throw "Missing @stash/ui build output: $stashUiDist. Run 'npm --prefix ..\stash-ui install' and 'npm --prefix ..\stash-ui run build' first."
}
pnpm --dir frontend build
Assert-ExitCode 'Frontend build'
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
@@ -27,6 +39,8 @@ try {
}
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -Force)
# uv 管理页默认从这个目录找自己下载的 uv.exe
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'uv') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'data') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
@@ -35,6 +49,10 @@ try {
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
# 发布包不该继承工作区的低完整性标签:带着它启动的进程写不了 uv 缓存和用户文件。
& icacls $releaseRoot /setintegritylevel '(OI)(CI)M' /T | Out-Null
Assert-ExitCode 'Release integrity label'
Write-Host "Release package: $releaseRoot"
}
finally {
+8
View File
@@ -1,6 +1,7 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$scratch = Join-Path $projectRoot '.tmp'
function Assert-ExitCode {
param([string]$Step)
@@ -10,6 +11,12 @@ function Assert-ExitCode {
}
}
# 测试统一使用仓库内的临时目录:不依赖系统 %TEMP% 的权限策略,
# 也让清理、归档和排障都留在项目目录里。
[void](New-Item -ItemType Directory -Path $scratch -Force)
$env:TMP = $scratch
$env:TEMP = $scratch
Push-Location $projectRoot
try {
pnpm --dir frontend lint
@@ -31,4 +38,5 @@ try {
}
finally {
Pop-Location
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""重新生成界面字体(字体二进制不入库)。
用法:
uv run --with fonttools --with brotli tools/build-fonts.py
流程:从 lxgw-wenkai-webfont 的 CDN 下载 Regular / Bold 的全部分包 woff2 子集,
合并成单个字体后裁剪到 GB2312 + Latin + 中文标点,写入 frontend/public/fonts/。
生成的字体仅供本地构建与打包使用,不提交到仓库。
"""
from __future__ import annotations
import io
import re
import sys
import tempfile
import urllib.request
from pathlib import Path
try:
from fontTools.merge import Merger
from fontTools.subset import Options, Subsetter
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover - 由 uv --with 保证
sys.exit("缺少依赖:uv run --with fonttools --with brotli tools/build-fonts.py")
CDN = "https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/"
WEIGHTS = {
"Regular": "lxgwwenkai-regular.css",
"Bold": "lxgwwenkai-bold.css",
}
OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "fonts"
def fetch(url: str) -> bytes:
request = urllib.request.Request(
url, headers={"User-Agent": "taskpilot-font-builder"}
)
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def subset_urls(css_name: str) -> list[str]:
css = fetch(CDN + css_name).decode("utf-8")
# 形如:src: url('./files/lxgwwenkai-regular-subset-4.woff2')
matches = re.findall(r"url\(\s*['\"]?([^'\")]+\.woff2)['\"]?\s*\)", css)
urls = sorted({match.lstrip("./") for match in matches})
if not urls:
raise RuntimeError(f"未能从 {css_name} 解析出字体子集,CDN 格式可能已变更")
return urls
def build_codepoints() -> set[int]:
points: set[int] = set()
for start, end in (
(0x20, 0x7F), # ASCII
(0xA0, 0x100), # Latin-1
(0x2000, 0x2070), # 通用标点
(0x25A0, 0x2600), # 几何图形
(0x3000, 0x3040), # 中文标点
(0xFF00, 0xFFF0), # 全角字符
):
points.update(range(start, end))
points.update(
[0x00D7, 0x00F7, 0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717, 0x23F3]
)
# GB2312 汉字与符号行
for high in range(0xA1, 0xF8):
for low in range(0xA1, 0xFF):
try:
points.add(ord(bytes([high, low]).decode("gb2312")))
except UnicodeDecodeError:
continue
return points
def build(weight: str, css_name: str, points: set[int], workdir: Path) -> None:
urls = subset_urls(css_name)
print(f"[{weight}] 下载 {len(urls)} 个子集…")
paths: list[str] = []
for url in urls:
data = fetch(CDN + url)
font = TTFont(io.BytesIO(data))
target = workdir / Path(url).name.replace(".woff2", ".ttf")
font.save(target)
font.close()
paths.append(str(target))
print(f"[{weight}] 合并 {len(paths)} 个字体…")
merged = Merger().merge(paths)
options = Options()
options.flavor = "woff2"
options.hinting = False
options.layout_features = ["*"]
options.name_IDs = ["*"]
subsetter = Subsetter(options=options)
subsetter.populate(unicodes=points)
subsetter.subset(merged)
output = OUT_DIR / f"LXGWWenKai-{weight}.woff2"
merged.save(output)
print(f"[{weight}] 写出 {output} {output.stat().st_size / 1024 / 1024:.2f} MB")
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
points = build_codepoints()
print(f"目标字符集:{len(points)} 个码位")
with tempfile.TemporaryDirectory() as directory:
workdir = Path(directory)
for weight, css_name in WEIGHTS.items():
build(weight, css_name, points, workdir)
return 0
if __name__ == "__main__":
sys.exit(main())
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""TaskPilot 端到端自检(仅标准库)。
用法:
uv run tools/verify.py
uv run tools/verify.py --base-url http://127.0.0.1:3000 --keep
它会真实地:创建任务 → 立即运行 → 读回日志 → 校验中文输出与虚拟环境注入 →
校验调度与统计接口 → 删除任务(--keep 可保留)。
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
TASK_ID = "verify_python_demo"
@dataclass
class Check:
name: str
ok: bool
detail: str = ""
def render(self) -> str:
marker = "OK " if self.ok else "FAIL"
suffix = f" {self.detail}" if self.detail else ""
return f"[{marker}] {self.name}{suffix}"
@dataclass
class Api:
base_url: str
token: str | None = None
checks: list[Check] = field(default_factory=list)
def request(
self,
method: str,
path: str,
body: dict | None = None,
authenticated: bool = False,
) -> tuple[int, object]:
url = f"{self.base_url}{path}"
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if authenticated:
headers["X-TaskPilot-Token"] = self.token or ""
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read()
status = response.status
except urllib.error.HTTPError as error:
raw = error.read()
status = error.code
except urllib.error.URLError as error:
raise SystemExit(f"无法连接 {url}: {error.reason}") from error
if not raw:
return status, None
try:
return status, json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
return status, raw.decode("utf-8", errors="replace")
def check(self, name: str, ok: bool, detail: str = "") -> bool:
self.checks.append(Check(name, ok, detail))
return ok
def session(self) -> str:
status, body = self.request("GET", "/api/v1/session")
if status != 200 or not isinstance(body, dict):
raise SystemExit(f"获取会话令牌失败: HTTP {status}")
self.token = str(body["token"])
return self.token
def poll_run(self, run_id: int, timeout: float) -> dict:
deadline = time.monotonic() + timeout
terminal = {"succeeded", "failed", "timed_out", "cancelled", "skipped"}
while time.monotonic() < deadline:
status, body = self.request("GET", f"/api/v1/runs/{run_id}")
if status != 200 or not isinstance(body, dict):
raise SystemExit(f"读取运行记录失败: HTTP {status}")
if body.get("status") in terminal:
return body
time.sleep(0.2)
raise SystemExit(f"运行 {run_id} 在 {timeout} 秒内未结束")
def read_logs(self, run_id: int, timeout: float) -> str:
deadline = time.monotonic() + timeout
content = ""
offset = 0
while time.monotonic() < deadline:
status, body = self.request(
"GET", f"/api/v1/runs/{run_id}/logs?offset={offset}"
)
if status != 200 or not isinstance(body, dict):
raise SystemExit(f"读取运行日志失败: HTTP {status}")
content += str(body.get("content", ""))
offset = int(body.get("next_offset", offset))
if body.get("complete"):
return content
time.sleep(0.1)
return content
def main() -> int:
parser = argparse.ArgumentParser(description="TaskPilot end-to-end self check")
parser.add_argument("--base-url", default="http://127.0.0.1:3000")
parser.add_argument(
"--working-dir",
default=str(Path(__file__).resolve().parent.parent / "samples" / "python-task"),
help="包含 .venv 的演示任务目录",
)
parser.add_argument("--keep", action="store_true", help="检查完成后保留任务")
parser.add_argument("--timeout", type=float, default=60.0)
args = parser.parse_args()
working_dir = Path(args.working_dir).resolve()
api = Api(args.base_url.rstrip("/"))
# 1. 健康检查
status, health = api.request("GET", "/api/v1/health")
api.check(
"health endpoint",
status == 200 and isinstance(health, dict) and health.get("status") == "ok",
f"HTTP {status}",
)
# 2. 会话令牌
token = api.session()
api.check("session token", bool(token), f"{len(token)} chars")
# 3. 目录列举能看到演示目录下的 .venv
query = urllib.parse.urlencode({"path": str(working_dir)})
status, listing = api.request("GET", f"/api/v1/system/directories?{query}")
names = [entry["name"] for entry in (listing or {}).get("entries", [])]
api.check(
"directory listing sees .venv",
status == 200 and ".venv" in names,
f"entries={names}",
)
# 4. 虚拟环境探测识别出 .venv\Scripts\python.exe
status, probe = api.request(
"POST",
"/api/v1/python/venv",
{"working_dir": str(working_dir), "interpreter": "python.exe", "venv": None},
authenticated=True,
)
probe = probe if isinstance(probe, dict) else {}
display_path = str(probe.get("display_path") or "")
api.check(
"venv probe links the demo environment",
status == 200
and probe.get("status") == "linked"
and display_path.endswith(r"Scripts\python.exe"),
f"status={probe.get('status')} path={display_path}",
)
# 5. 创建任务(启用 → 自动注册调度)
payload = {
"id": TASK_ID,
"name": "Python 演示任务",
"cron": "0 0 3 * * *",
"timezone": "Asia/Shanghai",
"enabled": True,
"timeout_secs": 30,
"action": {
"type": "script",
"working_dir": str(working_dir),
"interpreter": "python.exe",
"script": "main.py",
"args": ["--lines", "2"],
"env": {},
},
}
api.request("DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True)
status, created = api.request(
"POST", "/api/v1/tasks", payload, authenticated=True
)
api.check("create task", status == 201, f"HTTP {status}")
# 6. 调度接口给出下次触发时间
status, schedule = api.request("GET", "/api/v1/schedule")
entries = schedule if isinstance(schedule, list) else []
entry = next((item for item in entries if item.get("task_id") == TASK_ID), None)
api.check(
"task is registered in the schedule",
status == 200 and entry is not None and bool(entry.get("next_run_at")),
f"next_run_at={(entry or {}).get('next_run_at')}",
)
# 7. 立即运行并等待结束
status, run = api.request(
"POST", f"/api/v1/tasks/{TASK_ID}/run", authenticated=True
)
run = run if isinstance(run, dict) else {}
run_id = int(run.get("id", 0))
api.check("run accepted", status == 202 and run_id > 0, f"run={run_id}")
finished = api.poll_run(run_id, args.timeout)
api.check(
"run succeeded",
finished.get("status") == "succeeded" and finished.get("exit_code") == 0,
f"status={finished.get('status')} exit={finished.get('exit_code')}",
)
# 8. 日志里能看到中文、虚拟环境注入与真实解释器路径
log = api.read_logs(run_id, args.timeout)
api.check("log contains Chinese output", "中文输出" in log)
api.check(
"log shows the venv interpreter",
".venv" in log.lower() or "Scripts" in log,
)
api.check(
"log shows VIRTUAL_ENV injection",
"VIRTUAL_ENV" in log and "<未设置>" not in log,
)
api.check("log has no replacement characters", "\ufffd" not in log)
# 9. 统计接口口径正确
status, stats = api.request("GET", "/api/v1/stats")
stats = stats if isinstance(stats, dict) else {}
api.check(
"stats endpoint reports the task",
status == 200 and int(stats.get("total", 0)) >= 1 and int(stats.get("running", -1)) == 0,
f"total={stats.get('total')} enabled={stats.get('enabled')} running={stats.get('running')}",
)
# 10. 清理
if args.keep:
api.check("cleanup skipped (--keep)", True)
else:
status, _ = api.request(
"DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True
)
api.check("delete task", status == 204, f"HTTP {status}")
failures = [check for check in api.checks if not check.ok]
print(f"\nTaskPilot self check against {args.base_url}\n")
for check in api.checks:
print(check.render())
print(f"\n{len(api.checks) - len(failures)}/{len(api.checks)} checks passed")
if failures:
print("\n失败的检查:")
for check in failures:
print(f" - {check.name}: {check.detail}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())