docs: 重写 README 并加入 uv 示例与端到端自检
- README 重排为快速开始、执行语义、输入校验、API 参考与故障排查 - 新增 samples/python-task:uv 管理的演示任务 - 新增 tools/verify.py:14 项 API 全链路自检
This commit is contained in:
@@ -1,216 +1,309 @@
|
|||||||
# TaskPilot
|
# 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),断线自动回退轮询
|
||||||
|
- 前端零外部依赖:字体、样式、脚本全部随包自带,**断网可用**
|
||||||
|
|
||||||
仓库根目录的 `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,并支持创建、查询、更新和删除。
|
- [Python 与虚拟环境](#python-与虚拟环境)
|
||||||
- 任务列表支持名称/ID 搜索、启用状态筛选、Action 类型筛选和分页。
|
- [Web 界面](#web-界面)
|
||||||
- 脚本、HTTP 和二进制进程任务具有独立的类型化载荷与后端校验。
|
- [安全模型](#安全模型)
|
||||||
- 启用的脚本任务由 Tokio 调度循环自动执行,并支持启动后恢复调度。
|
- [配置项](#配置项)
|
||||||
- 后端重启后,已启用任务仍可持久化并重新注册。
|
- [API 参考](#api-参考)
|
||||||
- 支持手动立即运行,重复触发时记录为 `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 打包和一键启动脚本。
|
|
||||||
- 前端与后端均具备基础测试和统一质量检查命令。
|
|
||||||
|
|
||||||
## 技术栈
|
---
|
||||||
|
|
||||||
### 前端
|
## 快速开始
|
||||||
|
|
||||||
- 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
|
```powershell
|
||||||
volta --version
|
.\scripts\build-release.ps1 # 构建前端 + Release 可执行文件 + 可分发目录
|
||||||
node --version
|
.\release\TaskPilot\start.ps1 # 启动并访问 http://127.0.0.1:3000
|
||||||
pnpm --version
|
|
||||||
rustc --version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 本地开发
|
`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
|
```powershell
|
||||||
pnpm --dir frontend install
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
终端一,启动 Rust API:
|
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
|
||||||
|
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
|
||||||
|
|
||||||
|
### 方式三:用 uv 跑官方示例与自检
|
||||||
|
|
||||||
|
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# 1) 在示例目录创建虚拟环境(Python 3.11)
|
||||||
|
cd samples\python-task
|
||||||
|
uv venv
|
||||||
|
cd ..\..
|
||||||
|
|
||||||
|
# 2) 启动一个临时后端(任意终端)
|
||||||
cargo run --manifest-path backend/Cargo.toml
|
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
|
```text
|
||||||
pnpm --dir frontend dev
|
[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`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 |
|
||||||
|
| 界面 | 三类动态表单、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": {} }
|
||||||
```
|
```
|
||||||
|
|
||||||
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
|
### 运行状态机
|
||||||
|
|
||||||
## Windows Release
|
```text
|
||||||
|
queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||||
构建前端生产资源、Rust Release 可执行文件和可分发目录:
|
└────────────────► skipped (同一任务已有运行实例)
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\build-release.ps1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
产物位于 `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` 只在"运行结束且读到文件末尾"时为真。
|
||||||
|
- **输出编码**:默认 `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`,否则显式配置在保存时被拒绝。
|
||||||
|
|
||||||
|
命中虚拟环境后注入:
|
||||||
|
|
||||||
|
| 变量 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PATH` | 虚拟环境 `Scripts` 目录置前,保留用户 PATH,按大小写不敏感去重 |
|
||||||
|
| `VIRTUAL_ENV` / `VIRTUAL_ENV_PROMPT` | 指向虚拟环境根目录 |
|
||||||
|
| `PYTHONUNBUFFERED` | 强制 `1`(实时日志依赖) |
|
||||||
|
| `PYTHONIOENCODING` | 默认 `utf-8`,用户显式设置优先;避免 GBK 控制台下打印 emoji/生僻字符直接崩溃 |
|
||||||
|
| `PYTHONHOME` / `PYTHONPATH` | 虚拟环境模式下清理继承的污染值 |
|
||||||
|
|
||||||
|
编辑器会实时显示 `● 已关联虚拟环境: .venv\Scripts\python.exe`,并同步更新调用预览。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web 界面
|
||||||
|
|
||||||
|
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||||
|
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
|
||||||
|
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
||||||
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||||
|
- **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
|
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动),字体 `LXGW WenKai` +
|
||||||
|
`Outfit-Medium` 全部本地托管,断网仍可完整渲染。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全模型
|
||||||
|
|
||||||
|
| 边界 | 实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 监听地址 | 固定 `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` | 后端健康检查 |
|
| `TASK_PILOT_PORT` | `3000` | 监听端口(地址固定回环) |
|
||||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
| `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 文档 |
|
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||||
| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 |
|
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 |
|
||||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
|
||||||
|
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||||
|
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
||||||
|
|
||||||
|
### 任务与运行
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/tasks` | 任务列表(`q`、`enabled`、`action_type`、`page`、`page_size`) |
|
||||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
| `GET` | `/api/v1/tasks/{id}` | 任务详情 |
|
||||||
| `PUT` | `/api/v1/tasks/{id}` | 完整更新任务 |
|
| `PUT` | `/api/v1/tasks/{id}` | 完整更新 |
|
||||||
| `DELETE` | `/api/v1/tasks/{id}` | 删除任务 |
|
| `DELETE` | `/api/v1/tasks/{id}` | 删除(级联删除运行记录) |
|
||||||
| `POST` | `/api/v1/tasks/{id}/enable` | 启用任务并注册调度 |
|
| `POST` | `/api/v1/tasks/{id}/enable` | 启用并注册调度 |
|
||||||
| `POST` | `/api/v1/tasks/{id}/disable` | 停用任务并取消调度 |
|
| `POST` | `/api/v1/tasks/{id}/disable` | 停用并取消调度 |
|
||||||
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行 |
|
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行(`202`) |
|
||||||
| `GET` | `/api/v1/tasks/{id}/runs` | 查询任务运行历史 |
|
| `GET` | `/api/v1/tasks/{id}/runs` | 运行历史(`status`、`trigger`、分页) |
|
||||||
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行状态 |
|
| `GET` | `/api/v1/runs/{run_id}` | 单次运行详情 |
|
||||||
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量读取运行日志 |
|
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量日志(`content`、`next_offset`、`complete`) |
|
||||||
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整运行日志 |
|
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整日志 |
|
||||||
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消正在运行的任务 |
|
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消运行 |
|
||||||
| `GET` | `/api/v1/events` | SSE 运行状态和日志事件 |
|
| `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",
|
"id": "sync_data",
|
||||||
"name": "数据同步",
|
"name": "数据同步",
|
||||||
@@ -218,157 +311,212 @@ $env:TASK_PILOT_LOG_ENCODING = "auto"
|
|||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"timeout_secs": 300,
|
"timeout_secs": 300,
|
||||||
"retry_policy": {
|
|
||||||
"max_attempts": 0,
|
|
||||||
"delay_secs": 0
|
|
||||||
},
|
|
||||||
"action": {
|
"action": {
|
||||||
"type": "script",
|
"type": "script",
|
||||||
"working_dir": "D:\\Projects\\sync",
|
"working_dir": "D:\\Projects\\sync",
|
||||||
"interpreter": "python.exe",
|
"interpreter": "python.exe",
|
||||||
"script": "main.py",
|
"script": "main.py",
|
||||||
"args": ["--mode", "full"],
|
"args": ["--mode", "full"],
|
||||||
"env": {
|
"env": {}
|
||||||
"PYTHONUNBUFFERED": "1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
校验失败和资源不存在时统一返回结构化错误:
|
统计数据:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "total": 12, "enabled": 9, "running": 1, "script": 7, "http": 3, "process": 2 }
|
||||||
"code": "VALIDATION_ERROR",
|
|
||||||
"message": "Cron 表达式无效",
|
|
||||||
"field": "cron",
|
|
||||||
"details": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发命令
|
调度信息:
|
||||||
|
|
||||||
### 完整检查
|
```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` / `interpreter` / `script` | 非空 |
|
||||||
|
| `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/编码注入 |
|
||||||
|
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
|
||||||
|
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
||||||
|
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
||||||
|
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||||
|
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
||||||
|
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
||||||
|
| `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
|
```powershell
|
||||||
.\scripts\check.ps1
|
.\scripts\check.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
该脚本依次运行:
|
依次执行:前端 lint → 类型检查 → Vitest → 生产构建 → `cargo fmt --check` →
|
||||||
|
`clippy -D warnings` → `cargo test` → Playwright(真实后端 + Vite + Chrome)。
|
||||||
|
|
||||||
- 前端 lint
|
脚本会把 `TMP`/`TEMP` 指向仓库内 `.tmp/`,让测试不依赖系统临时目录的权限策略,
|
||||||
- TypeScript 类型检查
|
结束时自动清理。
|
||||||
- Vitest
|
|
||||||
- 前端生产构建
|
|
||||||
- Rust 格式检查
|
|
||||||
- Rust Clippy
|
|
||||||
- Rust 单元和集成测试
|
|
||||||
- Playwright 浏览器端到端测试
|
|
||||||
|
|
||||||
### 浏览器端到端测试
|
### 单独命令
|
||||||
|
|
||||||
```powershell
|
```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
|
pnpm --dir frontend test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
|
### 契约与类型
|
||||||
|
|
||||||
### 生成接口契约和类型
|
|
||||||
|
|
||||||
修改后端 API DTO 或路由后执行:
|
|
||||||
|
|
||||||
```powershell
|
```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`
|
|
||||||
|
|
||||||
生成文件不应手工修改。
|
| 层 | 覆盖 |
|
||||||
|
| --- | --- |
|
||||||
|
| Rust 单元(52) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举 |
|
||||||
|
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
|
||||||
|
| 前端 Vitest(13) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间 |
|
||||||
|
| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**完全离线渲染**(拦截所有外部请求) |
|
||||||
|
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||||
|
|
||||||
### 单独检查
|
---
|
||||||
|
|
||||||
```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
|
| `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` 到可写目录 |
|
||||||
|
|
||||||
## 项目结构
|
---
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
架构决策记录:
|
- 自动重试尚未实现:`retry_policy` 只校验与存储。
|
||||||
|
- 并发策略只有 `skip`;没有全局并发上限与队列。
|
||||||
|
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||||
|
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||||
|
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||||
|
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||||
|
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||||
|
- 错过的调度不会补跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构决策记录
|
||||||
|
|
||||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||||
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
||||||
- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.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)
|
||||||
|
|
||||||
- 重试策略字段已持久化,但自动重试尚未实现。
|
|
||||||
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
|
|
||||||
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
|
|
||||||
- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。
|
|
||||||
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
|
|
||||||
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
|
|
||||||
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
|
|
||||||
|
|
||||||
## 开发计划
|
## 开发计划
|
||||||
|
|
||||||
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
|
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。
|
||||||
|
|||||||
+13
-4
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## 数据目录
|
## 数据目录
|
||||||
|
|
||||||
- SQLite 数据库:`data/task_pilot.db`
|
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
|
||||||
- 运行日志:`logs/{task_id}/{run_id}.log`
|
- 运行日志:`logs/{task_id}/{run_id}.log`
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
@@ -33,16 +33,25 @@ $env:TASK_PILOT_STATIC_DIR = "web"
|
|||||||
|
|
||||||
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
|
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
|
||||||
|
|
||||||
|
单次运行日志上限 5 MiB,单行上限 256 KiB;日志按保留期与总容量清理,正在写入的文件不会被删除。
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
- 服务只监听 `127.0.0.1`。
|
- 服务只监听 `127.0.0.1`。
|
||||||
- 浏览器跨站 Origin 会被拒绝。
|
- 浏览器跨站 Origin 会被拒绝。
|
||||||
- 写操作需要运行期随机管理令牌。
|
- 写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||||
- 管理令牌在进程重启后自动更换。
|
- 请求体上限 2 MiB。
|
||||||
|
|
||||||
|
## 离线使用
|
||||||
|
|
||||||
|
前端资源、字体(`LXGW WenKai` / `Outfit-Medium`)与所有脚本都随包自带,不需要联网即可打开界面与执行任务。
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
|
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
|
||||||
- 页面无法打开时,确认 `web/index.html` 存在。
|
- 页面无法打开时,确认 `web/index.html` 存在。
|
||||||
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
|
- 任务未执行时,确认任务已启用、Cron 正确,并在任务表的「调度周期」列查看下次触发时间。
|
||||||
|
- PowerShell 脚本任务报「不是数字签名」时,是执行策略拦截未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录执行 `Unblock-File`。
|
||||||
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
|
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
|
||||||
|
- 日志里出现 `����`:改用 UTF-8 输出,或设置 `TASK_PILOT_LOG_ENCODING=gbk`。
|
||||||
|
- 进程被强杀后,遗留的运行会在下次启动时标记为「已取消(进程重启中断)」,不是数据损坏。
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[project]
|
||||||
|
name = "taskpilot-python-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TaskPilot 演示任务:验证虚拟环境、中文输出与退出码"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = []
|
||||||
+266
@@ -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())
|
||||||
Reference in New Issue
Block a user