CNWei 942f8de3b7 docs: 重写 README 并加入 uv 示例与端到端自检
- README 重排为快速开始、执行语义、输入校验、API 参考与故障排查
- 新增 samples/python-task:uv 管理的演示任务
- 新增 tools/verify.py:14 项 API 全链路自检
2026-10-26 00:38:16 +08:00
2026-10-17 16:17:59 +08:00

TaskPilot

TaskPilot 是一个面向 Windows 单机 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。

  • 单用户、单实例,只监听 127.0.0.1
  • 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
  • SQLite 持久化,重启后任务与历史仍在
  • 运行日志实时推送(SSE),断线自动回退轮询
  • 前端零外部依赖:字体、样式、脚本全部随包自带,断网可用

根目录的 task_pilot.html 只是最初的单文件视觉原型,正式入口是 React 应用。


目录


快速开始

方式一:打包版(推荐)

.\scripts\build-release.ps1     # 构建前端 + Release 可执行文件 + 可分发目录
.\release\TaskPilot\start.ps1   # 启动并访问 http://127.0.0.1:3000

start.ps1 -OpenBrowser 会自动打开浏览器。产物目录结构:

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

方式二:本地开发

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 的代理目标;打包版同源,不需要改前端。

方式三:用 uv 跑官方示例与自检

仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:

# 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 注入、 无替换字符、统计口径、删除任务。

[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、注入 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

// 脚本
{ "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": {} }

运行状态机

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 只在"运行结束且读到文件末尾"时为真。
  • 输出编码:默认 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,否则显式配置在保存时被拒绝。

命中虚拟环境后注入:

变量 行为
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 时丢弃缓存并重试一次

这是本地单用户工具的威胁模型:本机其他进程仍可调用只读接口。不要把端口暴露到局域网。


配置项

变量 默认值 说明
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 探测工作目录会关联的虚拟环境
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 创建任务
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

示例

创建任务:

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": {}
  }
}

统计数据:

{ "total": 12, "enabled": 9, "running": 1, "script": 7, "http": 3, "process": 2 }

调度信息:

[ { "task_id": "sync_data", "next_run_at": "2026-10-26T18:00:00Z" } ]

统一错误结构:

{ "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 不得超过文件长度。

不在保存时校验:工作目录是否存在、脚本文件是否存在、解释器是否存在(虚拟环境除外)。 这些在执行时才暴露,属于日志里的运行错误。


数据与文件布局

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。


架构与模块

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(跑示例任务与自检脚本)

完整质量门

.\scripts\check.ps1

依次执行:前端 lint → 类型检查 → Vitest → 生产构建 → cargo fmt --check → clippy -D warnings → cargo test → Playwright(真实后端 + Vite + Chrome)。

脚本会把 TMP/TEMP 指向仓库内 .tmp/,让测试不依赖系统临时目录的权限策略, 结束时自动清理。

单独命令

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

契约与类型

.\scripts\generate-api.ps1   # 更新 contracts/openapi.json 与 frontend/src/api/generated.ts

生成文件不要手工修改。

测试构成

层 覆盖
Rust 单元(52) Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举
Rust 集成(30) 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账
前端 Vitest(13) 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间
Playwright(4) 真实任务执行与日志、编辑/停用/删除、窄屏、完全离线渲染(拦截所有外部请求)
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 环境不会自动激活,需要显式填写解释器路径。
  • 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
  • 未安装为 Windows 服务;关闭启动脚本后调度停止。
  • 错过的调度不会补跑。

架构决策记录

开发计划

完整架构、数据模型、阶段任务与验收标准见 PLAN.md。六个阶段均已完成,当前处于维护与迭代阶段。

S
Description
No description provided
Readme
946 KiB
Languages
Rust 46.2%
TypeScript 40.1%
HTML 6.2%
CSS 4.5%
Python 1.8%
Other 1.2%