Files
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

665 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TaskPilot
TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。
- 单用户、单实例,只监听 `127.0.0.1`
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
- SQLite 持久化,重启后任务与历史仍在
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
---
## 目录
- [快速开始](#快速开始)
- [功能总览](#功能总览)
- [任务模型](#任务模型)
- [调度与执行语义](#调度与执行语义)
- [日志与编码](#日志与编码)
- [Python 与虚拟环境](#python-与虚拟环境)
- [Web 界面](#web-界面)
- [安全模型](#安全模型)
- [配置项](#配置项)
- [API 参考](#api-参考)
- [输入校验](#输入校验)
- [数据与文件布局](#数据与文件布局)
- [架构与模块](#架构与模块)
- [开发与测试](#开发与测试)
- [故障排查](#故障排查)
- [已知限制](#已知限制)
- [架构决策记录](#架构决策记录)
---
## 快速开始
### 方式一:打包版(推荐)
```powershell
.\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
```
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
```
自检会真实创建任务、立即运行、读回日志,校验:健康检查、令牌、目录列举、
虚拟环境探测、创建/调度注册、运行成功、中文日志、`VIRTUAL_ENV` 注入、
无替换字符、统计口径、删除任务。
```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
```
`--keep` 保留任务,`--working-dir` 可换成任意含 `.venv` 的目录。
---
## 功能总览
| 分组 | 能力 |
| --- | --- |
| 任务管理 | 增删改查、启用/停用、按 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": {} }
```
### 运行状态机
```text
queued ──► running ──► succeeded / failed / timed_out / cancelled
└────────────────► skipped (同一任务已有运行实例)
```
终态不可再取消;进程被强杀后,下次启动会把遗留的 `queued`/`running`
统一标记为 `cancelled`,并写入「TaskPilot 进程重启,运行被中断」。
---
## 调度与执行语义
- **三类任务都参与调度**。启用即注册,停用即取消;后端重启后从数据库恢复注册。
- 每个任务一个独立 Tokio 循环:计算下次触发 → 等待 → 调用执行器。
- **重叠触发**:同一任务已有运行实例时,新触发记为 `skipped`,不会并发。
- **没有全局并发上限**:10 个任务同时到点会各自启动一个进程。
- **取消**:通过 `CancellationToken` + `TerminateJobObject`,5 秒内未退出则 `kill`。
- **超时**:与取消同一路径,状态记为 `timed_out`。
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
---
## 日志与编码
文件布局:`logs/{task_id}/{run_id}.log`(UTF-8 写入)
- 每次运行 **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]`。
---
## Python 与虚拟环境
编辑器和执行器共用同一份解析逻辑:
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 时丢弃缓存并重试一次 |
> 这是**本地单用户工具**的威胁模型:本机其他进程仍可调用只读接口。不要把端口暴露到局域网。
---
## 配置项
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `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/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` | 手动立即执行(`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` |
### 示例
创建任务:
```http
POST /api/v1/tasks
X-TaskPilot-Token: <session token>
Content-Type: application/json
{
"id": "sync_data",
"name": "数据同步",
"description": "把上游数据同步到本地仓库,失败时记录日志",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
"timeout_secs": 300,
"action": {
"type": "script",
"working_dir": "D:\\Projects\\sync",
"interpreter": "python.exe",
"script": "main.py",
"args": ["--mode", "full"],
"env": {}
}
}
```
统计数据:
```json
{ "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)。
脚本会把 `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
```
### 契约与类型
```powershell
.\scripts\generate-api.ps1 # 更新 contracts/openapi.json 与 frontend/src/api/generated.ts
```
生成文件不要手工修改。
### 界面字体(可选)
字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面:
```powershell
uv run --with fonttools --with brotli tools/build-fonts.py
```
脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点,
输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行,
只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。
### 测试构成
| 层 | 覆盖 |
| --- | --- |
| 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)
- [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)
## 开发计划
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。