# TaskPilot TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。 - 单用户、单实例,只监听 `127.0.0.1` - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - SQLite 持久化,重启后任务与历史仍在 - 运行日志实时推送(SSE),断线自动回退轮询 - 前端零外部依赖:字体、样式、脚本全部随包自带,**断网可用** > 根目录的 `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 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 管理的演示任务和一个端到端自检脚本: ```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`、注入 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": {} } ``` ### 运行状态机 ```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` 只在"运行结束且读到文件末尾"时为真。 - **输出编码**:默认 `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` | ### 示例 创建任务: ```http POST /api/v1/tasks X-TaskPilot-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` / `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 .\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 ``` 生成文件不要手工修改。 ### 测试构成 | 层 | 覆盖 | | --- | --- | | 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 服务;关闭启动脚本后调度停止。 - 错过的调度不会补跑。 --- ## 架构决策记录 - [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) ## 开发计划 完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。