Files
task_pilot/README.md
T
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

596 lines
28 KiB
Markdown
Raw 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/ # 前端静态资源(字体可选)
├── 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 # 终端一: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` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
| 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
---
## 任务模型
### 字段
| 字段 | 说明 |
| --- | --- |
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
| `name` | 展示名,1–120 字符 |
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
| `enabled` | 是否注册调度 |
| `timeout_secs` | 1–86400;超时终止整个进程树 |
| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) |
| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** |
| `action` | 见下 |
### 三类 Action
```jsonc
// 脚本
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
"interpreter": "python.exe", "script": "main.py",
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
// 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`,否则显式配置在保存时被拒绝。
**没有虚拟环境时**:
- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`:
编辑器会显示「用 uv 创建环境」按钮,点击后
`pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。
已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像
(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
Python 任务不再静默回退到系统解释器。
- 解释器字段是可选的:留空表示 Python,运行时会自动使用虚拟环境里的 `python.exe`;
Node / Shell 等非 Python 脚本再填写具体可执行程序。
命中虚拟环境后注入:
| 变量 | 行为 |
| --- | --- |
| `PATH` | 虚拟环境 `Scripts` 目录置前,保留用户 PATH,按大小写不敏感去重 |
| `VIRTUAL_ENV` / `VIRTUAL_ENV_PROMPT` | 指向虚拟环境根目录 |
| `PYTHONUNBUFFERED` | 强制 `1`(实时日志依赖) |
| `PYTHONIOENCODING` | 默认 `utf-8`,用户显式设置优先;避免 GBK 控制台下打印 emoji/生僻字符直接崩溃 |
| `PYTHONHOME` / `PYTHONPATH` | 虚拟环境模式下清理继承的污染值 |
编辑器会实时显示 `● 已关联虚拟环境: .venv\Scripts\python.exe`,并同步更新调用预览。
---
## Web 界面
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览;
探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。
解释器字段可选,留空自动使用虚拟环境 Python。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
(任务自身配置的环境变量优先),不会自动安装依赖。
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、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=` | 列盘符或子文件夹(目录浏览器) |
| `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` | 设为默认(同一时间仅一个默认,并自动启用) |
### 任务与运行
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `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": "数据同步",
"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 |
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
| `action.working_dir` / `script` | 非空 |
| `action.interpreter` | 可空;留空时服务端默认成 `python.exe`,Python 任务会用虚拟环境解释器 |
| `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)
logs/{task_id}/{run_id}.log # UTF-8 运行日志
.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`。
---
## 架构与模块
```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/编码注入 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
前端:
| 模块 | 职责 |
| --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 |
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
| `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 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(38) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建 |
| 前端 Vitest(30) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、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` 到可写目录 |
---
## 已知限制
- 自动重试尚未实现:`retry_policy` 只校验与存储。
- 并发策略只有 `skip`;没有全局并发上限与队列。
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
- Conda 环境不会自动激活,需要显式填写解释器路径。
- 用 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)
## 开发计划
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。