feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Rust
/target/
/backend/target/
# Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/coverage/
/frontend/playwright-report/
/frontend/test-results/
/.pnpm-store/
# Local configuration and runtime data
.env
.env.*
!.env.example
/data/
/logs/
/.e2e-data/
/release/
# Editors and operating system files
/.idea/
.vscode/
.DS_Store
Thumbs.db
+464
View File
@@ -0,0 +1,464 @@
# TaskPilot 开发计划
> 更新时间:2026-10-18
> 目标后端:Rust
> 目标前端:React
> 首要平台:Windows
> 当前阶段:Phase 6 已完成,项目进入维护与迭代
## 1. 项目目标
将现有的单文件 HTML 原型升级为一个真实可用的本地定时任务管理平台,满足以下核心场景:
- 创建、编辑、启用、停用和删除定时任务。
- 使用 Cron 表达式按计划自动执行任务,也可以手动立即执行。
- 支持脚本、HTTP 请求和 Windows 二进制进程三类任务。
- 实时展示任务状态、下次执行时间和每次运行结果。
- 查看真实 stdout、stderr、退出码和耗时。
- 任务配置和运行历史在重启后仍然保留。
- 超时或取消时可靠终止目标进程及其子进程。
- React 前端成为真实 API 的使用者,不再用静态数据或 `alert` 模拟成功。
### 首版范围
- Windows 单机部署。
- 单用户、本地运行,默认只监听 `127.0.0.1`。
- 单后端实例。
- SQLite 持久化。
- 同一任务默认不并发执行,重叠触发时跳过并记录原因。
- 日志按运行实例保存,并配置容量和保留期限。
### 暂不纳入首版
- 多租户、登录和远程用户管理。
- 多机分布式调度。
- DAG、任务依赖和分布式队列。
- 集群高可用。
- 跨平台进程树管理。
- 移动端专用应用。
这些能力在单机版本稳定后再评估,避免首版范围过大。
## 2. 当前基线
仓库目前只有 `task_pilot.html` 和文档,没有前后端工程。现有页面具备完整视觉原型,但运行、删除、保存和日志均为演示行为。
已确认的本地工具链:
| 工具 | 版本 | 来源 |
| --- | --- | --- |
| Volta | 2.0.2 | 本地安装 |
| Node.js | 24.12.0 | Volta |
| npm | 11.6.2 | Volta |
| pnpm | 11.20.0 | Volta |
| Rust | 1.95.0 | `stable-x86_64-pc-windows-msvc` |
| Cargo | 1.95.0 | 本地 Rust 工具链 |
开发时使用 pnpm,并通过 `package.json` 的 `volta` 字段固定 Node.js 和 pnpm 版本。Rust 使用 `rust-toolchain.toml` 固定 MSVC 工具链。
## 3. 技术方案
### 3.1 前端
- React + TypeScript + Vite。
- pnpm 作为包管理器,Volta 固定工具版本。
- React Router 管理任务列表和任务详情路由。
- TanStack Query 管理服务端状态、缓存、刷新和错误状态。
- React Hook Form + Zod 管理表单和前端校验。
- 原生 `fetch` 封装类型化 API 客户端。
- 沿用现有 CSS 变量和视觉语言,组件样式使用 CSS Modules,不先引入大型 UI 框架。
- Vitest + React Testing Library 进行单元和组件测试。
- Playwright 进行关键业务流程端到端测试。
### 3.2 后端
- Rust + Axum + Tokio。
- Serde 定义 API 和持久化 DTO。
- SQLx + SQLite 负责迁移、查询和事务。
- `tracing` + `tracing-subscriber` 输出结构化运行日志。
- `reqwest` 执行 HTTP 任务。
- Windows Job Object 管理子进程树,实现超时和取消时的可靠终止。
- SSE 或轮询先用于运行状态与日志更新,后续再评估 WebSocket。
- Cron 引擎在 Phase 1 通过小型 POC 选定,必须支持动态增删任务、秒级 Cron、时区和下次执行时间计算。
初步候选为 `tokio-cron-scheduler`。进入正式实现前需要验证其维护状态、Cron 语义和动态任务能力;如果不能满足要求,则改用 Cron 解析 crate 与自建 Tokio 调度循环。
### 3.3 接口契约
- 后端 DTO 是 API 数据的唯一来源。
- 使用 OpenAPI 生成接口描述,并提交稳定快照供前端生成 TypeScript 类型。
- 前端不得手写与后端重复的 DTO 类型。
- 接口变更时,必须同时更新 OpenAPI 快照、生成类型和契约测试。
### 3.4 总体结构
```text
React SPA
|
| REST /api/v1 + SSE
v
Axum HTTP API
|
+--> Task Service --> SQLite
|
+--> Scheduler
|
+--> Script Executor
+--> HTTP Executor
+--> Process Executor
|
+--> Windows Job Object
+--> Log Capture
```
计划目录结构:
```text
task_pilot/
├── backend/
│ ├── Cargo.toml
│ ├── migrations/
│ ├── src/
│ └── tests/
├── frontend/
│ ├── package.json
│ ├── src/
│ └── tests/
├── contracts/
│ └── openapi.json
├── docs/
│ └── decisions/
├── task_pilot.html # 迁移完成前保留作视觉参考
├── PLAN.md
├── README.md
└── rust-toolchain.toml
```
## 4. 核心数据模型
### 4.1 Task
```text
Task
├── id 用户可读的唯一任务标识
├── name 显示名称
├── cron 5 位或 6 位 Cron 表达式
├── timezone 默认使用系统时区,可选择 IANA 时区
├── enabled 是否参与自动调度
├── timeout_secs 单次执行超时
├── concurrency_policy 首版固定为 skip
├── retry_policy 首版允许 0 次重试,结构上预留
├── action 带 type 标签的任务动作
├── created_at
└── updated_at
```
### 4.2 Action
```text
Action
├── script
│ ├── working_dir
│ ├── interpreter
│ ├── script
│ ├── args[]
│ └── env{}
├── http
│ ├── url
│ ├── method
│ ├── headers{}
│ ├── body
│ └── expected_status[]
└── process
├── executable
├── working_dir
├── args[]
└── env{}
```
- 参数必须使用结构化数组,禁止默认经过 Shell 拼接。
- 环境变量需要区分普通值和敏感值;敏感值返回前端时使用遮罩。
- 路径、URL、超时、Cron 和任务类型必须执行后端校验。
### 4.3 TaskRun
```text
TaskRun
├── id
├── task_id
├── trigger schedule / manual / retry
├── status queued / running / succeeded / failed / timed_out / cancelled / skipped
├── started_at
├── finished_at
├── duration_ms
├── exit_code
├── error_message
└── logs
```
## 5. API 草案
所有业务接口使用 `/api/v1` 前缀。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/v1/health` | 健康检查 |
| `GET` | `/api/v1/tasks` | 查询任务,支持搜索、筛选和分页 |
| `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` | 手动执行 |
| `GET` | `/api/v1/tasks/{id}/runs` | 查询运行历史 |
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行结果 |
| `GET` | `/api/v1/runs/{run_id}/logs` | 分页或增量读取日志 |
| `GET` | `/api/v1/events` | SSE 状态与日志事件 |
统一错误结构至少包含:
```json
{
"code": "TASK_CRON_INVALID",
"message": "Cron expression is invalid",
"field": "cron",
"details": {}
}
```
## 6. 里程碑
### Phase 0:工程基线
状态:已完成
状态:进行中
- [x] 盘点现有 HTML 原型和功能。
- [x] 生成项目 README。
- [x] 确认 Volta、Node.js、pnpm 和 Rust 工具链。
- [x] 制定本开发计划。
- [x] 为 Cron 引擎和静态资源托管方式编写 ADR。
验收:计划中的技术边界明确,后续任务有清晰顺序。
### Phase 1:工程脚手架与契约
状态:已完成(2026-10-18)
- [x] 创建 `frontend/` React + TypeScript + Vite 工程。
- [x] 通过 Volta 固定 Node.js 24.12.0 和 pnpm 11.20.0。
- [x] 创建 `backend/` Axum 工程并固定 Rust 1.95.0。
- [x] 配置 Vite 开发代理到 Rust API。
- [x] 提供 `/api/v1/health`。
- [x] 配置 Cargo fmt、clippy、test,以及前端 lint、typecheck、test、build。
- [x] 添加 OpenAPI 生成与前端类型生成脚本。
- [x] 确定 Cron 引擎依赖并完成 POC。
- [x] 保留旧 HTML,作为迁移期间的视觉参考。
验收:
- `pnpm install`、`pnpm build`、`cargo test` 和 `cargo clippy` 可执行。
- 前端开发服务器能够通过代理访问 Rust 健康检查。
- OpenAPI 快照可以重复生成,前后端对接口类型没有手工漂移。
### Phase 2:任务 CRUD 与持久化
状态:已完成(2026-10-18)
- [x] 创建 SQLite 迁移和任务表。
- [x] 实现领域模型、仓储层和服务层。
- [x] 实现任务列表、详情、创建、更新、删除接口。
- [x] 实现 Cron、时区、超时、路径和 Action 类型校验。
- [x] 实现统一 API 错误格式。
- [x] 增加任务列表搜索、筛选和分页参数。
- [x] 为 CRUD、校验和持久化编写后端测试。
说明:独立启停接口调整到 Phase 3,与调度器注册和取消同时实现,避免产生只改数据库但不更新调度的中间状态。
验收:
- 创建三类任务后重启进程,数据仍然存在。
- 非法 Cron、空路径、无效 URL 和错误 Action 字段被后端拒绝。
- 删除不存在的任务返回明确错误,而不是成功提示。
### Phase 3:调度器与脚本任务闭环
状态:已完成(2026-10-18)
- [x] 启动时从 SQLite 加载启用任务并注册调度。
- [x] 支持创建、更新、启停和删除任务时动态刷新调度。
- [x] 实现手动立即执行。
- [x] 实现脚本任务执行,使用结构化参数,不经过 Shell。
- [x] 捕获 stdout、stderr、退出码和耗时。
- [x] 实现同一任务并发触发时跳过。
- [x] 通过 Windows Job Object 实现超时和取消时的进程树终止。
- [x] 记录每次运行状态并处理进程异常退出。
- [x] 引入确定性时间计算测试和实时秒级调度测试,验证调度逻辑。
验收:
- 创建一个每秒执行的临时脚本任务后,自动运行并生成可查看日志。
- 超时脚本及其子进程被终止,运行状态为 `timed_out`。
- 后端重启后调度恢复,错过的历史运行不会批量重复执行。
- 前端尚未接入前,可通过 API 完整验证脚本任务链路。
### Phase 4:React 前端迁移与垂直切片
状态:已完成(2026-10-18)
- [x] 将现有页面拆分为布局、指标卡、任务表格、任务编辑器和日志查看器。
- [x] 接入真实任务列表、详情、创建、更新、删除和启停 API。
- [x] 接入手动运行 API,并展示 `queued/running/succeeded/failed` 状态。
- [x] 使用表单校验替代浏览器无效的 `required` 假象。
- [x] 根据 Action 类型提交完整、不同的载荷。
- [x] 实现加载、空数据、失败、重试和保存中状态。
- [x] 日志页面先使用轮询读取真实运行日志。
- [x] 移除伪造的成功提示和静态任务数据。
验收:
- 页面中的每个 CRUD、启停和手动运行操作都对应真实 API。
- 刷新页面后任务状态与 SQLite 一致。
- HTTP 和进程任务不会在尚未实现执行器时被错误提交为脚本任务。
- 表单错误能定位到具体字段,Cron 校验错误来自前后端一致结果。
### Phase 5:HTTP、进程任务与实时日志
状态:已完成(2026-10-18)
- [x] 实现 HTTP Executor,支持 GET/POST、Header、Body 和期望状态码。
- [x] 实现二进制进程 Executor。
- [x] 将 SSE 接入任务状态和日志增量更新,轮询作为降级方案。
- [x] 支持取消正在运行的任务。
- [x] 增加运行历史列表、状态/触发筛选、退出码、错误摘要和日志下载。
- [x] 实现日志大小上限、保留期限和清理策略。
- [x] 对敏感环境变量和 Header 值进行输入遮罩,并在日志中脱敏。
验收:
- 三类任务均可从 React 页面创建、执行和查看结果。
- 运行中日志可以持续更新,完成后状态正确收敛。
- HTTP 非预期状态码和进程非零退出码能明确标记失败。
- 取消或超时不会遗留目标进程树。
### Phase 6:质量、安全与交付
状态:已完成(2026-10-18)
- [x] 补齐后端单元、集成和 Windows 平台测试。
- [x] 补齐前端组件测试和 Playwright 主流程测试。
- [x] 完成键盘导航、焦点管理、ARIA 和窄屏布局。
- [x] 移除所有用户输入的 `innerHTML` 拼接,统一安全文本渲染。
- [x] 后端默认绑定 `127.0.0.1`,配置严格 CORS 和 Origin 校验。
- [x] 增加本地管理令牌或等价的本机接口保护方案。
- [x] 检查路径逃逸、命令注入、日志注入和敏感信息泄露。
- [x] 由 Axum 托管生产构建后的 React 静态资源。
- [x] 提供一键启动脚本、生产构建说明和故障排查文档。
- [x] 更新 README,并评审是否将旧 HTML 移入 `legacy/`。
说明:旧 `task_pilot.html` 暂时保留在根目录作为视觉参考;正式运行入口为 React 构建产物。
验收:
- 完整测试、格式检查、静态检查和前端生产构建通过。
- 单机 Release 构建能够启动、自动打开或明确提示访问地址。
- 关闭程序后,任务数据仍保留;重新启动后启用任务恢复调度。
- README 中的命令全部经过实际验证。
## 7. 开发顺序与检查点
每个阶段按以下顺序推进:
1. 先确定数据模型和 API 契约。
2. 实现后端能力并补充自动化测试。
3. 通过 API 或命令行验证真实行为。
4. 接入 React 页面,不先做没有后端支持的假交互。
5. 运行完整检查并更新本计划的勾选状态。
阶段之间保留可运行状态,不进行长期无法启动的大规模重构。旧 HTML 一直保留到 React 版本完成等价迁移。
## 8. 测试策略
### 后端
- 单元测试:Cron 校验、任务校验、状态转换、重试和并发策略。
- 集成测试:使用临时 SQLite 验证 CRUD、迁移和重启恢复。
- 调度测试:使用可控时钟验证下次执行时间和动态调度。
- 平台测试:在 Windows 上验证脚本参数、退出码、超时和进程树终止。
- 契约测试:检查 OpenAPI 与实现一致。
### 前端
- 组件测试:表单类型切换、错误展示、状态徽章和权限式操作可用性。
- API Mock 测试:加载、失败、空数据和重试。
- E2E:创建脚本任务、手动执行、查看日志、编辑、停用和删除。
### 提交前检查
计划提供以下统一命令,具体脚本在 Phase 1 落地:
```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
```
## 9. 安全与可靠性约束
- 后端默认只监听本机地址,不默认暴露到局域网。
- 脚本和进程任务使用结构化参数调用,不通过字符串拼接进入 Shell。
- 所有路径在执行前规范化并校验允许范围。
- 用户提交的文本只通过 React 文本节点渲染,禁止直接拼接 HTML。
- 环境变量中的敏感值在列表、详情、日志和错误信息中脱敏。
- stdout/stderr 必须并发读取,避免管道写满导致子进程卡死。
- 所有运行都有超时和取消路径,终止时覆盖子进程树。
- 日志设置单次大小和总容量上限,避免磁盘被无限占用。
- 数据库迁移必须可重复执行,并在失败时给出明确错误。
- 前端不得把请求失败或未接入能力显示为成功。
## 10. 主要风险
| 风险 | 影响 | 应对 |
| --- | --- | --- |
| Cron crate 语义或动态调度能力不满足要求 | 自动执行时间错误 | Phase 1 POC,覆盖 5/6 位、时区、日和星期语义 |
| Windows 子进程树难以可靠终止 | 超时后遗留进程 | 使用 Job Object,并增加真实 Windows 集成测试 |
| stdout/stderr 处理不当 | 子进程阻塞或日志丢失 | 异步并发读取,设置背压和日志上限 |
| SQLite 与调度器状态不一致 | 重启后漏跑或重复跑 | 事务更新、启动时重建调度、记录运行状态 |
| 前端迁移范围过大 | 长期无法交付 | 先完成脚本任务垂直切片,再扩展三类 Action |
| 敏感环境变量泄露 | 凭据暴露 | 遮罩、日志脱敏,后续接入系统凭据存储 |
| 动态依赖下载受网络影响 | 工程搭建受阻 | 锁定版本和 lockfile,必要时分阶段安装并记录依赖 |
| 本地接口被其他网页调用 | 未授权操作 | localhost 绑定、Origin 校验和本机管理令牌 |
## 11. MVP 完成标准
满足以下条件后,第一版可以视为完成:
- React 页面完全使用真实 API 数据。
- 任务 CRUD、启停和手动运行形成闭环。
- 脚本、HTTP 和二进制进程任务至少各有一条成功路径和一条失败路径验证。
- Cron 自动调度、时区和下次运行时间可验证。
- 任务配置、运行历史和日志重启后保留。
- 超时和取消可以终止 Windows 进程树。
- 页面能够展示运行中、成功、失败、超时和跳过状态。
- 后端默认仅本机可访问,敏感值不会出现在日志中。
- 自动化测试、格式检查和 Release 构建通过。
## 12. 后续维护
六个计划阶段已经完成。后续版本优先评估:
1. 自动重试与退避策略。
2. Windows 服务安装和开机自启。
3. 系统凭据存储与更严格的秘密管理。
4. 任务模板、导入导出和批量操作。
5. 运行指标、告警和长期监控。
6. HTTP 响应断言、认证方案和请求重放。
+360
View File
@@ -0,0 +1,360 @@
# TaskPilot
TaskPilot 是一个面向 Windows 的本地定时任务管理平台。项目目标是用 React 提供任务管理界面,用 Rust 实现 Cron 调度、Windows 进程管理和运行日志。
当前已完成 Phase 1 至 Phase 6:React 任务管理界面、Rust API、SQLite 持久化、Cron 调度、三类任务执行器、实时日志、安全边界、生产静态托管和 Windows 本地打包均已落地。
仓库根目录的 `task_pilot.html` 是最初的单文件视觉原型,目前只作为 React 迁移期间的设计参考,不是正式前端入口。
React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标卡、任务表格、660px 编辑弹窗、Cron 预览区和深色日志查看器均在保留真实功能的前提下复刻。
## 当前能力
- React 页面可以检测 Rust 后端连接状态。
- Vite 开发服务器将 `/api` 代理到 Axum 后端。
- Rust 服务提供健康检查和 OpenAPI 文档。
- OpenAPI 可以导出为稳定快照,并生成前端 TypeScript 类型。
- Cron POC 支持 5/6 位表达式、秒级调度、别名、月份/星期名称和 POSIX 日/星期语义。
- 任务可以持久化到 SQLite,并支持创建、查询、更新和删除。
- 任务列表支持名称/ID 搜索、启用状态筛选、Action 类型筛选和分页。
- 脚本、HTTP 和二进制进程任务具有独立的类型化载荷与后端校验。
- 启用的脚本任务由 Tokio 调度循环自动执行,并支持启动后恢复调度。
- 后端重启后,已启用任务仍可持久化并重新注册。
- 支持手动立即运行,重复触发时记录为 `skipped`,不会并发执行同一任务。
- 每次运行记录触发来源、状态、耗时、退出码和错误信息。
- 已实现 Windows Job Object,超时或关闭时会终止目标进程树。
- stdout 和 stderr 会写入独立运行日志,并可通过 API 增量读取。
- HTTP Action 支持 GET/POST、Header、Body、超时和期望状态码。
- 二进制进程 Action 使用 Job Object 执行,并支持工作目录、参数和环境变量。
- 运行中任务可以通过 API 或前端日志抽屉取消。
- `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
- 日志支持保留期限和总容量清理,单次日志仍有 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
volta --version
node --version
pnpm --version
rustc --version
```
## 本地开发
首次拉取项目后安装前端依赖:
```powershell
pnpm --dir frontend install
```
终端一,启动 Rust API:
```powershell
cargo run --manifest-path backend/Cargo.toml
```
终端二,启动 React 前端:
```powershell
pnpm --dir frontend dev
```
访问 <http://127.0.0.1:5173>。Rust 服务默认监听 `127.0.0.1:3000`,可通过 `TASK_PILOT_PORT` 修改端口:
```powershell
$env:TASK_PILOT_PORT = "3100"
cargo run --manifest-path backend/Cargo.toml
```
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
## Windows Release
构建前端生产资源、Rust Release 可执行文件和可分发目录:
```powershell
.\scripts\build-release.ps1
```
产物位于 `release/TaskPilot/`。启动打包版本:
```powershell
.\release\TaskPilot\start.ps1
```
访问 <http://127.0.0.1:3000>。打包使用同源 API,因此 Release 模式修改 `TASK_PILOT_PORT` 后无需重新构建前端。
打包目录中的 [RELEASE.md](docs/RELEASE.md) 会作为使用与故障排查说明一并复制。
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`,可通过以下环境变量配置:
```powershell
$env:TASK_PILOT_LOG_DIR = "logs"
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
```
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
## API
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/v1/health` | 后端健康检查 |
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
| `GET` | `/api/v1/tasks` | 查询任务列表 |
| `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` | 手动立即执行 |
| `GET` | `/api/v1/tasks/{id}/runs` | 查询任务运行历史 |
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行状态 |
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量读取运行日志 |
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整运行日志 |
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消正在运行的任务 |
| `GET` | `/api/v1/events` | SSE 运行状态和日志事件 |
任务列表查询参数:
- `q`:按任务 ID 或名称搜索。
- `enabled`:按启用状态筛选。
- `action_type`:`script`、`http` 或 `process`。
- `page`:页码,默认 `1`。
- `page_size`:每页数量,默认 `20`,最大 `100`。
运行历史查询额外支持 `status` 和 `trigger` 过滤。
健康检查示例:
```json
{
"status": "ok",
"service": "task-pilot-backend",
"version": "0.1.0"
}
```
创建脚本任务示例:
```json
{
"id": "sync_data",
"name": "数据同步",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
"timeout_secs": 300,
"retry_policy": {
"max_attempts": 0,
"delay_secs": 0
},
"action": {
"type": "script",
"working_dir": "D:\\Projects\\sync",
"interpreter": "python.exe",
"script": "main.py",
"args": ["--mode", "full"],
"env": {
"PYTHONUNBUFFERED": "1"
}
}
}
```
校验失败和资源不存在时统一返回结构化错误:
```json
{
"code": "VALIDATION_ERROR",
"message": "Cron 表达式无效",
"field": "cron",
"details": {}
}
```
## 开发命令
### 完整检查
在项目根目录执行:
```powershell
.\scripts\check.ps1
```
该脚本依次运行:
- 前端 lint
- TypeScript 类型检查
- Vitest
- 前端生产构建
- Rust 格式检查
- Rust Clippy
- Rust 单元和集成测试
- Playwright 浏览器端到端测试
### 浏览器端到端测试
```powershell
pnpm --dir frontend test:e2e
```
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
### 生成接口契约和类型
修改后端 API DTO 或路由后执行:
```powershell
.\scripts\generate-api.ps1
```
该命令会更新:
- `contracts/openapi.json`
- `frontend/src/api/generated.ts`
生成文件不应手工修改。
### 单独检查
```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
```
## 项目结构
```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
```
架构决策记录:
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
## 当前限制
- 重试策略字段已持久化,但自动重试尚未实现。
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
## 开发计划
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
+2927
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "task-pilot-backend"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
[dependencies]
axum = "0.8.9"
chrono = { version = "0.4.45", features = ["serde"] }
chrono-tz = "0.10.4"
croner = "4.0.1"
futures-util = { version = "0.3.34", features = ["std"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
thiserror = "2.0.21"
tokio = { version = "1.53.3", features = ["full"] }
tokio-stream = { version = "0.1.19", features = ["sync"] }
tokio-util = "0.7.20"
tower-http = { version = "0.7.1", features = ["cors", "fs", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8"
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.3.0"
uuid = { version = "1.28.0", features = ["v4"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] }
[dev-dependencies]
http-body-util = "0.1.5"
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
CHECK (concurrency_policy = 'skip'),
retry_max_attempts INTEGER NOT NULL DEFAULT 0
CHECK (retry_max_attempts >= 0),
retry_delay_secs INTEGER NOT NULL DEFAULT 0
CHECK (retry_delay_secs >= 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
+201
View File
@@ -0,0 +1,201 @@
use axum::{
Json, Router,
extract::State,
http::{HeaderValue, Method, header},
middleware,
response::IntoResponse,
routing::any,
routing::get,
};
use serde::Serialize;
use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use crate::{
cron::{self, CronPreviewRequest, CronPreviewResponse},
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
routes::{
__path_cancel_run, __path_download_run_logs, __path_execution_events, __path_get_run,
__path_get_run_logs, __path_list_task_runs, __path_run_task,
},
},
security,
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
},
routes::{
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
__path_get_task, __path_list_tasks, __path_update_task,
},
},
};
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthResponse {
/// Current service status.
pub status: String,
/// Stable service identifier.
pub service: String,
/// Backend package version.
pub version: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SessionResponse {
/// Random per-process token required for mutating browser requests.
pub token: String,
}
#[utoipa::path(
get,
path = "/api/v1/health",
tag = "system",
responses(
(status = 200, description = "TaskPilot API is healthy", body = HealthResponse)
)
)]
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_owned(),
service: "task-pilot-backend".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
})
}
#[utoipa::path(
get,
path = "/api/v1/session",
tag = "system",
responses(
(status = 200, description = "Local API session token", body = SessionResponse)
)
)]
pub async fn session(State(state): State<AppState>) -> Json<SessionResponse> {
Json(SessionResponse {
token: state.auth_token().to_owned(),
})
}
#[utoipa::path(
post,
path = "/api/v1/cron/preview",
tag = "system",
request_body = CronPreviewRequest,
responses(
(status = 200, description = "Upcoming cron occurrences", body = CronPreviewResponse),
(status = 400, description = "Invalid cron preview request", body = ErrorResponse)
)
)]
pub async fn preview_cron(
ApiJson(request): ApiJson<CronPreviewRequest>,
) -> ApiResult<Json<CronPreviewResponse>> {
if request.count == 0 || request.count > 10 {
return Err(ApiError::validation("count", "预览次数必须在 1 到 10 之间"));
}
let preview = cron::preview_in_timezone(
&request.expression,
&request.timezone,
chrono::Utc::now(),
request.count,
)
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
Ok(Json(preview))
}
#[derive(OpenApi)]
#[openapi(
info(
title = "TaskPilot API",
version = env!("CARGO_PKG_VERSION"),
description = "Local task scheduling and process management API."
),
paths(
health,
session,
preview_cron,
list_tasks,
create_task,
get_task,
update_task,
delete_task,
enable_task,
disable_task,
run_task,
list_task_runs,
get_run,
get_run_logs,
cancel_run,
download_run_logs,
execution_events
),
components(schemas(
HealthResponse,
SessionResponse,
CronPreviewRequest,
CronPreviewResponse,
ErrorResponse,
TaskActionType,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
TaskResponse,
TaskListResponse,
TaskRunResponse,
TaskRunListResponse,
LogChunkResponse
))
)]
pub struct ApiDoc;
pub fn openapi() -> utoipa::openapi::OpenApi {
ApiDoc::openapi()
}
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
Json(openapi())
}
pub fn router(state: AppState) -> Router {
let allowed_origins = [
HeaderValue::from_static("http://127.0.0.1:5173"),
HeaderValue::from_static("http://localhost:5173"),
];
let cors = CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]);
let api = Router::new()
.route("/api/v1/health", get(health))
.route("/api/v1/session", get(session))
.route("/api/v1/cron/preview", axum::routing::post(preview_cron))
.route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router())
.merge(crate::runs::routes::router())
.route("/api/{*path}", any(api_not_found))
.layer(cors)
.route_layer(middleware::from_fn_with_state(
state.clone(),
security::api_guard,
));
api.with_state(state)
}
async fn api_not_found() -> impl IntoResponse {
ApiError::route_not_found("API 路由不存在")
}
+16
View File
@@ -0,0 +1,16 @@
use std::{env, error::Error, fs, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
let json = serde_json::to_string_pretty(&task_pilot_backend::api::openapi())?;
if let Some(path) = env::args().nth(1) {
if let Some(parent) = PathBuf::from(&path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{json}\n"))?;
} else {
println!("{json}");
}
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
use std::str::FromStr;
use chrono::{DateTime, Local, Utc};
use chrono_tz::Tz;
use croner::Cron;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use utoipa::ToSchema;
const MAX_PREVIEW_RUNS: usize = 100;
#[derive(Debug, Error)]
pub enum CronPreviewError {
#[error("invalid cron expression: {0}")]
InvalidExpression(String),
#[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")]
TooManyRuns,
#[error("cron expression has no future occurrence")]
NoFutureOccurrence,
#[error("invalid IANA timezone: {0}")]
InvalidTimezone(String),
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CronPreviewRequest {
pub expression: String,
pub timezone: String,
pub count: usize,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CronPreviewResponse {
pub timezone: String,
pub runs: Vec<String>,
}
/// Calculates the next occurrences of a cron expression in local time.
pub fn next_occurrences(
expression: &str,
from: DateTime<Local>,
count: usize,
) -> Result<Vec<DateTime<Local>>, CronPreviewError> {
if count > MAX_PREVIEW_RUNS {
return Err(CronPreviewError::TooManyRuns);
}
let cron = Cron::from_str(expression)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
let mut runs = Vec::with_capacity(count);
let mut cursor = from;
for _ in 0..count {
let next = cron
.find_next_occurrence(&cursor, false)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
runs.push(next);
cursor = next;
}
Ok(runs)
}
pub fn preview_in_timezone(
expression: &str,
timezone: &str,
from: DateTime<Utc>,
count: usize,
) -> Result<CronPreviewResponse, CronPreviewError> {
if count > MAX_PREVIEW_RUNS {
return Err(CronPreviewError::TooManyRuns);
}
let timezone: Tz = timezone
.parse()
.map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?;
let cron = Cron::from_str(expression)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
let mut cursor = from.with_timezone(&timezone);
let mut runs = Vec::with_capacity(count);
for _ in 0..count {
let next = cron
.find_next_occurrence(&cursor, false)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string());
cursor = next;
}
Ok(CronPreviewResponse {
timezone: timezone.name().to_owned(),
runs,
})
}
#[cfg(test)]
mod tests {
use chrono::{Datelike, TimeZone, Weekday};
use super::*;
fn local_time(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> DateTime<Local> {
Local
.with_ymd_and_hms(year, month, day, hour, minute, second)
.single()
.expect("test timestamp should be unambiguous")
}
#[test]
fn parses_five_field_expression() {
let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1)
.expect("five field expression should parse");
assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]);
}
#[test]
fn parses_six_field_expression_with_seconds() {
let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2)
.expect("six field expression should parse");
assert_eq!(
runs,
vec![
local_time(2026, 1, 1, 12, 0, 15),
local_time(2026, 1, 1, 12, 0, 30)
]
);
}
#[test]
fn supports_hourly_alias() {
let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1)
.expect("hourly alias should parse");
assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]);
}
#[test]
fn rejects_zero_step() {
let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1)
.expect_err("zero step should be rejected");
assert!(matches!(error, CronPreviewError::InvalidExpression(_)));
}
#[test]
fn uses_posix_or_semantics_for_day_of_month_and_weekday() {
let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1)
.expect("POSIX cron expression should parse");
assert_eq!(runs[0].day(), 5);
assert_eq!(runs[0].weekday(), Weekday::Mon);
}
#[test]
fn supports_explicit_day_and_weekday_and_semantics() {
let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1)
.expect("AND semantics should parse");
assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0));
}
#[test]
fn previews_runs_in_iana_timezone() {
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap();
assert_eq!(preview.timezone, "Asia/Shanghai");
assert_eq!(
preview.runs,
vec![
"2026-01-02 02:00:00".to_owned(),
"2026-01-03 02:00:00".to_owned()
]
);
}
}
+29
View File
@@ -0,0 +1,29 @@
use std::{error::Error, str::FromStr};
use sqlx::{
SqlitePool,
migrate::MigrateError,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.foreign_keys(true);
let max_connections = if database_url == "sqlite::memory:" {
1
} else {
5
};
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.connect_with(options)
.await?;
migrate(&pool).await?;
Ok(pool)
}
pub async fn migrate(pool: &SqlitePool) -> Result<(), MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}
+222
View File
@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{FromRequest, FromRequestParts, Query, Request, rejection::JsonRejection},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Serialize, de::DeserializeOwned};
use utoipa::ToSchema;
pub type ApiResult<T> = Result<T, ApiError>;
pub struct ApiJson<T>(pub T);
impl<S, T> FromRequest<S> for ApiJson<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = ApiError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
Json::<T>::from_request(request, state)
.await
.map(|Json(value)| Self(value))
.map_err(json_rejection)
}
}
pub struct ApiQuery<T>(pub T);
impl<S, T> FromRequestParts<S> for ApiQuery<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
Query::<T>::from_request_parts(parts, state)
.await
.map(|Query(value)| Self(value))
.map_err(|error| {
ApiError::bad_request("INVALID_QUERY", format!("查询参数无效: {error}"))
})
}
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
code: &'static str,
message: String,
field: Option<String>,
details: BTreeMap<String, String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
pub details: BTreeMap<String, String>,
}
impl ApiError {
pub fn bad_request(code: &'static str, message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code,
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code: "VALIDATION_ERROR",
message: message.into(),
field: Some(field.into()),
details: BTreeMap::new(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "TASK_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "RUN_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn route_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_not_cancellable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "RUN_NOT_CANCELLABLE",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_log_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "RUN_LOG_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: "FORBIDDEN",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "TASK_ID_CONFLICT",
message: message.into(),
field: Some("id".to_owned()),
details: BTreeMap::new(),
}
}
pub fn unsupported(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNPROCESSABLE_ENTITY,
code: "ACTION_NOT_IMPLEMENTED",
message: message.into(),
field: Some("action.type".to_owned()),
details: BTreeMap::new(),
}
}
pub fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "internal API error");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "INTERNAL_ERROR",
message: "服务器处理请求时发生内部错误".to_owned(),
field: None,
details: BTreeMap::new(),
}
}
}
fn json_rejection(error: JsonRejection) -> ApiError {
ApiError::bad_request(
"INVALID_JSON",
format!("请求体不是有效的任务 JSON: {error}"),
)
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = ErrorResponse {
code: self.code.to_owned(),
message: self.message,
field: self.field,
details: self.details,
};
(self.status, Json(body)).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(error: sqlx::Error) -> Self {
Self::internal(error)
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
Self::internal(error)
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
+40
View File
@@ -0,0 +1,40 @@
pub mod api;
pub mod cron;
pub mod database;
pub mod error;
pub mod process_job;
pub mod runs;
pub mod scheduler;
pub mod security;
pub mod state;
pub mod tasks;
use axum::Router;
use sqlx::SqlitePool;
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
pub async fn app(
pool: SqlitePool,
logs_dir: std::path::PathBuf,
) -> crate::error::ApiResult<Router> {
let state = state::AppState::start(pool, logs_dir).await?;
Ok(router(state))
}
pub fn router(state: state::AppState) -> Router {
api::router(state).layer(TraceLayer::new_for_http())
}
pub fn router_with_static(state: state::AppState, static_dir: std::path::PathBuf) -> Router {
let index = static_dir.join("index.html");
let router = if index.is_file() {
api::router(state)
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)))
} else {
api::router(state)
};
router.layer(TraceLayer::new_for_http())
}
+77
View File
@@ -0,0 +1,77 @@
use std::{
env,
error::Error,
fs,
net::SocketAddr,
path::{Path, PathBuf},
};
use tokio::net::TcpListener;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
init_tracing();
let port = env::var("TASK_PILOT_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(3000);
let address = SocketAddr::from(([127, 0, 0, 1], port));
let database_url = database_url()?;
let pool = task_pilot_backend::database::connect(&database_url).await?;
let logs_dir = env::var("TASK_PILOT_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new("logs").to_path_buf());
fs::create_dir_all(&logs_dir)?;
let state = task_pilot_backend::state::AppState::start(pool.clone(), logs_dir).await?;
let static_dir = env::var("TASK_PILOT_STATIC_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new("frontend/dist").to_path_buf());
let listener = TcpListener::bind(address).await?;
tracing::info!(%address, database_url, "TaskPilot backend listening");
axum::serve(
listener,
task_pilot_backend::router_with_static(state.clone(), static_dir),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
state.shutdown();
pool.close().await;
Ok(())
}
fn database_url() -> Result<String, Box<dyn Error>> {
if let Ok(url) = env::var("TASK_PILOT_DATABASE_URL") {
return Ok(url);
}
let data_dir = Path::new("data");
fs::create_dir_all(data_dir)?;
Ok(format!(
"sqlite://{}",
data_dir
.join("task_pilot.db")
.to_string_lossy()
.replace('\\', "/")
))
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "task_pilot_backend=debug,tower_http=debug".into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
}
async fn shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::error!(%error, "failed to listen for shutdown signal");
}
}
+109
View File
@@ -0,0 +1,109 @@
#[cfg(windows)]
mod platform {
use std::{
ffi::c_void,
io,
mem::{size_of, zeroed},
ptr,
};
use tokio::process::Child;
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
},
};
pub struct ProcessJob {
handle: HANDLE,
}
// Windows job handles can be used across threads. This wrapper owns exactly one
// handle and closes it once in Drop.
unsafe impl Send for ProcessJob {}
unsafe impl Sync for ProcessJob {}
impl ProcessJob {
pub fn new() -> io::Result<Self> {
let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
if handle.is_null() {
return Err(io::Error::last_os_error());
}
let job = Self { handle };
job.enable_kill_on_close()?;
Ok(job)
}
pub fn assign(&self, child: &Child) -> io::Result<()> {
let process_handle = child
.raw_handle()
.ok_or_else(|| io::Error::other("process handle is unavailable"))?;
let result = unsafe { AssignProcessToJobObject(self.handle, process_handle.cast()) };
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn terminate(&self) -> io::Result<()> {
let result = unsafe { TerminateJobObject(self.handle, 1) };
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn enable_kill_on_close(&self) -> io::Result<()> {
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let result = unsafe {
SetInformationJobObject(
self.handle,
JobObjectExtendedLimitInformation,
(&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
impl Drop for ProcessJob {
fn drop(&mut self) {
unsafe {
CloseHandle(self.handle);
}
}
}
}
#[cfg(not(windows))]
mod platform {
use std::io;
use tokio::process::Child;
pub struct ProcessJob;
impl ProcessJob {
pub fn new() -> io::Result<Self> {
Ok(Self)
}
pub fn assign(&self, _child: &Child) -> io::Result<()> {
Ok(())
}
pub fn terminate(&self) -> io::Result<()> {
Ok(())
}
}
}
pub use platform::ProcessJob;
+962
View File
@@ -0,0 +1,962 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Stdio,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{Instant, SystemTime},
};
use chrono::Utc;
use futures_util::StreamExt;
use reqwest::Method;
use serde::Serialize;
use sqlx::SqlitePool;
use tokio::{
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex as AsyncMutex, broadcast},
time::{Duration, sleep, timeout},
};
use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
process_job::ProcessJob,
runs::{
model::{RunStatus, RunTrigger, TaskRunResponse},
repository,
},
tasks::model::{HttpMethod, TaskAction, TaskResponse},
};
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
const TERMINATION_WAIT_SECS: u64 = 5;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DEFAULT_RETENTION_DAYS: u64 = 30;
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct LogRetentionConfig {
pub retention_days: u64,
pub max_total_bytes: u64,
}
impl Default for LogRetentionConfig {
fn default() -> Self {
Self {
retention_days: DEFAULT_RETENTION_DAYS,
max_total_bytes: DEFAULT_MAX_TOTAL_LOG_BYTES,
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum ExecutionEvent {
Run {
run: TaskRunResponse,
},
Log {
run_id: i64,
offset: u64,
next_offset: u64,
content: String,
},
}
impl ExecutionEvent {
pub const fn event_name(&self) -> &'static str {
match self {
Self::Run { .. } => "run",
Self::Log { .. } => "log",
}
}
}
#[derive(Clone)]
pub struct TaskExecutor {
inner: Arc<ExecutorInner>,
}
struct ExecutorInner {
pool: SqlitePool,
logs_dir: PathBuf,
shutdown: CancellationToken,
running: Mutex<HashMap<String, RunningHandle>>,
events: broadcast::Sender<ExecutionEvent>,
http: reqwest::Client,
log_config: LogRetentionConfig,
cleanup_lock: AsyncMutex<()>,
}
struct RunningHandle {
run_id: Option<i64>,
cancellation: CancellationToken,
}
#[derive(Clone)]
struct LogSink {
file: Arc<AsyncMutex<tokio::fs::File>>,
size: Arc<AtomicU64>,
truncated: Arc<AtomicBool>,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Arc<Vec<String>>,
}
struct ScriptOutcome {
status: RunStatus,
exit_code: Option<i32>,
error_message: Option<String>,
}
enum ProcessEnd {
Exited(std::process::ExitStatus),
TimedOut,
Cancelled,
}
impl TaskExecutor {
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
let (events, _) = broadcast::channel(512);
Self {
inner: Arc::new(ExecutorInner {
pool,
logs_dir,
shutdown: CancellationToken::new(),
running: Mutex::new(HashMap::new()),
events,
http: reqwest::Client::builder()
.user_agent("TaskPilot/0.1")
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.unwrap_or_default(),
log_config,
cleanup_lock: AsyncMutex::new(()),
}),
}
}
pub async fn trigger(
&self,
task: &TaskResponse,
trigger: RunTrigger,
) -> ApiResult<TaskRunResponse> {
let cancellation = CancellationToken::new();
let already_running = {
let mut running = lock_running(&self.inner.running);
if running.contains_key(&task.id) {
true
} else {
running.insert(
task.id.clone(),
RunningHandle {
run_id: None,
cancellation: cancellation.clone(),
},
);
false
}
};
if already_running {
let run = repository::create_skipped(
&self.inner.pool,
&task.id,
trigger,
"同一任务已有运行实例,本次触发已跳过",
)
.await?;
self.emit_run(&run);
return Ok(run);
}
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
Ok(run) => run,
Err(error) => {
lock_running(&self.inner.running).remove(&task.id);
return Err(error);
}
};
if let Some(handle) = lock_running(&self.inner.running).get_mut(&task.id) {
handle.run_id = Some(run.id);
}
self.emit_run(&run);
let executor = self.clone();
let task = task.clone();
tokio::spawn(async move {
executor.execute_run(task, run.id, cancellation).await;
});
Ok(run)
}
pub fn cancel_task(&self, task_id: &str) -> bool {
let mut running = lock_running(&self.inner.running);
if let Some(handle) = running.remove(task_id) {
handle.cancellation.cancel();
true
} else {
false
}
}
pub fn cancel_run(&self, run_id: i64) -> bool {
let mut running = lock_running(&self.inner.running);
let task_id = running
.iter()
.find_map(|(task_id, handle)| (handle.run_id == Some(run_id)).then(|| task_id.clone()));
if let Some(task_id) = task_id
&& let Some(handle) = running.remove(&task_id)
{
handle.cancellation.cancel();
return true;
}
false
}
pub fn subscribe(&self) -> broadcast::Receiver<ExecutionEvent> {
self.inner.events.subscribe()
}
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
self.inner
.logs_dir
.join(task_id)
.join(format!("{run_id}.log"))
}
pub fn shutdown(&self) {
self.inner.shutdown.cancel();
let running = lock_running(&self.inner.running);
for handle in running.values() {
handle.cancellation.cancel();
}
}
pub async fn cleanup_logs(&self) -> std::io::Result<()> {
let _guard = self.inner.cleanup_lock.lock().await;
let logs_dir = self.inner.logs_dir.clone();
let config = self.inner.log_config.clone();
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
.await
.map_err(std::io::Error::other)?
}
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
let started_at = Utc::now();
let started = Instant::now();
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
tracing::error!(run_id, error = %error, "failed to mark task run as running");
lock_running(&self.inner.running).remove(&task.id);
return;
}
self.emit_run_by_id(run_id).await;
let outcome = self.run_action(&task, run_id, &cancellation).await;
let finished_at = Utc::now();
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(error) = repository::complete(
&self.inner.pool,
run_id,
outcome.status,
finished_at,
duration_ms,
outcome.exit_code,
outcome.error_message.as_deref(),
)
.await
{
tracing::error!(run_id, error = %error, "failed to complete task run");
}
self.emit_run_by_id(run_id).await;
lock_running(&self.inner.running).remove(&task.id);
if let Err(error) = self.cleanup_logs().await {
tracing::warn!(%error, "failed to clean task logs");
}
}
async fn run_action(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
) -> ScriptOutcome {
match &task.action {
TaskAction::Script {
working_dir,
interpreter,
script,
args,
env,
} => {
let mut command = Command::new(interpreter);
command
.arg(script)
.args(args)
.envs(env)
.current_dir(working_dir);
self.run_command(task, run_id, cancellation, command).await
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
let mut command = Command::new(executable);
command.args(args).envs(env);
if let Some(working_dir) = working_dir {
command.current_dir(working_dir);
}
self.run_command(task, run_id, cancellation, command).await
}
TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await,
}
}
async fn run_command(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
mut command: Command,
) -> ScriptOutcome {
let log_path = self.log_path(&task.id, run_id);
let secrets = sensitive_values(&task.action);
let sink = match LogSink::create(
&log_path,
task,
run_id,
self.inner.events.clone(),
secrets,
)
.await
{
Ok(sink) => sink,
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
};
let job = match ProcessJob::new() {
Ok(job) => job,
Err(error) => {
sink.system(&format!("无法创建 Windows Job Object: {error}"))
.await;
return failed_outcome(format!("无法创建 Windows Job Object: {error}"));
}
};
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
{
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
sink.system(&format!("启动进程失败: {error}")).await;
return failed_outcome(format!("启动进程失败: {error}"));
}
};
if let Err(error) = job.assign(&child) {
let _ = child.kill().await;
sink.system(&format!("关联 Job Object 失败: {error}")).await;
return failed_outcome(format!("关联 Windows Job Object 失败: {error}"));
}
let stdout_task = child
.stdout
.take()
.map(|stdout| spawn_log_reader(stdout, "stdout", sink.clone()));
let stderr_task = child
.stderr
.take()
.map(|stderr| spawn_log_reader(stderr, "stderr", sink.clone()));
let process_end = tokio::select! {
status = child.wait() => {
match status {
Ok(status) => ProcessEnd::Exited(status),
Err(error) => {
sink.system(&format!("等待进程失败: {error}")).await;
return failed_outcome(format!("等待进程失败: {error}"));
}
}
}
_ = sleep(Duration::from_secs(task.timeout_secs)) => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate timed out job");
}
wait_after_termination(&mut child).await;
ProcessEnd::TimedOut
}
_ = cancellation.cancelled() => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate cancelled job");
}
wait_after_termination(&mut child).await;
ProcessEnd::Cancelled
}
_ = self.inner.shutdown.cancelled() => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate job during shutdown");
}
wait_after_termination(&mut child).await;
ProcessEnd::Cancelled
}
};
if let Some(stdout_task) = stdout_task
&& let Err(error) = stdout_task.await
{
tracing::warn!(task_id = %task.id, run_id, %error, "stdout capture task failed");
}
if let Some(stderr_task) = stderr_task
&& let Err(error) = stderr_task.await
{
tracing::warn!(task_id = %task.id, run_id, %error, "stderr capture task failed");
}
match process_end {
ProcessEnd::Exited(status) => {
let exit_code = status.code();
if status.success() {
sink.system("任务执行成功").await;
ScriptOutcome {
status: RunStatus::Succeeded,
exit_code,
error_message: None,
}
} else {
let message = format!(
"进程退出码为 {}",
exit_code.map_or_else(|| "unknown".to_owned(), |code| code.to_string())
);
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Failed,
exit_code,
error_message: Some(message),
}
}
}
ProcessEnd::TimedOut => {
let message = format!("任务执行超过 {} 秒,已终止进程树", task.timeout_secs);
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::TimedOut,
exit_code: None,
error_message: Some(message),
}
}
ProcessEnd::Cancelled => {
let message = "任务已取消".to_owned();
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some(message),
}
}
}
}
async fn run_http(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
) -> ScriptOutcome {
let TaskAction::Http {
url,
method,
headers,
body,
expected_status,
} = &task.action
else {
return failed_outcome("invalid HTTP action payload");
};
let log_path = self.log_path(&task.id, run_id);
let sink = match LogSink::create(
&log_path,
task,
run_id,
self.inner.events.clone(),
sensitive_values(&task.action),
)
.await
{
Ok(sink) => sink,
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
};
let method = match method {
HttpMethod::Get => Method::GET,
HttpMethod::Post => Method::POST,
};
let mut request = self
.inner
.http
.request(method.clone(), url)
.timeout(Duration::from_secs(task.timeout_secs));
for (name, value) in headers {
request = request.header(name, value);
}
if let Some(body) = body {
request = request.body(body.clone());
}
sink.http(&format!("请求 {method} {url}")).await;
let response = tokio::select! {
response = request.send() => {
match response {
Ok(response) => response,
Err(error) => {
let message = if error.is_timeout() {
format!("HTTP 请求超过 {} 秒", task.timeout_secs)
} else {
format!("HTTP 请求失败: {error}")
};
sink.system(&message).await;
return ScriptOutcome {
status: if error.is_timeout() {
RunStatus::TimedOut
} else {
RunStatus::Failed
},
exit_code: None,
error_message: Some(message),
};
}
}
}
_ = cancellation.cancelled() => {
sink.system("HTTP 请求已取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 请求已取消".to_owned()),
};
}
_ = self.inner.shutdown.cancelled() => {
sink.system("HTTP 请求因服务关闭而取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 请求因服务关闭而取消".to_owned()),
};
}
};
let http_status = response.status();
sink.http(&format!("响应状态 {http_status}")).await;
let mut response_stream = response.bytes_stream();
let mut response_body = Vec::new();
let mut body_truncated = false;
loop {
let next = tokio::select! {
chunk = response_stream.next() => chunk,
_ = cancellation.cancelled() => {
sink.system("读取 HTTP 响应已取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("读取 HTTP 响应已取消".to_owned()),
};
}
_ = self.inner.shutdown.cancelled() => {
sink.system("HTTP 响应读取因服务关闭而取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 响应读取因服务关闭而取消".to_owned()),
};
}
};
match next {
Some(Ok(chunk)) => {
let remaining = MAX_HTTP_LOG_BODY_BYTES.saturating_sub(response_body.len());
let copy_length = remaining.min(chunk.len());
response_body.extend_from_slice(&chunk[..copy_length]);
if chunk.len() > copy_length {
body_truncated = true;
break;
}
}
Some(Err(error)) => {
let message = if error.is_timeout() {
format!("读取 HTTP 响应超过 {} 秒", task.timeout_secs)
} else {
format!("读取 HTTP 响应失败: {error}")
};
sink.system(&message).await;
return ScriptOutcome {
status: if error.is_timeout() {
RunStatus::TimedOut
} else {
RunStatus::Failed
},
exit_code: None,
error_message: Some(message),
};
}
None => break,
}
}
let body_text = String::from_utf8_lossy(&response_body);
if !body_text.is_empty() {
sink.http(&body_text).await;
}
if body_truncated {
sink.system(&format!(
"HTTP 响应体超过 {MAX_HTTP_LOG_BODY_BYTES} 字节,日志仅保留前部内容"
))
.await;
}
let expected = if expected_status.is_empty() {
http_status.is_success()
} else {
expected_status.contains(&http_status.as_u16())
};
if expected {
sink.system("HTTP 任务执行成功").await;
ScriptOutcome {
status: RunStatus::Succeeded,
exit_code: Some(i32::from(http_status.as_u16())),
error_message: None,
}
} else {
let message = format!("HTTP 状态码 {http_status} 不在期望范围内");
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Failed,
exit_code: Some(i32::from(http_status.as_u16())),
error_message: Some(message),
}
}
}
fn emit_run(&self, run: &TaskRunResponse) {
let _ = self
.inner
.events
.send(ExecutionEvent::Run { run: run.clone() });
}
async fn emit_run_by_id(&self, run_id: i64) {
match repository::get(&self.inner.pool, run_id).await {
Ok(Some(run)) => self.emit_run(&run),
Ok(None) => {}
Err(error) => {
tracing::warn!(run_id, error = %error, "failed to load run for SSE event");
}
}
}
}
impl LogSink {
async fn create(
path: &Path,
task: &TaskResponse,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Vec<String>,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let header = format!(
"[system] TaskPilot run {run_id} for task {} started at {}\n",
task.id,
Utc::now().to_rfc3339()
);
tokio::fs::write(path, header.as_bytes()).await?;
let file = tokio::fs::OpenOptions::new()
.append(true)
.open(path)
.await?;
let mut secrets = secrets;
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
Ok(Self {
file: Arc::new(AsyncMutex::new(file)),
size: Arc::new(AtomicU64::new(header.len() as u64)),
truncated: Arc::new(AtomicBool::new(false)),
run_id,
events,
secrets: Arc::new(secrets),
})
}
async fn append(&self, text: &str) {
let redacted = redact_text(text, &self.secrets);
let bytes = redacted.as_bytes();
let current = self.size.load(Ordering::Relaxed);
if current + bytes.len() as u64 > MAX_LOG_BYTES {
if !self.truncated.swap(true, Ordering::Relaxed) {
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
let mut file = self.file.lock().await;
let _ = file.write_all(notice.as_bytes()).await;
}
return;
}
let mut file = self.file.lock().await;
if file.write_all(bytes).await.is_err() {
return;
}
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
let _ = self.events.send(ExecutionEvent::Log {
run_id: self.run_id,
offset: current,
next_offset: next,
content: redacted,
});
}
async fn system(&self, message: &str) {
self.append(&format!("[system] {message}\n")).await;
}
async fn http(&self, message: &str) {
self.append(&format!("[http] {message}\n")).await;
}
}
fn lock_running(
running: &Mutex<HashMap<String, RunningHandle>>,
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
running
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn spawn_log_reader<R>(
reader: R,
stream: &'static str,
sink: LogSink,
) -> tokio::task::JoinHandle<()>
where
R: AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line);
sink.append(&format!("[{stream}] {text}")).await;
}
Err(error) => {
tracing::warn!(stream, %error, "failed to read task output");
break;
}
}
}
})
}
async fn wait_after_termination(child: &mut Child) {
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
.await
.is_err()
{
let _ = child.kill().await;
}
}
fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
.iter()
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
.map(|(_, value)| value.clone())
.collect(),
TaskAction::Http { headers, .. } => headers
.iter()
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
.map(|(_, value)| value.clone())
.collect(),
}
}
fn is_sensitive_name(name: &str) -> bool {
let name = name.to_ascii_uppercase();
[
"SECRET",
"TOKEN",
"PASSWORD",
"PASS",
"API_KEY",
"PRIVATE_KEY",
"CREDENTIAL",
"AUTHORIZATION",
"AUTH",
]
.iter()
.any(|marker| name.contains(marker))
}
fn redact_text(text: &str, secrets: &[String]) -> String {
secrets.iter().fold(text.to_owned(), |current, secret| {
current.replace(secret, "[REDACTED]")
})
}
fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
ScriptOutcome {
status: RunStatus::Failed,
exit_code: None,
error_message: Some(message.into()),
}
}
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
if !root.exists() {
return Ok(());
}
let mut files = Vec::new();
collect_log_files(root, &mut files)?;
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
let cutoff = SystemTime::now().checked_sub(retention);
if let Some(cutoff) = cutoff {
files.retain(|(path, metadata)| {
let expired = metadata
.modified()
.map(|modified| modified < cutoff)
.unwrap_or(false);
if expired {
let _ = std::fs::remove_file(path);
false
} else {
true
}
});
}
files.sort_by_key(|(_, metadata)| metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH));
let mut total = files
.iter()
.map(|(_, metadata)| metadata.len())
.sum::<u64>();
for (path, metadata) in files {
if total <= config.max_total_bytes {
break;
}
if std::fs::remove_file(&path).is_ok() {
total = total.saturating_sub(metadata.len());
}
}
remove_empty_directories(root)?;
Ok(())
}
fn collect_log_files(
directory: &Path,
files: &mut Vec<(PathBuf, std::fs::Metadata)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_log_files(&entry.path(), files)?;
} else if file_type.is_file() {
files.push((entry.path(), entry.metadata()?));
}
}
Ok(())
}
fn remove_empty_directories(root: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let path = entry.path();
remove_empty_directories(&path)?;
if std::fs::read_dir(&path)?.next().is_none() {
let _ = std::fs::remove_dir(path);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_statuses_are_identified() {
assert!(!RunStatus::Queued.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(RunStatus::Succeeded.is_terminal());
assert!(RunStatus::Failed.is_terminal());
assert!(RunStatus::TimedOut.is_terminal());
assert!(RunStatus::Cancelled.is_terminal());
assert!(RunStatus::Skipped.is_terminal());
}
#[test]
fn redacts_sensitive_values() {
let secrets = vec!["super-secret".to_owned(), "token-value".to_owned()];
let output = redact_text("found super-secret and token-value", &secrets);
assert_eq!(output, "found [REDACTED] and [REDACTED]");
}
#[test]
fn identifies_sensitive_environment_names() {
assert!(is_sensitive_name("API_TOKEN"));
assert!(is_sensitive_name("DB_PASSWORD"));
assert!(is_sensitive_name("Authorization"));
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
}
#[test]
fn cleanup_enforces_total_log_capacity() {
let temp = tempfile::TempDir::new().unwrap();
for index in 0..6 {
std::fs::write(temp.path().join(format!("run-{index}.log")), vec![b'x'; 4]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
}
cleanup_logs_sync(
temp.path(),
&LogRetentionConfig {
retention_days: 30,
max_total_bytes: 10,
},
)
.unwrap();
let total = std::fs::read_dir(temp.path())
.unwrap()
.map(|entry| entry.unwrap().metadata().unwrap().len())
.sum::<u64>();
assert!(total <= 10);
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod executor;
pub mod model;
pub mod repository;
pub mod routes;
+98
View File
@@ -0,0 +1,98 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunTrigger {
Schedule,
Manual,
Retry,
}
impl RunTrigger {
pub const fn as_str(self) -> &'static str {
match self {
Self::Schedule => "schedule",
Self::Manual => "manual",
Self::Retry => "retry",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Queued,
Running,
Succeeded,
Failed,
TimedOut,
Cancelled,
Skipped,
}
impl RunStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::TimedOut => "timed_out",
Self::Cancelled => "cancelled",
Self::Skipped => "skipped",
}
}
pub const fn is_terminal(self) -> bool {
matches!(
self,
Self::Succeeded | Self::Failed | Self::TimedOut | Self::Cancelled | Self::Skipped
)
}
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TaskRunResponse {
pub id: i64,
pub task_id: String,
pub trigger: RunTrigger,
pub status: RunStatus,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
pub exit_code: Option<i32>,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct TaskRunListResponse {
pub items: Vec<TaskRunResponse>,
pub page: u32,
pub page_size: u32,
pub total: u64,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct RunListQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub status: Option<RunStatus>,
pub trigger: Option<RunTrigger>,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct LogChunkQuery {
pub offset: Option<u64>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct LogChunkResponse {
pub content: String,
pub next_offset: u64,
pub complete: bool,
}
+257
View File
@@ -0,0 +1,257 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
runs::model::{RunListQuery, RunStatus, RunTrigger, TaskRunListResponse, TaskRunResponse},
};
const DEFAULT_PAGE_SIZE: u32 = 20;
const MAX_PAGE_SIZE: u32 = 100;
#[derive(Debug, FromRow)]
struct TaskRunRow {
id: i64,
task_id: String,
trigger_type: String,
status: String,
started_at: Option<DateTime<Utc>>,
finished_at: Option<DateTime<Utc>>,
duration_ms: Option<i64>,
exit_code: Option<i64>,
error_message: Option<String>,
created_at: DateTime<Utc>,
}
impl TryFrom<TaskRunRow> for TaskRunResponse {
type Error = ApiError;
fn try_from(row: TaskRunRow) -> Result<Self, Self::Error> {
Ok(Self {
id: row.id,
task_id: row.task_id,
trigger: parse_trigger(&row.trigger_type)?,
status: parse_status(&row.status)?,
started_at: row.started_at,
finished_at: row.finished_at,
duration_ms: row
.duration_ms
.map(u64::try_from)
.transpose()
.map_err(ApiError::internal)?,
exit_code: row
.exit_code
.map(i32::try_from)
.transpose()
.map_err(ApiError::internal)?,
error_message: row.error_message,
created_at: row.created_at,
})
}
}
pub async fn create_queued(
pool: &SqlitePool,
task_id: &str,
trigger: RunTrigger,
) -> ApiResult<TaskRunResponse> {
let now = Utc::now();
let result = sqlx::query(
"INSERT INTO task_runs (
task_id, trigger_type, status, created_at
) VALUES (?, ?, ?, ?)",
)
.bind(task_id)
.bind(trigger.as_str())
.bind(RunStatus::Queued.as_str())
.bind(now)
.execute(pool)
.await?;
Ok(TaskRunResponse {
id: result.last_insert_rowid(),
task_id: task_id.to_owned(),
trigger,
status: RunStatus::Queued,
started_at: None,
finished_at: None,
duration_ms: None,
exit_code: None,
error_message: None,
created_at: now,
})
}
pub async fn create_skipped(
pool: &SqlitePool,
task_id: &str,
trigger: RunTrigger,
message: &str,
) -> ApiResult<TaskRunResponse> {
let now = Utc::now();
let result = sqlx::query(
"INSERT INTO task_runs (
task_id, trigger_type, status, started_at, finished_at,
duration_ms, error_message, created_at
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind(task_id)
.bind(trigger.as_str())
.bind(RunStatus::Skipped.as_str())
.bind(now)
.bind(now)
.bind(message)
.bind(now)
.execute(pool)
.await?;
Ok(TaskRunResponse {
id: result.last_insert_rowid(),
task_id: task_id.to_owned(),
trigger,
status: RunStatus::Skipped,
started_at: Some(now),
finished_at: Some(now),
duration_ms: Some(0),
exit_code: None,
error_message: Some(message.to_owned()),
created_at: now,
})
}
pub async fn mark_running(
pool: &SqlitePool,
run_id: i64,
started_at: DateTime<Utc>,
) -> ApiResult<()> {
sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
.bind(RunStatus::Running.as_str())
.bind(started_at)
.bind(run_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn complete(
pool: &SqlitePool,
run_id: i64,
status: RunStatus,
finished_at: DateTime<Utc>,
duration_ms: u64,
exit_code: Option<i32>,
error_message: Option<&str>,
) -> ApiResult<()> {
sqlx::query(
"UPDATE task_runs SET
status = ?, finished_at = ?, duration_ms = ?,
exit_code = ?, error_message = ?
WHERE id = ?",
)
.bind(status.as_str())
.bind(finished_at)
.bind(duration_ms as i64)
.bind(exit_code.map(i64::from))
.bind(error_message)
.bind(run_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
let row = sqlx::query_as::<_, TaskRunRow>(
"SELECT
id, task_id, trigger_type, status, started_at, finished_at,
duration_ms, exit_code, error_message, created_at
FROM task_runs
WHERE id = ?",
)
.bind(run_id)
.fetch_optional(pool)
.await?;
row.map(TaskRunResponse::try_from).transpose()
}
pub async fn list_for_task(
pool: &SqlitePool,
task_id: &str,
query: RunListQuery,
) -> ApiResult<TaskRunListResponse> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query
.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let offset = i64::from(page - 1) * i64::from(page_size);
let status = query.status.map(RunStatus::as_str);
let trigger = query.trigger.map(RunTrigger::as_str);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM task_runs
WHERE task_id = ?
AND (? IS NULL OR status = ?)
AND (? IS NULL OR trigger_type = ?)",
)
.bind(task_id)
.bind(status)
.bind(status)
.bind(trigger)
.bind(trigger)
.fetch_one(pool)
.await?;
let rows = sqlx::query_as::<_, TaskRunRow>(
"SELECT
id, task_id, trigger_type, status, started_at, finished_at,
duration_ms, exit_code, error_message, created_at
FROM task_runs
WHERE task_id = ?
AND (? IS NULL OR status = ?)
AND (? IS NULL OR trigger_type = ?)
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?",
)
.bind(task_id)
.bind(status)
.bind(status)
.bind(trigger)
.bind(trigger)
.bind(i64::from(page_size))
.bind(offset)
.fetch_all(pool)
.await?;
let items = rows
.into_iter()
.map(TaskRunResponse::try_from)
.collect::<ApiResult<Vec<_>>>()?;
Ok(TaskRunListResponse {
items,
page,
page_size,
total: u64::try_from(total).map_err(ApiError::internal)?,
})
}
fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
match value {
"schedule" => Ok(RunTrigger::Schedule),
"manual" => Ok(RunTrigger::Manual),
"retry" => Ok(RunTrigger::Retry),
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
}
}
fn parse_status(value: &str) -> ApiResult<RunStatus> {
match value {
"queued" => Ok(RunStatus::Queued),
"running" => Ok(RunStatus::Running),
"succeeded" => Ok(RunStatus::Succeeded),
"failed" => Ok(RunStatus::Failed),
"timed_out" => Ok(RunStatus::TimedOut),
"cancelled" => Ok(RunStatus::Cancelled),
"skipped" => Ok(RunStatus::Skipped),
_ => Err(ApiError::internal(format!("unknown run status: {value}"))),
}
}
+265
View File
@@ -0,0 +1,265 @@
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use futures_util::{StreamExt, stream};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_stream::wrappers::BroadcastStream;
use crate::{
error::{ApiError, ApiQuery, ApiResult},
runs::{
model::{
LogChunkQuery, LogChunkResponse, RunListQuery, RunTrigger, TaskRunListResponse,
TaskRunResponse,
},
repository,
},
state::AppState,
tasks::service,
};
const MAX_LOG_CHUNK_BYTES: u64 = 64 * 1024;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/tasks/{id}/run", post(run_task))
.route("/api/v1/tasks/{id}/runs", get(list_task_runs))
.route("/api/v1/runs/{run_id}", get(get_run))
.route("/api/v1/runs/{run_id}/logs", get(get_run_logs))
.route(
"/api/v1/runs/{run_id}/logs/download",
get(download_run_logs),
)
.route("/api/v1/runs/{run_id}/cancel", post(cancel_run))
.route("/api/v1/events", get(execution_events))
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/run",
tag = "runs",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 202, description = "Manual run accepted", body = TaskRunResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse),
(status = 422, description = "Action executor not implemented", body = crate::error::ErrorResponse)
)
)]
pub async fn run_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
let task = service::get(&state.pool, &id).await?;
let run = state.executor.trigger(&task, RunTrigger::Manual).await?;
Ok((StatusCode::ACCEPTED, Json(run)))
}
#[utoipa::path(
get,
path = "/api/v1/tasks/{id}/runs",
tag = "runs",
params(
("id" = String, Path, description = "Stable task identifier"),
RunListQuery
),
responses(
(status = 200, description = "Task run history", body = TaskRunListResponse)
)
)]
pub async fn list_task_runs(
State(state): State<AppState>,
Path(id): Path<String>,
ApiQuery(query): ApiQuery<RunListQuery>,
) -> ApiResult<Json<TaskRunListResponse>> {
Ok(Json(
repository::list_for_task(&state.pool, &id, query).await?,
))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 200, description = "Task run detail", body = TaskRunResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_run(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<Json<TaskRunResponse>> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
Ok(Json(run))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}/logs",
tag = "runs",
params(
("run_id" = i64, Path, description = "Run identifier"),
LogChunkQuery
),
responses(
(status = 200, description = "Incremental task run log", body = LogChunkResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_run_logs(
State(state): State<AppState>,
Path(run_id): Path<i64>,
ApiQuery(query): ApiQuery<LogChunkQuery>,
) -> ApiResult<Json<LogChunkResponse>> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
let offset = query.offset.unwrap_or(0);
let path = state.executor.log_path(&run.task_id, run.id);
let mut file = match tokio::fs::File::open(&path).await {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Json(LogChunkResponse {
content: String::new(),
next_offset: offset,
complete: run.status.is_terminal(),
}));
}
Err(error) => return Err(ApiError::internal(error)),
};
let length = file.metadata().await.map_err(ApiError::internal)?.len();
if offset > length {
return Err(ApiError::validation(
"offset",
"日志读取偏移不能大于当前日志长度",
));
}
file.seek(std::io::SeekFrom::Start(offset))
.await
.map_err(ApiError::internal)?;
let mut bytes = Vec::new();
file.take(MAX_LOG_CHUNK_BYTES)
.read_to_end(&mut bytes)
.await
.map_err(ApiError::internal)?;
Ok(Json(LogChunkResponse {
content: String::from_utf8_lossy(&bytes).into_owned(),
next_offset: offset + bytes.len() as u64,
complete: run.status.is_terminal(),
}))
}
#[utoipa::path(
post,
path = "/api/v1/runs/{run_id}/cancel",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 202, description = "Cancellation requested", body = TaskRunResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse),
(status = 409, description = "Run is not cancellable", body = crate::error::ErrorResponse)
)
)]
pub async fn cancel_run(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
if run.status.is_terminal() {
return Err(ApiError::run_not_cancellable("运行已经结束,无法取消"));
}
if !state.executor.cancel_run(run_id) {
return Err(ApiError::run_not_cancellable(
"运行不在当前实例的可取消状态中",
));
}
Ok((StatusCode::ACCEPTED, Json(run)))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}/logs/download",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 200, description = "Plain text task log", body = String, content_type = "text/plain"),
(status = 404, description = "Run or log not found", body = crate::error::ErrorResponse)
)
)]
pub async fn download_run_logs(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<Response> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
let path = state.executor.log_path(&run.task_id, run.id);
let content = tokio::fs::read(&path).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ApiError::run_log_not_found(format!("运行记录 {run_id} 还没有日志文件"))
} else {
ApiError::internal(error)
}
})?;
let mut response = content.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"run-{run_id}.log\"")
.parse::<HeaderValue>()
.map_err(ApiError::internal)?,
);
Ok(response)
}
#[utoipa::path(
get,
path = "/api/v1/events",
tag = "runs",
responses(
(status = 200, description = "Run status and log events", body = String, content_type = "text/event-stream")
)
)]
pub async fn execution_events(
State(state): State<AppState>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
let receiver = state.executor.subscribe();
let events = BroadcastStream::new(receiver).filter_map(|message| async move {
match message {
Ok(event) => {
let event_name = event.event_name();
let data = serde_json::to_string(&event).ok()?;
Some(Ok(Event::default().event(event_name).data(data)))
}
Err(error) => {
tracing::warn!(%error, "SSE subscriber lagged");
Some(Ok(Event::default()
.event("lagged")
.data("{\"reason\":\"event stream lagged\"}")))
}
}
});
let events =
stream::once(async { Ok(Event::default().event("ready").data("{\"status\":\"ok\"}")) })
.chain(events);
Sse::new(events).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)))
}
+205
View File
@@ -0,0 +1,205 @@
use std::{
collections::HashMap,
str::FromStr,
sync::{Arc, Mutex, MutexGuard},
};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
runs::{executor::TaskExecutor, model::RunTrigger},
tasks::{model::TaskAction, model::TaskResponse, repository},
};
struct ScheduledTask {
cancellation: CancellationToken,
handle: JoinHandle<()>,
}
struct SchedulerInner {
executor: TaskExecutor,
shutdown: CancellationToken,
jobs: Mutex<HashMap<String, ScheduledTask>>,
}
#[derive(Clone)]
pub struct Scheduler {
inner: Arc<SchedulerInner>,
}
impl Scheduler {
pub async fn start(pool: SqlitePool, executor: TaskExecutor) -> ApiResult<Self> {
let scheduler = Self {
inner: Arc::new(SchedulerInner {
executor,
shutdown: CancellationToken::new(),
jobs: Mutex::new(HashMap::new()),
}),
};
for task in repository::list_enabled_script_tasks(&pool).await? {
scheduler.refresh(task);
}
Ok(scheduler)
}
pub fn refresh(&self, task: TaskResponse) {
self.cancel(&task.id);
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
return;
}
let cancellation = CancellationToken::new();
let task_for_loop = task.clone();
let executor = self.inner.executor.clone();
let shutdown = self.inner.shutdown.clone();
let cancellation_for_loop = cancellation.clone();
let handle = tokio::spawn(async move {
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
});
lock_jobs(&self.inner.jobs).insert(
task.id,
ScheduledTask {
cancellation,
handle,
},
);
}
pub fn cancel(&self, task_id: &str) {
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
job.cancellation.cancel();
job.handle.abort();
}
}
pub fn is_scheduled(&self, task_id: &str) -> bool {
lock_jobs(&self.inner.jobs).contains_key(task_id)
}
pub fn shutdown(&self) {
self.inner.shutdown.cancel();
let mut jobs = lock_jobs(&self.inner.jobs);
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
jobs.clear();
}
}
impl Drop for SchedulerInner {
fn drop(&mut self) {
self.shutdown.cancel();
let jobs = self
.jobs
.get_mut()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
}
}
async fn schedule_loop(
task: TaskResponse,
executor: TaskExecutor,
cancellation: CancellationToken,
shutdown: CancellationToken,
) {
loop {
let now = Utc::now();
let next = match next_occurrence(&task, now) {
Ok(next) => next,
Err(error) => {
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
return;
}
};
let delay = (next - now).to_std().unwrap_or_default();
tokio::select! {
_ = cancellation.cancelled() => return,
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(delay) => {
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
}
}
}
}
}
fn next_occurrence(task: &TaskResponse, now: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
let timezone = Tz::from_str(&task.timezone)
.map_err(|_| format!("invalid task timezone: {}", task.timezone))?;
let cron = Cron::from_str(&task.cron).map_err(|error| format!("invalid task cron: {error}"))?;
let local_now = now.with_timezone(&timezone);
let next = cron
.find_next_occurrence(&local_now, false)
.map_err(|error| format!("failed to calculate next occurrence: {error}"))?;
Ok(next.with_timezone(&Utc))
}
fn lock_jobs(
jobs: &Mutex<HashMap<String, ScheduledTask>>,
) -> MutexGuard<'_, HashMap<String, ScheduledTask>> {
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
use super::*;
fn task(cron: &str, timezone: &str) -> TaskResponse {
TaskResponse {
id: "schedule_test".to_owned(),
name: "Schedule test".to_owned(),
cron: cron.to_owned(),
timezone: timezone.to_owned(),
enabled: true,
timeout_secs: 30,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy::default(),
action: TaskAction::Script {
working_dir: ".".to_owned(),
interpreter: "cmd.exe".to_owned(),
script: "/C".to_owned(),
args: vec!["exit".to_owned(), "0".to_owned()],
env: Default::default(),
},
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn calculates_next_occurrence_in_task_timezone() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let next = next_occurrence(&task("0 0 9 * * *", "Asia/Shanghai"), now).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap());
assert_eq!(next.hour(), 1);
}
#[test]
fn rejects_unknown_timezone() {
let now = Utc::now();
let error = next_occurrence(&task("0 * * * * *", "Mars/Olympus"), now).unwrap_err();
assert!(error.contains("timezone"));
}
}
+75
View File
@@ -0,0 +1,75 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, Method, header},
middleware::Next,
response::Response,
};
use crate::{
error::{ApiError, ApiResult},
state::AppState,
};
pub const AUTH_HEADER: &str = "x-taskpilot-token";
pub async fn api_guard(
State(state): State<AppState>,
request: Request,
next: Next,
) -> ApiResult<Response> {
let headers = request.headers();
if let Some(origin) = headers.get(header::ORIGIN) {
let origin = origin.to_str().unwrap_or_default();
if !is_local_origin(origin) {
return Err(ApiError::forbidden("请求 Origin 不在本地允许列表中"));
}
}
if !matches!(
*request.method(),
Method::GET | Method::HEAD | Method::OPTIONS
) && !has_valid_token(headers, state.auth_token())
{
return Err(ApiError::forbidden("缺少有效的本地管理令牌"));
}
Ok(next.run(request).await)
}
fn has_valid_token(headers: &HeaderMap, expected: &str) -> bool {
headers
.get(AUTH_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
fn is_local_origin(origin: &str) -> bool {
url::Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_local_origins_only() {
assert!(is_local_origin("http://127.0.0.1:5173"));
assert!(is_local_origin("http://localhost:3000"));
assert!(!is_local_origin("http://127.0.0.1.example.com"));
assert!(!is_local_origin("https://example.com"));
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::{env, path::PathBuf};
use sqlx::SqlitePool;
use crate::{
error::{ApiError, ApiResult},
runs::executor::{LogRetentionConfig, TaskExecutor},
scheduler::Scheduler,
};
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
pub scheduler: Scheduler,
pub executor: TaskExecutor,
auth_token: String,
}
impl AppState {
pub async fn start(pool: SqlitePool, logs_dir: PathBuf) -> ApiResult<Self> {
tokio::fs::create_dir_all(&logs_dir)
.await
.map_err(ApiError::internal)?;
let mut log_config = LogRetentionConfig::default();
if let Ok(value) = env::var("TASK_PILOT_LOG_RETENTION_DAYS")
&& let Ok(days) = value.parse::<u64>()
{
log_config.retention_days = days;
}
if let Ok(value) = env::var("TASK_PILOT_LOG_MAX_TOTAL_MB")
&& let Ok(megabytes) = value.parse::<u64>()
{
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
}
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
executor.cleanup_logs().await.map_err(ApiError::internal)?;
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
Ok(Self {
pool,
scheduler,
executor,
auth_token: uuid::Uuid::new_v4().simple().to_string(),
})
}
pub fn shutdown(&self) {
self.scheduler.shutdown();
self.executor.shutdown();
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub(crate) mod repository;
pub mod routes;
pub mod service;
+176
View File
@@ -0,0 +1,176 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::IntoParams;
use utoipa::ToSchema;
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskActionType {
Script,
Http,
Process,
}
impl TaskActionType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Script => "script",
Self::Http => "http",
Self::Process => "process",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum HttpMethod {
#[serde(rename = "GET")]
Get,
#[serde(rename = "POST")]
Post,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyPolicy {
#[default]
Skip,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_attempts: u32,
#[serde(default)]
pub delay_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskAction {
Script {
working_dir: String,
interpreter: String,
script: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
},
Http {
url: String,
method: HttpMethod,
#[serde(default)]
headers: BTreeMap<String, String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
expected_status: Vec<u16>,
},
Process {
executable: String,
#[serde(default)]
working_dir: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
},
}
impl TaskAction {
pub const fn action_type(&self) -> TaskActionType {
match self {
Self::Script { .. } => TaskActionType::Script,
Self::Http { .. } => TaskActionType::Http,
Self::Process { .. } => TaskActionType::Process,
}
}
}
fn default_timezone() -> String {
DEFAULT_TIMEZONE.to_owned()
}
const fn default_enabled() -> bool {
true
}
const fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreateTaskRequest {
pub id: String,
pub name: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateTaskRequest {
pub name: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TaskResponse {
pub id: String,
pub name: String,
pub cron: String,
pub timezone: String,
pub enabled: bool,
pub timeout_secs: u64,
pub concurrency_policy: ConcurrencyPolicy,
pub retry_policy: RetryPolicy,
pub action: TaskAction,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct TaskListResponse {
pub items: Vec<TaskResponse>,
pub page: u32,
pub page_size: u32,
pub total: u64,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListTasksQuery {
pub q: Option<String>,
pub enabled: Option<bool>,
pub action_type: Option<TaskActionType>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
+284
View File
@@ -0,0 +1,284 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
";
pub struct ListFilters {
pub q: Option<String>,
pub enabled: Option<bool>,
pub action_type: Option<TaskActionType>,
pub page: u32,
pub page_size: u32,
}
#[derive(Debug, FromRow)]
struct TaskRow {
id: String,
name: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
"task {} action type does not match action payload",
row.id
)));
}
Ok(Self {
id: row.id,
name: row.name,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}
pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(now)
.execute(pool)
.await;
if let Err(error) = result {
if is_unique_violation(&error) {
return Err(ApiError::conflict(format!(
"任务标识 {} 已存在",
request.id
)));
}
return Err(error.into());
}
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
})
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
row.map(TaskResponse::try_from).transpose()
}
pub async fn update(
pool: &SqlitePool,
id: &str,
request: &UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found(format!("任务 {id} 不存在")));
}
get(pool, id)
.await?
.ok_or_else(|| ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<bool> {
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<bool> {
let result = sqlx::query("UPDATE tasks SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(Utc::now())
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskListResponse> {
let mut count_builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM tasks");
apply_filters(&mut count_builder, filters);
let total: i64 = count_builder.build_query_scalar().fetch_one(pool).await?;
let offset = i64::from(filters.page.saturating_sub(1)) * i64::from(filters.page_size);
let mut list_builder = QueryBuilder::<Sqlite>::new(format!("SELECT {TASK_COLUMNS} FROM tasks"));
apply_filters(&mut list_builder, filters);
list_builder
.push(" ORDER BY updated_at DESC, id ASC LIMIT ")
.push_bind(i64::from(filters.page_size))
.push(" OFFSET ")
.push_bind(offset);
let rows = list_builder
.build_query_as::<TaskRow>()
.fetch_all(pool)
.await?;
let items = rows
.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()?;
Ok(TaskListResponse {
items,
page: filters.page,
page_size: filters.page_size,
total: u64::try_from(total).map_err(ApiError::internal)?,
})
}
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE enabled = 1 AND action_type = 'script'
ORDER BY updated_at DESC, id ASC",
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()
}
fn apply_filters(builder: &mut QueryBuilder<Sqlite>, filters: &ListFilters) {
builder.push(" WHERE 1 = 1");
if let Some(search) = &filters.q {
let pattern = format!(
"%{}%",
search
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
builder
.push(" AND (id LIKE ")
.push_bind(pattern.clone())
.push(" ESCAPE '\\' OR name LIKE ")
.push_bind(pattern)
.push(" ESCAPE '\\')");
}
if let Some(enabled) = filters.enabled {
builder.push(" AND enabled = ").push_bind(enabled);
}
if let Some(action_type) = filters.action_type {
builder
.push(" AND action_type = ")
.push_bind(action_type.as_str());
}
}
fn is_unique_violation(error: &sqlx::Error) -> bool {
matches!(error, sqlx::Error::Database(database_error) if database_error.is_unique_violation())
}
+167
View File
@@ -0,0 +1,167 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use crate::{
error::{ApiJson, ApiQuery, ApiResult},
state::AppState,
tasks::{
model::{
CreateTaskRequest, ListTasksQuery, TaskListResponse, TaskResponse, UpdateTaskRequest,
},
service,
},
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/tasks", get(list_tasks).post(create_task))
.route(
"/api/v1/tasks/{id}",
get(get_task).put(update_task).delete(delete_task),
)
.route(
"/api/v1/tasks/{id}/enable",
axum::routing::post(enable_task),
)
.route(
"/api/v1/tasks/{id}/disable",
axum::routing::post(disable_task),
)
}
#[utoipa::path(
get,
path = "/api/v1/tasks",
tag = "tasks",
params(ListTasksQuery),
responses(
(status = 200, description = "Paginated task list", body = TaskListResponse)
)
)]
pub async fn list_tasks(
State(state): State<AppState>,
ApiQuery(query): ApiQuery<ListTasksQuery>,
) -> ApiResult<Json<TaskListResponse>> {
Ok(Json(service::list(&state.pool, query).await?))
}
#[utoipa::path(
post,
path = "/api/v1/tasks",
tag = "tasks",
request_body = CreateTaskRequest,
responses(
(status = 201, description = "Task created", body = TaskResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 409, description = "Task ID already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn create_task(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreateTaskRequest>,
) -> ApiResult<(StatusCode, Json<TaskResponse>)> {
let task = service::create(&state.pool, request).await?;
state.scheduler.refresh(task.clone());
Ok((StatusCode::CREATED, Json(task)))
}
#[utoipa::path(
get,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task detail", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
Ok(Json(service::get(&state.pool, &id).await?))
}
#[utoipa::path(
put,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
request_body = UpdateTaskRequest,
responses(
(status = 200, description = "Task updated", body = TaskResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn update_task(
State(state): State<AppState>,
Path(id): Path<String>,
ApiJson(request): ApiJson<UpdateTaskRequest>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::update(&state.pool, &id, request).await?;
state.scheduler.refresh(task.clone());
Ok(Json(task))
}
#[utoipa::path(
delete,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 204, description = "Task deleted"),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
state.scheduler.cancel(&id);
state.executor.cancel_task(&id);
service::delete(&state.pool, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/enable",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task enabled", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn enable_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::set_enabled(&state.pool, &id, true).await?;
state.scheduler.refresh(task.clone());
Ok(Json(task))
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/disable",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task disabled", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn disable_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::set_enabled(&state.pool, &id, false).await?;
state.scheduler.cancel(&id);
Ok(Json(task))
}
+369
View File
@@ -0,0 +1,369 @@
use std::{collections::BTreeMap, str::FromStr};
use axum::http::{HeaderName, HeaderValue};
use chrono::Utc;
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use url::Url;
use crate::{error::ApiResult, tasks::model::*};
use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
const MAX_ENV_ENTRIES: usize = 128;
const MAX_ENV_KEY_LEN: usize = 128;
const MAX_ENV_VALUE_LEN: usize = 8_192;
const MAX_HTTP_BODY_LEN: usize = 1_048_576;
const DEFAULT_PAGE_SIZE: u32 = 20;
const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_task_id(&request.id)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::create(pool, &request).await
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<TaskResponse> {
repository::get(pool, id)
.await?
.ok_or_else(|| crate::error::ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn update(
pool: &SqlitePool,
id: &str,
mut request: UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::update(pool, id, &request).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
if repository::delete(pool, id).await? {
Ok(())
} else {
Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)))
}
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<TaskResponse> {
if !repository::set_enabled(pool, id, enabled).await? {
return Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)));
}
get(pool, id).await
}
pub async fn list(pool: &SqlitePool, query: ListTasksQuery) -> ApiResult<TaskListResponse> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query
.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let filters = ListFilters {
q: query
.q
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
enabled: query.enabled,
action_type: query.action_type,
page,
page_size,
};
repository::list(pool, &filters).await
}
fn validate_task_id(id: &str) -> ApiResult<()> {
if id.is_empty() || id.len() > MAX_TASK_ID_LEN {
return Err(crate::error::ApiError::validation(
"id",
format!("任务标识长度必须在 1 到 {MAX_TASK_ID_LEN} 个字符之间"),
));
}
if !id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return Err(crate::error::ApiError::validation(
"id",
"任务标识只能包含 ASCII 字母、数字、下划线和连字符",
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
"name",
format!("任务名称长度必须在 1 到 {MAX_TASK_NAME_LEN} 个字符之间"),
));
}
if timeout_secs == 0 || timeout_secs > MAX_TIMEOUT_SECS {
return Err(crate::error::ApiError::validation(
"timeout_secs",
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
})?;
let cron = Cron::from_str(cron_expression).map_err(|error| {
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
})?;
cron.find_next_occurrence(&Utc::now(), false)
.map_err(|error| {
crate::error::ApiError::validation(
"cron",
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
)
})?;
Ok(())
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
interpreter,
script,
args,
env,
} => {
validate_required("action.working_dir", working_dir)?;
validate_required("action.interpreter", interpreter)?;
validate_required("action.script", script)?;
validate_args(args)?;
validate_env(env)?;
}
TaskAction::Http {
url,
headers,
body,
expected_status,
..
} => {
let parsed_url = Url::parse(url).map_err(|error| {
crate::error::ApiError::validation("action.url", format!("URL 无效: {error}"))
})?;
if !matches!(parsed_url.scheme(), "http" | "https") || parsed_url.host_str().is_none() {
return Err(crate::error::ApiError::validation(
"action.url",
"URL 必须使用 http 或 https 协议并包含主机名",
));
}
validate_headers(headers)?;
if body
.as_ref()
.is_some_and(|value| value.len() > MAX_HTTP_BODY_LEN)
{
return Err(crate::error::ApiError::validation(
"action.body",
"HTTP Body 不能超过 1 MiB",
));
}
if expected_status
.iter()
.any(|status| !(100..=599).contains(status))
{
return Err(crate::error::ApiError::validation(
"action.expected_status",
"期望状态码必须在 100 到 599 之间",
));
}
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
validate_required("action.executable", executable)?;
if let Some(working_dir) = working_dir {
validate_required("action.working_dir", working_dir)?;
}
validate_args(args)?;
validate_env(env)?;
}
}
Ok(())
}
fn validate_required(field: &str, value: &str) -> ApiResult<()> {
if value.trim().is_empty() {
Err(crate::error::ApiError::validation(field, "该字段不能为空"))
} else {
Ok(())
}
}
fn validate_args(args: &[String]) -> ApiResult<()> {
if args.len() > MAX_ARGS {
return Err(crate::error::ApiError::validation(
"action.args",
format!("参数数量不能超过 {MAX_ARGS} 个"),
));
}
if args.iter().any(|argument| argument.len() > MAX_ARG_LEN) {
return Err(crate::error::ApiError::validation(
"action.args",
format!("单个参数不能超过 {MAX_ARG_LEN} 个字符"),
));
}
Ok(())
}
fn validate_env(env: &BTreeMap<String, String>) -> ApiResult<()> {
if env.len() > MAX_ENV_ENTRIES {
return Err(crate::error::ApiError::validation(
"action.env",
format!("环境变量数量不能超过 {MAX_ENV_ENTRIES} 个"),
));
}
for (key, value) in env {
if key.is_empty()
|| key.len() > MAX_ENV_KEY_LEN
|| key.contains(['=', '\0'])
|| value.len() > MAX_ENV_VALUE_LEN
|| value.contains('\0')
{
return Err(crate::error::ApiError::validation(
"action.env",
"环境变量名称或值无效",
));
}
}
Ok(())
}
fn validate_headers(headers: &BTreeMap<String, String>) -> ApiResult<()> {
for (name, value) in headers {
HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 名称无效")
})?;
HeaderValue::from_str(value).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 值无效")
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use axum::response::IntoResponse;
use super::*;
fn script_action() -> TaskAction {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
env: BTreeMap::new(),
}
}
#[test]
fn accepts_valid_task_fields() {
validate_task_id("db_backup_daily").expect("task id should be valid");
validate_common(
"每日数据库备份",
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
}
#[test]
fn rejects_invalid_cron() {
let error = validate_common(
"Invalid cron",
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
assert_eq!(error.into_response().status(), 400);
}
#[test]
fn rejects_empty_script_path() {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
env: BTreeMap::new(),
};
validate_action(&action).expect_err("empty script path should fail");
}
}
+74
View File
@@ -0,0 +1,74 @@
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::Value;
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
#[tokio::test]
async fn health_endpoint_returns_service_metadata() {
let (app, _temp_dir) = test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/health")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "task-pilot-backend");
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn openapi_endpoint_describes_health_api() {
let (app, _temp_dir) = test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/openapi.json")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
assert_eq!(json["info"]["title"], "TaskPilot API");
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
}
+650
View File
@@ -0,0 +1,650 @@
use std::time::Duration;
use axum::{
Json, Router,
body::Body,
http::{HeaderMap, Method, Request, StatusCode, header},
response::Response,
routing::{get, post},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use task_pilot_backend::database;
use task_pilot_backend::state::AppState;
use tempfile::TempDir;
use tokio::time::sleep;
use tokio::{net::TcpListener, task::JoinHandle};
use tower::ServiceExt;
use task_pilot_backend::runs::executor::ExecutionEvent;
struct TestApp {
router: Router,
state: AppState,
_temp_dir: TempDir,
}
async fn test_app() -> TestApp {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let state = AppState::start(pool, temp_dir.path().join("logs"))
.await
.expect("app state should start");
let router = task_pilot_backend::router(state.clone());
TestApp {
router,
state,
_temp_dir: temp_dir,
}
}
async fn send_json(router: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
None
} else {
Some(fetch_api_token(router).await)
};
let body = body
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
.unwrap_or_else(Body::empty);
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = builder.body(body).expect("request should build");
router
.clone()
.oneshot(request)
.await
.expect("router should respond")
}
async fn fetch_api_token(router: &Router) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/api/v1/session")
.body(Body::empty())
.expect("session request should build");
let response = router
.clone()
.oneshot(request)
.await
.expect("session endpoint should respond");
assert_eq!(response.status(), StatusCode::OK);
response_json(response).await["token"]
.as_str()
.expect("session token should be a string")
.to_owned()
}
async fn response_json(response: Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
serde_json::from_slice(&body).expect("response should be JSON")
}
async fn connect_file(path: &std::path::Path) -> SqlitePool {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("file database should open");
database::migrate(&pool)
.await
.expect("migrations should run");
pool
}
fn script_task(
id: &str,
working_dir: &str,
interpreter: &str,
script: &str,
args: Vec<&str>,
cron: &str,
timeout_secs: u64,
) -> Value {
json!({
"id": id,
"name": id,
"cron": cron,
"timeout_secs": timeout_secs,
"action": {
"type": "script",
"working_dir": working_dir,
"interpreter": interpreter,
"script": script,
"args": args,
"env": {}
}
})
}
fn http_task(url: &str) -> Value {
json!({
"id": "http_task",
"name": "HTTP task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "http",
"url": url,
"method": "POST",
"headers": {
"X-Test": "yes"
},
"body": "{\"ping\":true}",
"expected_status": [202]
}
})
}
async fn spawn_http_target() -> (String, JoinHandle<()>) {
async fn check(headers: HeaderMap, body: String) -> StatusCode {
if headers.get("x-test").and_then(|value| value.to_str().ok()) == Some("yes")
&& body.contains("\"ping\":true")
{
StatusCode::ACCEPTED
} else {
StatusCode::BAD_REQUEST
}
}
let app = Router::new()
.route("/check", post(check))
.route("/health", get(|| async { Json(json!({"status":"ok"})) }));
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("HTTP test listener should bind");
let address = listener
.local_addr()
.expect("listener address should exist");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{address}"), handle)
}
async fn wait_for_run(router: &Router, run_id: i64) -> Value {
for _ in 0..100 {
let response =
send_json(router, Method::GET, &format!("/api/v1/runs/{run_id}"), None).await;
assert_eq!(response.status(), StatusCode::OK);
let run = response_json(response).await;
if matches!(
run["status"].as_str(),
Some("succeeded" | "failed" | "timed_out" | "cancelled" | "skipped")
) {
return run;
}
sleep(Duration::from_millis(100)).await;
}
panic!("run {run_id} did not reach a terminal state");
}
#[tokio::test]
async fn manual_script_run_captures_exit_code_and_logs() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"echo_task",
&working_dir,
"cmd.exe",
"/C",
vec!["echo", "hello-from-task"],
"0 0 2 * * *",
5,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let run_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/echo_task/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let run_id = run["id"].as_i64().expect("run id should be an integer");
assert_eq!(run["status"], "queued");
let completed = wait_for_run(&app.router, run_id).await;
assert_eq!(completed["status"], "succeeded");
assert_eq!(completed["exit_code"], 0);
assert_eq!(completed["trigger"], "manual");
let log_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs"),
None,
)
.await;
assert_eq!(log_response.status(), StatusCode::OK);
let log = response_json(log_response).await;
assert_eq!(log["complete"], true);
assert!(log["content"].as_str().unwrap().contains("hello-from-task"));
let filtered_response = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/echo_task/runs?status=succeeded&trigger=manual",
None,
)
.await;
let filtered = response_json(filtered_response).await;
assert_eq!(filtered["total"], 1);
let download_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs/download"),
None,
)
.await;
assert_eq!(download_response.status(), StatusCode::OK);
assert!(
download_response
.headers()
.get(header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("attachment")
);
app.state.shutdown();
}
#[tokio::test]
async fn http_and_process_executors_run_through_the_api() {
let app = test_app().await;
let (http_base, http_handle) = spawn_http_target().await;
let create_http = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(http_task(&format!("{http_base}/check"))),
)
.await;
assert_eq!(create_http.status(), StatusCode::CREATED);
let http_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/http_task/run",
None,
)
.await;
assert_eq!(http_run.status(), StatusCode::ACCEPTED);
let http_run_id = response_json(http_run).await["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, http_run_id).await["status"],
"succeeded"
);
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let process_task = json!({
"id": "process_task",
"name": "Process task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "process",
"executable": "cmd.exe",
"working_dir": working_dir,
"args": ["/C", "echo", "process-output"],
"env": {}
}
});
let create_process = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(process_task),
)
.await;
assert_eq!(create_process.status(), StatusCode::CREATED);
let process_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/process_task/run",
None,
)
.await;
let process_run_id = response_json(process_run).await["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, process_run_id).await["status"],
"succeeded"
);
let process_log = response_json(
send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{process_run_id}/logs"),
None,
)
.await,
)
.await;
assert!(
process_log["content"]
.as_str()
.unwrap()
.contains("process-output")
);
http_handle.abort();
app.state.shutdown();
}
#[tokio::test]
async fn running_task_can_be_cancelled_and_sensitive_output_is_redacted() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = json!({
"id": "cancel_task",
"name": "Cancel task",
"cron": "0 0 2 * * *",
"timeout_secs": 30,
"action": {
"type": "script",
"working_dir": working_dir,
"interpreter": "cmd.exe",
"script": "/C",
"args": ["ping", "-t", "127.0.0.1"],
"env": {
"API_TOKEN": "super-secret-token"
}
}
});
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let mut events = app.state.executor.subscribe();
let run_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/cancel_task/run",
None,
)
.await;
let run_id = response_json(run_response).await["id"].as_i64().unwrap();
let cancel_response = send_json(
&app.router,
Method::POST,
&format!("/api/v1/runs/{run_id}/cancel"),
None,
)
.await;
assert_eq!(cancel_response.status(), StatusCode::ACCEPTED);
assert_eq!(
wait_for_run(&app.router, run_id).await["status"],
"cancelled"
);
let mut saw_run_event = false;
let mut saw_log_event = false;
for _ in 0..20 {
let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
.await
.expect("SSE event should arrive")
.expect("SSE channel should stay open");
match event {
ExecutionEvent::Run { run } if run.id == run_id => saw_run_event = true,
ExecutionEvent::Log {
run_id: event_run_id,
..
} if event_run_id == run_id => saw_log_event = true,
_ => {}
}
if saw_run_event && saw_log_event {
break;
}
}
assert!(saw_run_event);
assert!(saw_log_event);
let terminal_cancel = send_json(
&app.router,
Method::POST,
&format!("/api/v1/runs/{run_id}/cancel"),
None,
)
.await;
assert_eq!(terminal_cancel.status(), StatusCode::CONFLICT);
let output_task = json!({
"id": "redaction_task",
"name": "Redaction task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "script",
"working_dir": app._temp_dir.path().to_string_lossy(),
"interpreter": "cmd.exe",
"script": "/C",
"args": ["echo", "%API_TOKEN%"],
"env": {
"API_TOKEN": "super-secret-token"
}
}
});
let create_output = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(output_task),
)
.await;
assert_eq!(create_output.status(), StatusCode::CREATED);
let output_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/redaction_task/run",
None,
)
.await;
let output_run_id = response_json(output_run).await["id"].as_i64().unwrap();
wait_for_run(&app.router, output_run_id).await;
let output_log = response_json(
send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{output_run_id}/logs"),
None,
)
.await,
)
.await;
let content = output_log["content"].as_str().unwrap();
assert!(content.contains("[REDACTED]"));
assert!(!content.contains("super-secret-token"));
app.state.shutdown();
}
#[tokio::test]
async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
let app = test_app().await;
let marker_path = app._temp_dir.path().join("child-survived.txt");
let marker = marker_path.to_string_lossy().replace('\'', "''");
let script_path = app._temp_dir.path().join("timeout-test.ps1");
let script = format!(
"$marker = '{marker}'\n\
$child = \"Start-Sleep -Seconds 3; Set-Content -LiteralPath '{marker}' -Value 'alive'\"\n\
Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile', '-Command', $child -WindowStyle Hidden\n\
Start-Sleep -Seconds 30\n"
);
std::fs::write(&script_path, script).expect("test script should be written");
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"timeout_task",
&working_dir,
"powershell.exe",
"-NoProfile",
vec!["-File", &script_path.to_string_lossy()],
"0 0 2 * * *",
1,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let first_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/timeout_task/run",
None,
)
.await;
assert_eq!(first_response.status(), StatusCode::ACCEPTED);
let first_run = response_json(first_response).await;
let first_run_id = first_run["id"].as_i64().unwrap();
let second_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/timeout_task/run",
None,
)
.await;
assert_eq!(second_response.status(), StatusCode::ACCEPTED);
let second_run = response_json(second_response).await;
assert_eq!(second_run["status"], "skipped");
let completed = wait_for_run(&app.router, first_run_id).await;
assert_eq!(completed["status"], "timed_out");
assert!(
completed["error_message"]
.as_str()
.unwrap()
.contains("已终止进程树")
);
sleep(Duration::from_secs(3)).await;
assert!(
!marker_path.exists(),
"child process survived after job termination"
);
app.state.shutdown();
}
#[tokio::test]
async fn scheduler_runs_tasks_and_tracks_enable_state() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"scheduled_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"*/1 * * * * *",
5,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
let mut scheduled_run = None;
for _ in 0..50 {
let response = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/scheduled_task/runs",
None,
)
.await;
let runs = response_json(response).await;
if let Some(item) = runs["items"]
.as_array()
.and_then(|items| items.first())
.filter(|item| item["trigger"] == "schedule")
{
scheduled_run = Some(item.clone());
break;
}
sleep(Duration::from_millis(100)).await;
}
let scheduled_run = scheduled_run.expect("scheduler should trigger a run");
let run_id = scheduled_run["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, run_id).await["status"],
"succeeded"
);
let disable_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/scheduled_task/disable",
None,
)
.await;
assert_eq!(disable_response.status(), StatusCode::OK);
assert!(!app.state.scheduler.is_scheduled("scheduled_task"));
let enable_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/scheduled_task/enable",
None,
)
.await;
assert_eq!(enable_response.status(), StatusCode::OK);
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
app.state.shutdown();
}
#[tokio::test]
async fn scheduler_restores_enabled_tasks_after_restart() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let database_path = temp_dir.path().join("restart.db");
let first_pool = connect_file(&database_path).await;
let first_state = AppState::start(first_pool.clone(), temp_dir.path().join("logs-first"))
.await
.expect("first app state should start");
let first_router = task_pilot_backend::router(first_state.clone());
let working_dir = temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"restored_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let create_response = send_json(&first_router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
first_state.shutdown();
drop(first_router);
drop(first_state);
first_pool.close().await;
let second_pool = connect_file(&database_path).await;
let second_state = AppState::start(second_pool.clone(), temp_dir.path().join("logs-second"))
.await
.expect("second app state should start");
assert!(second_state.scheduler.is_scheduled("restored_task"));
second_state.shutdown();
second_pool.close().await;
}
+57
View File
@@ -0,0 +1,57 @@
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use http_body_util::BodyExt;
use task_pilot_backend::state::AppState;
use tempfile::TempDir;
use tower::ServiceExt;
#[tokio::test]
async fn serves_spa_and_keeps_api_404_as_json() {
let temp = TempDir::new().unwrap();
let static_dir = temp.path().join("web");
std::fs::create_dir_all(&static_dir).unwrap();
std::fs::write(
static_dir.join("index.html"),
"<!doctype html><title>TaskPilot SPA</title>",
)
.unwrap();
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.unwrap();
let state = AppState::start(pool, temp.path().join("logs"))
.await
.unwrap();
let app = task_pilot_backend::router_with_static(state.clone(), static_dir);
let page = app
.clone()
.oneshot(
Request::builder()
.uri("/tasks/example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(page.status(), StatusCode::OK);
let page_body = page.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&page_body).contains("TaskPilot SPA"));
let api_not_found = app
.oneshot(
Request::builder()
.uri("/api/v1/does-not-exist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(api_not_found.status(), StatusCode::NOT_FOUND);
assert_eq!(
api_not_found.headers().get(header::CONTENT_TYPE),
Some(&header::HeaderValue::from_static("application/json"))
);
state.shutdown();
}
+354
View File
@@ -0,0 +1,354 @@
use axum::{
Router,
body::Body,
http::{Method, Request, StatusCode, header},
response::Response,
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use task_pilot_backend::database;
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn connect_file(path: &std::path::Path) -> SqlitePool {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("file database should open");
database::migrate(&pool)
.await
.expect("migrations should run");
pool
}
async fn send_json(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
None
} else {
Some(fetch_api_token(app).await)
};
let body = body
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
.unwrap_or_else(Body::empty);
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = builder.body(body).expect("request should build");
app.clone()
.oneshot(request)
.await
.expect("router should respond")
}
async fn fetch_api_token(app: &Router) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/api/v1/session")
.body(Body::empty())
.expect("session request should build");
let response = app
.clone()
.oneshot(request)
.await
.expect("session endpoint should respond");
assert_eq!(response.status(), StatusCode::OK);
response_json(response).await["token"]
.as_str()
.expect("session token should be a string")
.to_owned()
}
async fn response_json(response: Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
serde_json::from_slice(&body).expect("response should be JSON")
}
fn script_task(id: &str, name: &str) -> Value {
json!({
"id": id,
"name": name,
"cron": "0 0 2 * * *",
"enabled": true,
"timeout_secs": 300,
"action": {
"type": "script",
"working_dir": "D:\\Projects\\sync",
"interpreter": "python.exe",
"script": "main.py",
"args": ["--mode", "full"],
"env": {
"PYTHONUNBUFFERED": "1"
}
}
})
}
#[tokio::test]
async fn task_crud_flow_works_end_to_end() {
let (app, _temp_dir) = test_app().await;
let create_response = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("sync_data", "Data sync")),
)
.await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let created = response_json(create_response).await;
assert_eq!(created["id"], "sync_data");
assert_eq!(created["timezone"], "Asia/Shanghai");
assert_eq!(created["action"]["type"], "script");
let get_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
assert_eq!(get_response.status(), StatusCode::OK);
assert_eq!(response_json(get_response).await["name"], "Data sync");
let list_response = send_json(
&app,
Method::GET,
"/api/v1/tasks?q=sync&enabled=true&action_type=script&page=1&page_size=10",
None,
)
.await;
assert_eq!(list_response.status(), StatusCode::OK);
let listed = response_json(list_response).await;
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["id"], "sync_data");
let update_response = send_json(
&app,
Method::PUT,
"/api/v1/tasks/sync_data",
Some(json!({
"name": "Updated data sync",
"cron": "*/30 * * * * *",
"enabled": false,
"timeout_secs": 120,
"action": {
"type": "http",
"url": "https://example.com/health",
"method": "GET",
"headers": {},
"expected_status": [200]
}
})),
)
.await;
assert_eq!(update_response.status(), StatusCode::OK);
let updated = response_json(update_response).await;
assert_eq!(updated["name"], "Updated data sync");
assert_eq!(updated["enabled"], false);
assert_eq!(updated["action"]["type"], "http");
let delete_response = send_json(&app, Method::DELETE, "/api/v1/tasks/sync_data", None).await;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let missing_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
let missing = response_json(missing_response).await;
assert_eq!(missing["code"], "TASK_NOT_FOUND");
}
#[tokio::test]
async fn duplicate_task_id_returns_conflict() {
let (app, _temp_dir) = test_app().await;
let first = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("duplicate", "First")),
)
.await;
assert_eq!(first.status(), StatusCode::CREATED);
let second = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("duplicate", "Second")),
)
.await;
assert_eq!(second.status(), StatusCode::CONFLICT);
let body = response_json(second).await;
assert_eq!(body["code"], "TASK_ID_CONFLICT");
assert_eq!(body["field"], "id");
}
#[tokio::test]
async fn invalid_cron_returns_structured_validation_error() {
let (app, _temp_dir) = test_app().await;
let mut request = script_task("invalid_cron", "Invalid cron");
request["cron"] = Value::String("*/0 * * * * *".to_owned());
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(request)).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["code"], "VALIDATION_ERROR");
assert_eq!(body["field"], "cron");
}
#[tokio::test]
async fn malformed_json_and_query_return_structured_errors() {
let (app, _temp_dir) = test_app().await;
let token = fetch_api_token(&app).await;
let malformed_request = Request::builder()
.method(Method::POST)
.uri("/api/v1/tasks")
.header(header::CONTENT_TYPE, "application/json")
.header("x-taskpilot-token", token)
.body(Body::from("{not-json"))
.expect("request should build");
let malformed_response = app
.clone()
.oneshot(malformed_request)
.await
.expect("router should respond");
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response_json(malformed_response).await["code"],
"INVALID_JSON"
);
let invalid_query = send_json(&app, Method::GET, "/api/v1/tasks?enabled=maybe", None).await;
assert_eq!(invalid_query.status(), StatusCode::BAD_REQUEST);
assert_eq!(response_json(invalid_query).await["code"], "INVALID_QUERY");
}
#[tokio::test]
async fn rejects_cross_origin_and_missing_management_token() {
let (app, _temp_dir) = test_app().await;
let cross_origin = Request::builder()
.method(Method::GET)
.uri("/api/v1/tasks")
.header(header::ORIGIN, "https://evil.example")
.body(Body::empty())
.expect("request should build");
let response = app
.clone()
.oneshot(cross_origin)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let missing_token = Request::builder()
.method(Method::POST)
.uri("/api/v1/tasks")
.header(header::ORIGIN, "http://127.0.0.1:5173")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&script_task("blocked_task", "Blocked"))
.expect("JSON should serialize"),
))
.expect("request should build");
let response = app
.oneshot(missing_token)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response_json(response).await["code"], "FORBIDDEN");
}
#[tokio::test]
async fn list_supports_pagination_and_escaped_search() {
let (app, _temp_dir) = test_app().await;
for (id, name) in [
("sync_one", "Sync one"),
("sync_two", "Sync two"),
("other", "100% complete"),
] {
let response = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task(id, name)),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
}
let first_page = send_json(
&app,
Method::GET,
"/api/v1/tasks?q=sync&page=1&page_size=1",
None,
)
.await;
let first_page = response_json(first_page).await;
assert_eq!(first_page["page"], 1);
assert_eq!(first_page["page_size"], 1);
assert_eq!(first_page["total"], 2);
assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1));
let escaped = send_json(&app, Method::GET, "/api/v1/tasks?q=%25", None).await;
let escaped = response_json(escaped).await;
assert_eq!(escaped["total"], 1);
assert_eq!(escaped["items"][0]["id"], "other");
}
#[tokio::test]
async fn tasks_survive_database_reconnect() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let database_path = temp_dir.path().join("task_pilot_test.db");
let first_pool = connect_file(&database_path).await;
let first_app = task_pilot_backend::app(first_pool.clone(), temp_dir.path().join("logs-first"))
.await
.expect("first app should start");
let create_response = send_json(
&first_app,
Method::POST,
"/api/v1/tasks",
Some(script_task("persistent_task", "Persistent task")),
)
.await;
assert_eq!(create_response.status(), StatusCode::CREATED);
first_pool.close().await;
drop(first_app);
let second_pool = connect_file(&database_path).await;
let second_app =
task_pilot_backend::app(second_pool.clone(), temp_dir.path().join("logs-second"))
.await
.expect("second app should start");
let get_response = send_json(
&second_app,
Method::GET,
"/api/v1/tasks/persistent_task",
None,
)
.await;
assert_eq!(get_response.status(), StatusCode::OK);
assert_eq!(response_json(get_response).await["name"], "Persistent task");
second_pool.close().await;
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
# TaskPilot Windows 本地版
## 启动
```powershell
.\start.ps1
```
然后访问 <http://127.0.0.1:3000>。
需要自动打开浏览器时:
```powershell
.\start.ps1 -OpenBrowser
```
## 数据目录
- SQLite 数据库:`data/task_pilot.db`
- 运行日志:`logs/{task_id}/{run_id}.log`
## 配置
```powershell
$env:TASK_PILOT_PORT = "3000"
$env:TASK_PILOT_DATABASE_URL = "sqlite://data/task_pilot.db"
$env:TASK_PILOT_LOG_DIR = "logs"
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
$env:TASK_PILOT_STATIC_DIR = "web"
```
## 安全边界
- 服务只监听 `127.0.0.1`。
- 浏览器跨站 Origin 会被拒绝。
- 写操作需要运行期随机管理令牌。
- 管理令牌在进程重启后自动更换。
## 故障排查
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
- 页面无法打开时,确认 `web/index.html` 存在。
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
+34
View File
@@ -0,0 +1,34 @@
# ADR-0001:Cron 解析与任务调度
- 状态:已接受
- 日期:2026-10-18
## 背景
TaskPilot 需要校验 5 位和 6 位 Cron 表达式、计算未来执行时间,并支持在任务创建、编辑、启停后动态调整调度。
## 决策
使用 `croner` 负责 Cron 表达式解析和下次执行时间计算,使用 Tokio 定时循环负责任务注册、等待和触发。
第一阶段验证结果:
- 支持 5 位分钟级和 6 位秒级表达式。
- 支持 `@hourly` 等别名。
- 支持月份和星期名称,星期 `7` 与 `0` 均表示星期日。
- 默认按 POSIX 语义处理日和星期的组合。
- 非法的 `*/0` 会被拒绝,不会形成无限循环。
- 可从指定时间计算下一次及连续多次执行时间。
## 原因
- `croner` 专注解析和日期计算,不绑定调度运行时。
- 自建 Tokio 循环可以按 SQLite 中的任务状态动态注册和取消任务。
- 避免将持久化任务 ID 和外部调度器 ID 做双重状态映射。
- 后续可以替换触发层而无需更换 Cron 表达式语义。
## 影响
- 调度器需要自行维护任务句柄、取消信号和执行并发策略。
- 必须用可控时间测试覆盖等待逻辑、任务更新和进程关闭。
- 时区支持继续使用 Chrono 的时区能力,Phase 2 引入 `chrono-tz` 存储 IANA 时区。
+21
View File
@@ -0,0 +1,21 @@
# ADR-0002:前后端开发与生产托管
- 状态:已接受
- 日期:2026-10-18
## 决策
开发环境由 Vite 提供 React 页面,并将 `/api` 代理到 `http://127.0.0.1:3000` 的 Axum 服务。
生产环境先构建 React 静态资源,再由 Axum 托管构建产物。未知的非 `/api` 路由回退到 `index.html`,以支持 React Router。
## 原因
- 开发时保留 Vite HMR 和快速反馈。
- 生产时只发布一个后端可执行程序及静态资源目录,降低 Windows 本地部署复杂度。
- 同源访问可以减少 CORS 和 Cookie/令牌配置的复杂度。
## 影响
- 后端必须明确区分 API 404 和前端路由回退。
- 静态资源托管将在 Phase 6 交付时接入,Phase 1 只完成开发代理。
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"react/incompatible-library": "off"
}
}
+151
View File
@@ -0,0 +1,151 @@
import { expect, test, type APIRequestContext } from '@playwright/test'
const taskPayload = {
id: 'e2e_echo',
name: 'E2E echo',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 10,
action: {
type: 'script',
working_dir: '.',
interpreter: 'cmd.exe',
script: '/C',
args: ['echo', 'e2e-output'],
env: {},
},
}
async function authHeaders(request: APIRequestContext) {
const response = await request.get('/api/v1/session')
expect(response.ok()).toBeTruthy()
const session = (await response.json()) as { token: string }
return { 'X-TaskPilot-Token': session.token }
}
test('manages a task and reads a real run log', async ({ page, request }) => {
const headers = await authHeaders(request)
const existing = await request.get('/api/v1/tasks?page=1&page_size=100')
expect(existing.ok()).toBeTruthy()
const existingBody = (await existing.json()) as {
items: Array<{ id: string }>
}
for (const task of existingBody.items) {
await request.delete(`/api/v1/tasks/${task.id}`, { headers })
}
const createResponse = await request.post('/api/v1/tasks', {
data: taskPayload,
headers,
})
expect(createResponse.status()).toBe(201)
await page.goto('/')
await expect(page.getByText('E2E echo')).toBeVisible()
await page.screenshot({
path: 'test-results/dashboard-desktop.png',
fullPage: true,
})
await page.getByRole('button', { name: '+ 新建任务' }).click()
const newTaskDialog = page.getByRole('dialog', { name: '新建定时任务' })
await expect(newTaskDialog).toBeVisible()
await expect(newTaskDialog.getByLabel('任务标识')).toBeFocused()
await page.screenshot({
path: 'test-results/dashboard-task-editor.png',
fullPage: true,
})
await page.keyboard.press('Escape')
await expect(newTaskDialog).toBeHidden()
await page.getByRole('button', { name: '运行' }).click()
const drawer = page.getByRole('dialog', { name: 'E2E echo' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText('e2e-output')).toBeVisible()
await expect(drawer.locator('.status-succeeded').first()).toBeVisible()
await page.screenshot({
path: 'test-results/dashboard-log-drawer.png',
fullPage: true,
})
await drawer.getByRole('button', { name: '关闭运行日志' }).click()
await page.getByRole('button', { name: '编辑' }).click()
const editor = page.getByRole('dialog', {
name: '编辑任务 [e2e_echo]',
})
await expect(editor).toBeVisible()
const nameInput = editor.getByLabel('任务名称')
await nameInput.fill('E2E updated')
await editor.getByRole('button', { name: '保存修改' }).click()
await expect(page.getByText('E2E updated')).toBeVisible()
await page.getByRole('button', { name: '停用' }).click()
await expect(
page
.getByRole('row', { name: /E2E updated/ })
.locator('.status-disabled'),
).toBeVisible()
await page.getByRole('button', { name: '删除' }).click()
await page
.getByRole('alertdialog')
.getByRole('button', { name: '确认删除' })
.click()
await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible()
})
test('keeps the dashboard usable on a narrow viewport', async ({
page,
request,
}) => {
const headers = await authHeaders(request)
await request.post('/api/v1/tasks', {
headers,
data: {
...taskPayload,
id: 'e2e_mobile',
name: 'Mobile task',
},
})
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await expect(page.getByText('Mobile task')).toBeVisible()
await expect(page.getByRole('button', { name: '+ 新建任务' })).toBeVisible()
await page.screenshot({
path: 'test-results/dashboard-mobile.png',
fullPage: true,
})
})
test('cancels a running task from the log drawer', async ({
page,
request,
}) => {
const headers = await authHeaders(request)
const response = await request.post('/api/v1/tasks', {
headers,
data: {
...taskPayload,
id: 'e2e_cancel',
name: 'Cancel task',
timeout_secs: 30,
action: {
...taskPayload.action,
args: ['ping', '-t', '127.0.0.1'],
},
},
})
expect(response.status()).toBe(201)
await page.goto('/')
await page
.getByRole('row', { name: /Cancel task/ })
.getByRole('button', { name: '运行' })
.click()
const drawer = page.getByRole('dialog', { name: 'Cancel task' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText('SSE 实时连接')).toBeVisible()
await drawer.getByRole('button', { name: '取消运行' }).click()
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
})
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="TaskPilot - Windows 本地定时任务管理平台"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>TaskPilot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{
"name": "taskpilot-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "[email protected]",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File ../scripts/e2e.ps1",
"generate:api": "openapi-typescript ../contracts/openapi.json -o src/api/generated.ts",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.9.2",
"@tanstack/react-query": "^5.104.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.90.0",
"react-router-dom": "^7.18.4",
"zod": "^4.6.7"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.3",
"openapi-typescript": "^7.13.0",
"oxlint": "^1.81.0",
"typescript": "~5.9.3",
"vite": "^8.3.4",
"vitest": "^5.0.5"
},
"volta": {
"node": "24.12.0",
"pnpm": "11.20.0"
}
}
+53
View File
@@ -0,0 +1,53 @@
import { mkdirSync } from 'node:fs'
import { defineConfig, devices } from '@playwright/test'
mkdirSync(new URL('../.e2e-data/', import.meta.url), { recursive: true })
const externalServers = process.env.TASKPILOT_E2E_EXTERNAL === '1'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: {
timeout: 8_000,
},
fullyParallel: false,
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:5173',
channel: 'chrome',
trace: 'retain-on-failure',
},
webServer: externalServers
? undefined
: [
{
command: 'backend\\target\\debug\\task-pilot-backend.exe',
cwd: '..',
url: 'http://127.0.0.1:3000/api/v1/health',
timeout: 120_000,
reuseExistingServer: false,
env: {
TASK_PILOT_DATABASE_URL: 'sqlite://.e2e-data/task_pilot.db',
TASK_PILOT_LOG_DIR: '.e2e-data/logs',
},
},
{
command: 'node node_modules/vite/bin/vite.js --host 127.0.0.1',
cwd: '.',
url: 'http://127.0.0.1:5173',
timeout: 120_000,
reuseExistingServer: false,
},
],
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 1000 },
},
},
],
})
+1894
View File
File diff suppressed because it is too large Load Diff
+1251
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import type { Task, TaskListResponse } from './api/client'
import App from './App'
const task: Task = {
id: 'echo_task',
name: 'Echo task',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 300,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: {
type: 'script',
working_dir: 'D:\\Projects\\echo',
interpreter: 'python.exe',
script: 'main.py',
args: [],
env: {},
},
created_at: '2026-10-18T10:00:00Z',
updated_at: '2026-10-18T10:00:00Z',
}
const taskList: TaskListResponse = {
items: [task],
page: 1,
page_size: 20,
total: 1,
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function createFetchMock() {
return vi.fn(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
if (url.endsWith('/api/v1/health')) {
return jsonResponse({
status: 'ok',
service: 'task-pilot-backend',
version: '0.1.0',
})
}
if (url.endsWith('/api/v1/session')) {
return jsonResponse({ token: 'test-token' })
}
if (url.endsWith('/api/v1/cron/preview')) {
return jsonResponse({
timezone: 'Asia/Shanghai',
runs: [
'2026-10-19 02:00:00',
'2026-10-20 02:00:00',
],
})
}
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
return jsonResponse(
{
...task,
...JSON.parse(String(init.body)),
},
201,
)
}
if (url.includes('/api/v1/tasks')) {
return jsonResponse(taskList)
}
return jsonResponse({ code: 'NOT_FOUND', message: 'not found', details: {} }, 404)
},
)
}
function renderApp() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<App />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('TaskDashboard', () => {
it('renders tasks returned by the Rust API', async () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
})
it('creates a script task with the visible form values', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.clear(screen.getByLabelText('任务标识'))
await user.type(screen.getByLabelText('任务标识'), 'new_task')
await user.type(screen.getByLabelText('任务名称'), 'New task')
await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
await user.click(
screen.getByRole('button', { name: '保存并热刷新' }),
)
await waitFor(() => {
const createCall = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
)
expect(createCall).toBeDefined()
})
const createCall = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
)
const payload = JSON.parse(String(createCall?.[1]?.body))
expect(payload.id).toBe('new_task')
expect(payload.name).toBe('New task')
expect(payload.action.type).toBe('script')
expect(payload.action.working_dir).toBe('D:\\Projects\\new')
})
})
+31
View File
@@ -0,0 +1,31 @@
import { Link, Route, Routes } from 'react-router-dom'
import { TaskDashboard } from './pages/TaskDashboard'
import './App.css'
function NotFoundPage() {
return (
<main className="app-shell">
<header className="dashboard-header">
<div>
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
<h1>页面不存在</h1>
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
</div>
</header>
<Link className="button button-primary" to="/">
返回任务列表
</Link>
</main>
)
}
function App() {
return (
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
)
}
export default App
+234
View File
@@ -0,0 +1,234 @@
import type { components } from './generated'
export type HealthResponse = components['schemas']['HealthResponse']
export type Task = components['schemas']['TaskResponse']
export type TaskAction = components['schemas']['TaskAction']
export type TaskActionType = components['schemas']['TaskActionType']
export type CreateTaskRequest = components['schemas']['CreateTaskRequest']
export type UpdateTaskRequest = components['schemas']['UpdateTaskRequest']
export type TaskListResponse = components['schemas']['TaskListResponse']
export type TaskRun = components['schemas']['TaskRunResponse']
export type TaskRunListResponse = components['schemas']['TaskRunListResponse']
export type RunStatus = components['schemas']['RunStatus']
export type RunTrigger = components['schemas']['RunTrigger']
export type LogChunkResponse = components['schemas']['LogChunkResponse']
export type ErrorResponse = components['schemas']['ErrorResponse']
export type SessionResponse = components['schemas']['SessionResponse']
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
export interface TaskFilters {
q?: string
enabled?: boolean
action_type?: TaskActionType
page?: number
page_size?: number
}
export interface RunFilters {
page?: number
page_size?: number
status?: RunStatus
trigger?: RunTrigger
}
export type ExecutionEvent =
| {
event: 'run'
data: {
run: TaskRun
}
}
| {
event: 'log'
data: {
run_id: number
offset: number
next_offset: number
content: string
}
}
export class ApiRequestError extends Error {
readonly status: number
readonly code?: string
readonly field?: string | null
readonly details?: Record<string, string>
constructor(status: number, body?: ErrorResponse) {
super(body?.message ?? `请求失败(HTTP ${status})`)
this.name = 'ApiRequestError'
this.status = status
this.code = body?.code
this.field = body?.field
this.details = body?.details
}
}
async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
const method = (init.method ?? 'GET').toUpperCase()
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
}
const response = await fetch(path, {
...init,
headers,
})
if (!response.ok) {
let body: ErrorResponse | undefined
try {
body = (await response.json()) as ErrorResponse
} catch {
body = undefined
}
throw new ApiRequestError(response.status, body)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
async function fetchSessionToken(): Promise<string> {
const response = await fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new ApiRequestError(response.status)
}
const session = (await response.json()) as SessionResponse
return session.token
}
function withQuery(path: string, values: object): string {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
}
const serialized = query.toString()
return serialized ? `${path}?${serialized}` : path
}
export function fetchHealth(signal?: AbortSignal): Promise<HealthResponse> {
return requestJson<HealthResponse>('/api/v1/health', { signal })
}
export function listTasks(
filters: TaskFilters = {},
signal?: AbortSignal,
): Promise<TaskListResponse> {
return requestJson<TaskListResponse>(
withQuery('/api/v1/tasks', filters),
{ signal },
)
}
export function getTask(id: string, signal?: AbortSignal): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
signal,
})
}
export function createTask(payload: CreateTaskRequest): Promise<Task> {
return requestJson<Task>('/api/v1/tasks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function updateTask(
id: string,
payload: UpdateTaskRequest,
): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function deleteTask(id: string): Promise<void> {
return requestJson<void>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
export function setTaskEnabled(
id: string,
enabled: boolean,
): Promise<Task> {
const action = enabled ? 'enable' : 'disable'
return requestJson<Task>(
`/api/v1/tasks/${encodeURIComponent(id)}/${action}`,
{ method: 'POST' },
)
}
export function runTask(id: string): Promise<TaskRun> {
return requestJson<TaskRun>(
`/api/v1/tasks/${encodeURIComponent(id)}/run`,
{ method: 'POST' },
)
}
export function listTaskRuns(
taskId: string,
filters: RunFilters = {},
signal?: AbortSignal,
): Promise<TaskRunListResponse> {
return requestJson<TaskRunListResponse>(
withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters),
{ signal },
)
}
export function getRun(
runId: number,
signal?: AbortSignal,
): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}`, { signal })
}
export function getRunLogs(
runId: number,
offset = 0,
signal?: AbortSignal,
): Promise<LogChunkResponse> {
return requestJson<LogChunkResponse>(
withQuery(`/api/v1/runs/${runId}/logs`, { offset }),
{ signal },
)
}
export function cancelRun(runId: number): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}/cancel`, {
method: 'POST',
})
}
export function runLogDownloadUrl(runId: number): string {
return `/api/v1/runs/${runId}/logs/download`
}
export function previewCron(
request: CronPreviewRequest,
signal?: AbortSignal,
): Promise<CronPreviewResponse> {
return requestJson<CronPreviewResponse>('/api/v1/cron/preview', {
method: 'POST',
body: JSON.stringify(request),
signal,
})
}
+940
View File
@@ -0,0 +1,940 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1/cron/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["preview_cron"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["execution_events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancel_run"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["download_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["session"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_tasks"];
put?: never;
post: operations["create_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_task"];
put: operations["update_task"];
post?: never;
delete: operations["delete_task"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/disable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["disable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/enable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["enable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/run": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["run_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/runs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_task_runs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
CronPreviewRequest: {
count: number;
expression: string;
timezone: string;
};
CronPreviewResponse: {
runs: string[];
timezone: string;
};
ErrorResponse: {
code: string;
details: {
[key: string]: string;
};
field?: string | null;
message: string;
};
HealthResponse: {
/** @description Stable service identifier. */
service: string;
/** @description Current service status. */
status: string;
/** @description Backend package version. */
version: string;
};
/** @enum {string} */
HttpMethod: "GET" | "POST";
LogChunkResponse: {
complete: boolean;
content: string;
/** Format: int64 */
next_offset: number;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
SessionResponse: {
/** @description Random per-process token required for mutating browser requests. */
token: string;
};
TaskAction: {
args?: string[];
env?: {
[key: string]: string;
};
interpreter: string;
script: string;
/** @enum {string} */
type: "script";
working_dir: string;
} | {
body?: string | null;
expected_status?: number[];
headers?: {
[key: string]: string;
};
method: components["schemas"]["HttpMethod"];
/** @enum {string} */
type: "http";
url: string;
} | {
args?: string[];
env?: {
[key: string]: string;
};
executable: string;
/** @enum {string} */
type: "process";
working_dir?: string | null;
};
/** @enum {string} */
TaskActionType: "script" | "http" | "process";
TaskListResponse: {
items: components["schemas"]["TaskResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskResponse: {
action: components["schemas"]["TaskAction"];
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
/** Format: date-time */
created_at: string;
cron: string;
enabled: boolean;
id: string;
name: string;
retry_policy: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs: number;
timezone: string;
/** Format: date-time */
updated_at: string;
};
TaskRunListResponse: {
items: components["schemas"]["TaskRunResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskRunResponse: {
/** Format: date-time */
created_at: string;
/** Format: int64 */
duration_ms?: number | null;
error_message?: string | null;
/** Format: int32 */
exit_code?: number | null;
/** Format: date-time */
finished_at?: string | null;
/** Format: int64 */
id: number;
/** Format: date-time */
started_at?: string | null;
status: components["schemas"]["RunStatus"];
task_id: string;
trigger: components["schemas"]["RunTrigger"];
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
preview_cron: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CronPreviewRequest"];
};
};
responses: {
/** @description Upcoming cron occurrences */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CronPreviewResponse"];
};
};
/** @description Invalid cron preview request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
execution_events: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Run status and log events */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": string;
};
};
};
};
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description TaskPilot API is healthy */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
get_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
cancel_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cancellation requested */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Run is not cancellable */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_run_logs: {
parameters: {
query?: {
offset?: number;
};
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Incremental task run log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LogChunkResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
download_run_logs: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Plain text task log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": string;
};
};
/** @description Run or log not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
session: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Local API session token */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
};
};
list_tasks: {
parameters: {
query?: {
q?: string;
enabled?: boolean;
action_type?: components["schemas"]["TaskActionType"];
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Paginated task list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskListResponse"];
};
};
};
};
create_task: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateTaskRequest"];
};
};
responses: {
/** @description Task created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task ID already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
update_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateTaskRequest"];
};
};
responses: {
/** @description Task updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
disable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task disabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
enable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task enabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
run_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Manual run accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Action executor not implemented */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
list_task_runs: {
parameters: {
query?: {
page?: number;
page_size?: number;
status?: components["schemas"]["RunStatus"];
trigger?: components["schemas"]["RunTrigger"];
};
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run history */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunListResponse"];
};
};
};
};
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react'
import { useFocusTrap } from '../lib/useFocusTrap'
interface ConfirmDialogProps {
open: boolean
title: string
description: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
description,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef)
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busy) {
onCancel()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [busy, onCancel, open])
if (!open) {
return null
}
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
onCancel()
}
}}
>
<section
ref={dialogRef}
className="confirm-card"
tabIndex={-1}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-description"
>
<p className="eyebrow">CONFIRM ACTION</p>
<h2 id="confirm-title">{title}</h2>
<p className="muted-copy">{description}</p>
<div className="modal-actions">
<button
type="button"
className="button button-secondary"
onClick={onCancel}
disabled={busy}
>
取消
</button>
<button
type="button"
className="button button-danger"
onClick={onConfirm}
disabled={busy}
>
{busy ? '删除中...' : '确认删除'}
</button>
</div>
</section>
</div>
)
}
+501
View File
@@ -0,0 +1,501 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import {
cancelRun,
getRun,
getRunLogs,
listTaskRuns,
runLogDownloadUrl,
type ExecutionEvent,
type RunStatus,
type RunTrigger,
type Task,
type TaskRun,
} from '../api/client'
import {
formatDateTime,
formatDuration,
isTerminalRunStatus,
} from '../lib/format'
import { useFocusTrap } from '../lib/useFocusTrap'
import { RunStatusBadge } from './StatusBadge'
interface RunLogDrawerProps {
open: boolean
task: Task | null
initialRunId?: number | null
onClose: () => void
}
export function RunLogDrawer({
open,
task,
initialRunId,
onClose,
}: RunLogDrawerProps) {
const queryClient = useQueryClient()
const [selectedRunId, setSelectedRunId] = useState<number | null>(
initialRunId ?? null,
)
const [historyStatus, setHistoryStatus] = useState<RunStatus | ''>('')
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
const [sseConnected, setSseConnected] = useState(false)
const [logEvents] = useState(() => new EventTarget())
const drawerRef = useRef<HTMLElement>(null)
useFocusTrap(open, drawerRef)
const runsQuery = useQuery({
queryKey: [
'task-runs',
task?.id,
historyStatus,
historyTrigger,
],
queryFn: ({ signal }) =>
listTaskRuns(
task!.id,
{
page: 1,
page_size: 50,
status: historyStatus || undefined,
trigger: historyTrigger || undefined,
},
signal,
),
enabled: open && Boolean(task),
refetchInterval: (query) =>
query.state.data?.items.some((run) => !isTerminalRunStatus(run.status))
? 1_000
: 5_000,
})
const runs = runsQuery.data?.items ?? []
const effectiveRunId = selectedRunId ?? runs[0]?.id ?? null
const selectedRunQuery = useQuery({
queryKey: ['run', effectiveRunId],
queryFn: ({ signal }) => getRun(effectiveRunId!, signal),
enabled: effectiveRunId !== null,
refetchInterval: (query) =>
query.state.data && !isTerminalRunStatus(query.state.data.status)
? 800
: false,
})
const selectedRun =
selectedRunQuery.data ?? runs.find((run) => run.id === effectiveRunId) ?? null
const cancelMutation = useMutation({
mutationFn: (runId: number) => cancelRun(runId),
onSuccess: (run) => {
queryClient.setQueryData(['run', run.id], run)
void queryClient.invalidateQueries({
queryKey: ['task-runs', run.task_id],
})
},
})
useEffect(() => {
if (!open || !task) {
return
}
const source = new EventSource('/api/v1/events')
source.onopen = () => setSseConnected(true)
source.onerror = () => setSseConnected(false)
const onRun = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as ExecutionEvent
if (payload.event !== 'run' || payload.data.run.task_id !== task.id) {
return
}
queryClient.setQueryData(['run', payload.data.run.id], payload.data.run)
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
} catch {
setSseConnected(false)
}
}
const onLog = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as ExecutionEvent
if (payload.event === 'log') {
logEvents.dispatchEvent(
new CustomEvent('log', { detail: payload }),
)
}
} catch {
setSseConnected(false)
}
}
const onLagged = () => {
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
}
source.addEventListener('run', onRun as EventListener)
source.addEventListener('log', onLog as EventListener)
source.addEventListener('lagged', onLagged)
return () => {
source.removeEventListener('run', onRun as EventListener)
source.removeEventListener('log', onLog as EventListener)
source.removeEventListener('lagged', onLagged)
source.close()
setSseConnected(false)
}
}, [logEvents, open, queryClient, task])
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose, open])
if (!open || !task) {
return null
}
return (
<div
className="modal-overlay drawer-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
>
<aside
ref={drawerRef}
className="run-drawer"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="run-drawer-title"
>
<header className="modal-header">
<div>
<p className="eyebrow">RUN HISTORY</p>
<h2 id="run-drawer-title">{task.name}</h2>
<p className="drawer-subtitle mono">{task.id}</p>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="关闭运行日志"
>
×
</button>
</header>
<div className="run-drawer-body">
<section className="run-history-panel">
<div className="section-heading">
<div>
<p className="section-label">RUNS</p>
<h3>最近运行</h3>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => void runsQuery.refetch()}
disabled={runsQuery.isFetching}
>
刷新
</button>
</div>
<div className="history-filters">
<select
className="field-input compact-select"
aria-label="运行状态筛选"
value={historyStatus}
onChange={(event) => {
setHistoryStatus(event.target.value as RunStatus | '')
setSelectedRunId(null)
}}
>
<option value="">全部状态</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="succeeded">成功</option>
<option value="failed">失败</option>
<option value="timed_out">已超时</option>
<option value="cancelled">已取消</option>
<option value="skipped">已跳过</option>
</select>
<select
className="field-input compact-select"
aria-label="触发方式筛选"
value={historyTrigger}
onChange={(event) => {
setHistoryTrigger(event.target.value as RunTrigger | '')
setSelectedRunId(null)
}}
>
<option value="">全部触发</option>
<option value="schedule">定时</option>
<option value="manual">手动</option>
<option value="retry">重试</option>
</select>
</div>
{runsQuery.isPending ? (
<p className="muted-copy">正在加载运行记录...</p>
) : runsQuery.isError ? (
<div className="inline-alert inline-alert-error">
{runsQuery.error.message}
</div>
) : runs.length === 0 ? (
<p className="empty-inline">该任务还没有运行记录。</p>
) : (
<div className="run-list">
{runs.map((run) => (
<button
type="button"
className={`run-list-item ${
run.id === effectiveRunId ? 'is-selected' : ''
}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
>
<span className="run-list-main">
<span className="mono">#{run.id}</span>
<RunStatusBadge status={run.status} />
</span>
<span className="run-list-meta">
{formatDateTime(run.created_at)} · {run.trigger}
</span>
</button>
))}
</div>
)}
</section>
<section className="run-detail-panel">
<div className="section-heading">
<div>
<p className="section-label">DETAIL</p>
<h3>
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
</h3>
</div>
{selectedRun ? (
<div className="run-detail-actions">
<RunStatusBadge status={selectedRun.status} />
{!isTerminalRunStatus(selectedRun.status) ? (
<button
type="button"
className="button button-danger button-small"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === selectedRun.id
}
onClick={() => cancelMutation.mutate(selectedRun.id)}
>
{cancelMutation.isPending ? '取消中...' : '取消运行'}
</button>
) : null}
<a
className="button button-secondary button-small"
href={runLogDownloadUrl(selectedRun.id)}
>
下载日志
</a>
</div>
) : null}
</div>
{selectedRunQuery.isError ? (
<div className="inline-alert inline-alert-error">
{selectedRunQuery.error.message}
</div>
) : null}
{selectedRun ? (
<RunMetadata run={selectedRun} />
) : (
<p className="empty-inline">选择一条运行记录查看日志。</p>
)}
{effectiveRunId ? (
<RunLogOutput
key={effectiveRunId}
runId={effectiveRunId}
logEvents={logEvents}
sseConnected={sseConnected}
runComplete={
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
}
/>
) : null}
</section>
</div>
</aside>
</div>
)
}
function RunMetadata({ run }: { run: TaskRun }) {
return (
<dl className="run-metadata">
<div>
<dt>触发方式</dt>
<dd>{run.trigger}</dd>
</div>
<div>
<dt>开始时间</dt>
<dd>{formatDateTime(run.started_at)}</dd>
</div>
<div>
<dt>耗时</dt>
<dd>{formatDuration(run.duration_ms)}</dd>
</div>
<div>
<dt>退出码</dt>
<dd>{run.exit_code ?? '--'}</dd>
</div>
{run.error_message ? (
<div className="run-error-metadata">
<dt>结果说明</dt>
<dd>{run.error_message}</dd>
</div>
) : null}
</dl>
)
}
interface RunLogOutputProps {
runId: number
logEvents: EventTarget
sseConnected: boolean
runComplete: boolean
}
function RunLogOutput({
runId,
logEvents,
sseConnected,
runComplete,
}: RunLogOutputProps) {
const [content, setContent] = useState('')
const [pollComplete, setPollComplete] = useState(runComplete)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [reconcileKey, setReconcileKey] = useState(0)
const offsetRef = useRef(0)
const outputRef = useRef<HTMLPreElement>(null)
const complete = runComplete || pollComplete
useEffect(() => {
let cancelled = false
const controller = new AbortController()
const poll = async () => {
while (!cancelled) {
try {
const startOffset = offsetRef.current
const chunk = await getRunLogs(runId, startOffset, controller.signal)
if (cancelled) {
return
}
if (offsetRef.current !== startOffset) {
continue
}
if (chunk.content) {
setContent((current) => current + chunk.content)
}
offsetRef.current = chunk.next_offset
if (chunk.complete) {
setPollComplete(true)
return
}
await new Promise((resolve) => window.setTimeout(resolve, 600))
} catch (requestError) {
if (!cancelled) {
setError(
requestError instanceof Error
? requestError.message
: '读取日志失败',
)
}
return
}
}
}
void poll()
return () => {
cancelled = true
controller.abort()
}
}, [reconcileKey, reloadKey, runId])
useEffect(() => {
const onLog = (event: Event) => {
const payload = (event as CustomEvent<ExecutionEvent>).detail
if (
payload.event !== 'log' ||
payload.data.run_id !== runId
) {
return
}
if (payload.data.offset === offsetRef.current) {
if (payload.data.content) {
setContent((current) => current + payload.data.content)
}
offsetRef.current = payload.data.next_offset
} else if (payload.data.offset > offsetRef.current) {
setReconcileKey((value) => value + 1)
}
}
logEvents.addEventListener('log', onLog)
return () => logEvents.removeEventListener('log', onLog)
}, [logEvents, runId])
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, [content])
return (
<div className="log-viewer-wrap">
<div className="log-viewer-header">
<span>运行日志</span>
<span className={complete ? 'log-complete' : 'log-streaming'}>
{error
? '读取失败'
: complete
? '已完成'
: sseConnected
? 'SSE 实时连接'
: '轮询读取中'}
</span>
</div>
{error ? (
<div className="inline-alert inline-alert-error">
{error}
<button
type="button"
className="text-button"
onClick={() => {
setContent('')
setPollComplete(false)
setError(null)
offsetRef.current = 0
setReloadKey((value) => value + 1)
}}
>
重试
</button>
</div>
) : null}
<pre className="log-viewer" ref={outputRef} aria-live="polite">
{content || '等待日志输出...'}
</pre>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { RunStatus } from '../api/client'
import { runStatusLabels } from '../lib/format'
interface RunStatusBadgeProps {
status: RunStatus
}
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
return (
<span className={`status-badge status-${status}`}>
<span className="status-dot" aria-hidden="true" />
{runStatusLabels[status]}
</span>
)
}
interface EnabledBadgeProps {
enabled: boolean
}
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
return (
<span
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
>
<span className="status-dot" aria-hidden="true" />
{enabled ? '活跃中' : '已暂停'}
</span>
)
}
+623
View File
@@ -0,0 +1,623 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
type FieldPath,
type UseFormRegister,
useFieldArray,
useForm,
} from 'react-hook-form'
import { ApiRequestError, previewCron, type Task } from '../api/client'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
taskFormSchema,
taskToFormValues,
type TaskFormValues,
} from '../lib/taskForm'
import { useFocusTrap } from '../lib/useFocusTrap'
import { useDebouncedValue } from '../lib/useDebouncedValue'
interface TaskEditorModalProps {
open: boolean
task: Task | null
saving: boolean
onClose: () => void
onSubmit: (values: TaskFormValues) => Promise<void>
}
export function TaskEditorModal({
open,
task,
saving,
onClose,
onSubmit,
}: TaskEditorModalProps) {
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
const {
register,
control,
handleSubmit,
reset,
watch,
setError,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: createEmptyTaskFormValues(),
mode: 'onBlur',
})
const actionType = watch('actionType')
const cronValue = watch('cron')
const timezoneValue = watch('timezone')
const debouncedCron = useDebouncedValue(cronValue, 300)
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
const cronPreview = useQuery({
queryKey: [
'cron-preview',
debouncedCron,
debouncedTimezone,
cronPreviewCount,
],
queryFn: ({ signal }) =>
previewCron(
{
expression: debouncedCron,
timezone: debouncedTimezone,
count: cronPreviewCount,
},
signal,
),
enabled:
open &&
Boolean(debouncedCron.trim()) &&
Boolean(debouncedTimezone.trim()),
retry: false,
})
const scriptArgs = useFieldArray({ control, name: 'script.args' })
const scriptEnv = useFieldArray({ control, name: 'script.env' })
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
const processArgs = useFieldArray({ control, name: 'process.args' })
const processEnv = useFieldArray({ control, name: 'process.env' })
useEffect(() => {
if (!open) {
return
}
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
setCronPreviewCount(5)
setSubmitError(null)
}, [open, reset, task])
useEffect(() => {
if (!open) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !saving) {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose, open, saving])
if (!open) {
return null
}
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
await onSubmit(values)
} catch (error) {
if (error instanceof ApiRequestError && error.field) {
const formField = mapServerFieldToFormField(error.field, values.actionType)
if (formField) {
setError(formField as FieldPath<TaskFormValues>, {
type: 'server',
message: error.message,
})
return
}
}
setSubmitError(
error instanceof Error ? error.message : '保存任务时发生未知错误',
)
}
})
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !saving) {
onClose()
}
}}
>
<section
ref={dialogRef}
className="modal-card task-editor"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="task-editor-title"
>
<header className="modal-header">
<div>
<h2 id="task-editor-title">
{task ? `编辑任务 [${task.id}]` : '新建定时任务'}
</h2>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
disabled={saving}
aria-label="关闭任务编辑器"
>
×
</button>
</header>
<form className="modal-body" onSubmit={submit} noValidate>
{submitError ? (
<div className="inline-alert inline-alert-error" role="alert">
{submitError}
</div>
) : null}
<div className="form-grid">
<Field label="任务标识" error={errors.id?.message}>
<input
{...register('id')}
className="field-input mono"
placeholder="例如:sync_data"
disabled={Boolean(task)}
data-dialog-autofocus
aria-invalid={Boolean(errors.id)}
/>
</Field>
<Field label="任务名称" error={errors.name?.message}>
<input
{...register('name')}
className="field-input"
placeholder="业务可读名称"
aria-invalid={Boolean(errors.name)}
/>
</Field>
</div>
<div className="form-grid">
<div className="field">
<div className="cron-header-row">
<span className="field-label">Cron 表达式</span>
<div className="cron-n-selector">
<span>预览未来</span>
<select
value={cronPreviewCount}
onChange={(event) =>
setCronPreviewCount(Number(event.target.value))
}
aria-label="Cron 预览次数"
>
<option value="3">3 次</option>
<option value="5">5 次</option>
<option value="8">8 次</option>
<option value="10">10 次</option>
</select>
</div>
</div>
<input
{...register('cron')}
className="field-input mono"
placeholder="0 0 2 * * *"
aria-invalid={Boolean(errors.cron)}
/>
<div className="cron-preview-box" aria-live="polite">
{cronPreview.isFetching ? (
<span className="cron-preview-message">正在计算...</span>
) : cronPreview.isError ? (
<span className="cron-preview-error">
{cronPreview.error.message}
</span>
) : cronPreview.data?.runs.length ? (
cronPreview.data.runs.map((run, index) => (
<div className="cron-run-item" key={run}>
<span className="cron-run-index">#{index + 1}</span>
<span className="cron-run-time">{run}</span>
</div>
))
) : (
<span className="cron-preview-message">
等待输入有效 Cron 表达式...
</span>
)}
</div>
{errors.cron?.message ? (
<span className="field-error">{errors.cron.message}</span>
) : (
<span className="field-hint">
支持 5/6 位字段及 @daily、@hourly
</span>
)}
</div>
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}>
<input
{...register('timeout_secs', { valueAsNumber: true })}
type="number"
min="1"
max="86400"
className="field-input mono"
aria-invalid={Boolean(errors.timeout_secs)}
/>
<span className="field-hint">
超时后将被 Windows Job Object 强制终止
</span>
</Field>
</div>
<div className="form-grid">
<Field label="IANA 时区" error={errors.timezone?.message}>
<input
{...register('timezone')}
className="field-input"
list="timezone-options"
placeholder="Asia/Shanghai"
aria-invalid={Boolean(errors.timezone)}
/>
<datalist id="timezone-options">
<option value="Asia/Shanghai" />
<option value="Asia/Hong_Kong" />
<option value="UTC" />
<option value="Europe/London" />
<option value="America/New_York" />
</datalist>
</Field>
<Field label="任务类型">
<select {...register('actionType')} className="field-input">
<option value="script">执行脚本</option>
<option value="http">HTTP 请求</option>
<option value="process">二进制进程</option>
</select>
</Field>
</div>
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
{actionType === 'script' ? (
<fieldset className="action-panel">
<legend>脚本配置</legend>
<div className="form-grid">
<Field
label="工作目录"
error={errors.script?.working_dir?.message}
>
<input
{...register('script.working_dir')}
className="field-input mono"
placeholder="D:\Projects\sync"
/>
</Field>
<Field
label="解释器"
error={errors.script?.interpreter?.message}
>
<input
{...register('script.interpreter')}
className="field-input mono"
placeholder="python.exe"
/>
</Field>
</div>
<Field
label="脚本文件或相对路径"
error={errors.script?.script?.message}
>
<input
{...register('script.script')}
className="field-input mono"
placeholder="main.py 或 scripts\sync.py"
/>
</Field>
<div className="collection-header">
<div>
<strong>运行参数</strong>
<small>每行一个参数,不经过 Shell 拼接</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => scriptArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{scriptArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`script.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--mode"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => scriptArgs.remove(index)}
>
删除
</button>
</div>
))}
{scriptArgs.fields.length === 0 ? (
<p className="empty-inline">暂无运行参数</p>
) : null}
{errors.script?.args?.message ? (
<span className="field-error">
{errors.script.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={scriptEnv.fields}
registerKey={register}
name="script.env"
error={errors.script?.env?.message}
onAdd={() => scriptEnv.append({ key: '', value: '' })}
onRemove={scriptEnv.remove}
/>
</fieldset>
) : null}
{actionType === 'http' ? (
<fieldset className="action-panel">
<legend>HTTP 配置</legend>
<div className="form-grid">
<Field label="请求 URL" error={errors.http?.url?.message}>
<input
{...register('http.url')}
className="field-input mono"
placeholder="https://api.example.com/health"
/>
</Field>
<Field label="请求方式">
<select {...register('http.method')} className="field-input">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</Field>
</div>
<div className="form-grid">
<Field
label="期望状态码"
hint="使用逗号分隔,例如:200, 204"
error={errors.http?.expected_status?.message}
>
<input
{...register('http.expected_status')}
className="field-input mono"
placeholder="200"
/>
</Field>
<Field label="JSON Body" error={errors.http?.body?.message}>
<textarea
{...register('http.body')}
className="field-input field-textarea mono"
rows={4}
placeholder='{"trigger":"manual"}'
/>
</Field>
</div>
<KeyValueEditor
title="请求 Header"
fields={httpHeaders.fields}
registerKey={register}
name="http.headers"
error={errors.http?.headers?.message}
onAdd={() => httpHeaders.append({ key: '', value: '' })}
onRemove={httpHeaders.remove}
/>
</fieldset>
) : null}
{actionType === 'process' ? (
<fieldset className="action-panel">
<legend>进程配置</legend>
<div className="form-grid">
<Field
label="可执行程序"
error={errors.process?.executable?.message}
>
<input
{...register('process.executable')}
className="field-input mono"
placeholder="C:\Tools\exporter.exe"
/>
</Field>
<Field label="工作目录">
<input
{...register('process.working_dir')}
className="field-input mono"
placeholder="可选"
/>
</Field>
</div>
<div className="collection-header">
<div>
<strong>启动参数</strong>
<small>每行一个参数</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => processArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{processArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`process.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--port"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => processArgs.remove(index)}
>
删除
</button>
</div>
))}
{processArgs.fields.length === 0 ? (
<p className="empty-inline">暂无启动参数</p>
) : null}
{errors.process?.args?.message ? (
<span className="field-error">
{errors.process.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={processEnv.fields}
registerKey={register}
name="process.env"
error={errors.process?.env?.message}
onAdd={() => processEnv.append({ key: '', value: '' })}
onRemove={processEnv.remove}
/>
</fieldset>
) : null}
<footer className="form-actions">
<button
type="button"
className="button button-secondary"
onClick={onClose}
disabled={saving}
>
取消
</button>
<button
type="submit"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</form>
</section>
</div>
)
}
interface FieldProps {
label: string
error?: string
hint?: string
children: ReactNode
}
function Field({ label, error, hint, children }: FieldProps) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
{error ? <span className="field-error">{error}</span> : null}
{!error && hint ? <span className="field-hint">{hint}</span> : null}
</label>
)
}
interface KeyValueEditorProps {
title: string
name: 'script.env' | 'http.headers' | 'process.env'
fields: Array<{ id: string; key: string; value: string }>
registerKey: UseFormRegister<TaskFormValues>
onAdd: () => void
onRemove: (index: number) => void
error?: string
}
function KeyValueEditor({
title,
name,
fields,
registerKey,
onAdd,
onRemove,
error,
}: KeyValueEditorProps) {
return (
<div className="key-value-editor">
<div className="collection-header">
<div>
<strong>{title}</strong>
<small>名称和值会原样提交给后端</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={onAdd}
>
+ 添加
</button>
</div>
<div className="collection-list">
{fields.map((field, index) => (
<div className="key-value-row" key={field.id}>
<input
{...registerKey(
`${name}.${index}.key` as FieldPath<TaskFormValues>,
)}
className="field-input mono"
placeholder="名称"
/>
<input
{...registerKey(
`${name}.${index}.value` as FieldPath<TaskFormValues>,
)}
type="password"
autoComplete="off"
className="field-input mono"
placeholder="值"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => onRemove(index)}
>
删除
</button>
</div>
))}
{fields.length === 0 ? <p className="empty-inline">暂无配置</p> : null}
{error ? <span className="field-error">{error}</span> : null}
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
:root {
--bg-color: #f7f7f8;
--card-bg: #ffffff;
--text-main: #111827;
--text-muted: #8c939d;
--text-light: #b5b9c0;
--primary: #111827;
--primary-hover: #262e3d;
--border-color: #f0f1f3;
--badge-bg: #f3f4f6;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
--background: var(--bg-color);
--surface: var(--card-bg);
--surface-muted: #fafafa;
--text: var(--text-main);
--text-secondary: var(--text-muted);
--text-tertiary: #a4aab3;
--border: #eceef1;
--success: #10b981;
--success-bg: #eefbf5;
--danger: #f43f5e;
--danger-bg: #fff1f2;
--shadow: var(--shadow-card);
--font-sans:
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', Consolas, 'Courier New', monospace;
color: var(--text);
background: var(--background);
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--background);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
line-height: 1.5;
font-size: 14px;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
::selection {
background: #111827;
color: #fff;
}
:focus-visible {
outline: 3px solid rgba(17, 24, 39, 0.25);
outline-offset: 3px;
}
#root {
min-height: 100vh;
}
+59
View File
@@ -0,0 +1,59 @@
import type { RunStatus, TaskActionType } from '../api/client'
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
export function formatDateTime(value: string | null | undefined): string {
if (!value) {
return '--'
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
}
export function formatDuration(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
}
if (value < 1_000) {
return `${value} ms`
}
if (value < 60_000) {
return `${(value / 1_000).toFixed(2)} s`
}
return `${(value / 60_000).toFixed(1)} min`
}
export function actionTypeLabel(type: TaskActionType): string {
return {
script: '脚本',
http: 'HTTP',
process: '进程',
}[type]
}
export const runStatusLabels: Record<RunStatus, string> = {
queued: '排队中',
running: '运行中',
succeeded: '成功',
failed: '失败',
timed_out: '已超时',
cancelled: '已取消',
skipped: '已跳过',
}
export function isTerminalRunStatus(status: RunStatus): boolean {
return [
'succeeded',
'failed',
'timed_out',
'cancelled',
'skipped',
].includes(status)
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
valuesToCreateRequest,
} from './taskForm'
describe('task form payload', () => {
it('creates an HTTP action instead of forcing script type', () => {
const values = createEmptyTaskFormValues()
values.id = 'health_check'
values.name = 'Health check'
values.actionType = 'http'
values.http.url = 'https://example.com/health'
values.http.method = 'POST'
values.http.expected_status = '200, 204'
values.http.headers = [{ key: 'X-Test', value: 'yes' }]
values.http.body = '{"ping":true}'
const payload = valuesToCreateRequest(values)
expect(payload.action.type).toBe('http')
expect(payload.action).toMatchObject({
type: 'http',
url: 'https://example.com/health',
method: 'POST',
expected_status: [200, 204],
headers: { 'X-Test': 'yes' },
})
})
it('creates a process action with structured arguments', () => {
const values = createEmptyTaskFormValues()
values.id = 'worker'
values.name = 'Worker'
values.actionType = 'process'
values.process.executable = 'C:\\Tools\\worker.exe'
values.process.args = [{ value: '--port' }, { value: '8080' }]
values.process.env = [{ key: 'WORKER_MODE', value: 'local' }]
const payload = valuesToCreateRequest(values)
expect(payload.action).toEqual({
type: 'process',
executable: 'C:\\Tools\\worker.exe',
working_dir: null,
args: ['--port', '8080'],
env: { WORKER_MODE: 'local' },
})
})
it('maps backend validation fields to the current form section', () => {
expect(mapServerFieldToFormField('action.working_dir', 'script')).toBe(
'script.working_dir',
)
expect(mapServerFieldToFormField('action.url', 'http')).toBe('http.url')
expect(mapServerFieldToFormField('cron', 'script')).toBe('cron')
})
})
+333
View File
@@ -0,0 +1,333 @@
import { z } from 'zod'
import type {
CreateTaskRequest,
Task,
TaskAction,
TaskActionType,
UpdateTaskRequest,
} from '../api/client'
export interface KeyValueFormValue {
key: string
value: string
}
export interface ArgumentFormValue {
value: string
}
export interface TaskFormValues {
id: string
name: string
cron: string
timezone: string
timeout_secs: number
enabled: boolean
actionType: TaskActionType
script: {
working_dir: string
interpreter: string
script: string
args: ArgumentFormValue[]
env: KeyValueFormValue[]
}
http: {
url: string
method: 'GET' | 'POST'
body: string
expected_status: string
headers: KeyValueFormValue[]
}
process: {
executable: string
working_dir: string
args: ArgumentFormValue[]
env: KeyValueFormValue[]
}
}
const keyValueSchema = z.object({
key: z.string(),
value: z.string(),
})
function hasValidCronShape(expression: string): boolean {
const value = expression.trim()
if (['@daily', '@hourly'].includes(value)) {
return true
}
return [5, 6].includes(value.split(/\s+/).length)
}
export const taskFormSchema = z
.object({
id: z
.string()
.trim()
.min(1, '请输入任务标识')
.max(64, '任务标识不能超过 64 个字符')
.regex(
/^[A-Za-z0-9_-]+$/,
'只能使用英文字母、数字、下划线和连字符',
),
name: z
.string()
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
cron: z
.string()
.trim()
.min(1, '请输入 Cron 表达式')
.refine(hasValidCronShape, 'Cron 必须是 5 位或 6 位字段'),
timezone: z.string().trim().min(1, '请输入 IANA 时区'),
timeout_secs: z
.number({ error: '请输入有效的超时时间' })
.int('超时时间必须是整数')
.min(1, '超时时间至少为 1 秒')
.max(86_400, '超时时间不能超过 86400 秒'),
enabled: z.boolean(),
actionType: z.enum(['script', 'http', 'process']),
script: z.object({
working_dir: z.string(),
interpreter: z.string(),
script: z.string(),
args: z.array(z.object({ value: z.string() })),
env: z.array(keyValueSchema),
}),
http: z.object({
url: z.string(),
method: z.enum(['GET', 'POST']),
body: z.string(),
expected_status: z.string(),
headers: z.array(keyValueSchema),
}),
process: z.object({
executable: z.string(),
working_dir: z.string(),
args: z.array(z.object({ value: z.string() })),
env: z.array(keyValueSchema),
}),
})
.superRefine((values, context) => {
if (values.actionType === 'script') {
if (!values.script.working_dir.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'working_dir'],
message: '请输入工作目录',
})
}
if (!values.script.interpreter.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'interpreter'],
message: '请输入解释器或可执行程序',
})
}
if (!values.script.script.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'script'],
message: '请输入脚本文件名或路径',
})
}
}
if (values.actionType === 'http') {
try {
const url = new URL(values.http.url)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('unsupported protocol')
}
} catch {
context.addIssue({
code: 'custom',
path: ['http', 'url'],
message: '请输入有效的 HTTP 或 HTTPS URL',
})
}
}
if (
values.actionType === 'process' &&
!values.process.executable.trim()
) {
context.addIssue({
code: 'custom',
path: ['process', 'executable'],
message: '请输入可执行程序路径',
})
}
})
export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
enabled: true,
actionType: 'script',
script: {
working_dir: '',
interpreter: 'python.exe',
script: 'main.py',
args: [],
env: [],
},
http: {
url: '',
method: 'GET',
body: '',
expected_status: '200',
headers: [],
},
process: {
executable: '',
working_dir: '',
args: [],
env: [],
},
}
}
export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
values.enabled = task.enabled
values.actionType = task.action.type
if (task.action.type === 'script') {
values.script = {
working_dir: task.action.working_dir,
interpreter: task.action.interpreter,
script: task.action.script,
args: (task.action.args ?? []).map((value) => ({ value })),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
})),
}
} else if (task.action.type === 'http') {
values.http = {
url: task.action.url,
method: task.action.method,
body: task.action.body ?? '',
expected_status: (task.action.expected_status ?? []).join(', '),
headers: Object.entries(task.action.headers ?? {}).map(
([key, value]) => ({ key, value }),
),
}
} else {
values.process = {
executable: task.action.executable,
working_dir: task.action.working_dir ?? '',
args: (task.action.args ?? []).map((value) => ({ value })),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
})),
}
}
return values
}
export function valuesToAction(values: TaskFormValues): TaskAction {
if (values.actionType === 'script') {
return {
type: 'script',
working_dir: values.script.working_dir.trim(),
interpreter: values.script.interpreter.trim(),
script: values.script.script.trim(),
args: cleanList(values.script.args.map((argument) => argument.value)),
env: keyValuesToObject(values.script.env),
}
}
if (values.actionType === 'http') {
const expectedStatus = values.http.expected_status
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isInteger(value) && value >= 100 && value <= 599)
return {
type: 'http',
url: values.http.url.trim(),
method: values.http.method,
body: values.http.body.trim() || null,
expected_status: expectedStatus,
headers: keyValuesToObject(values.http.headers),
}
}
return {
type: 'process',
executable: values.process.executable.trim(),
working_dir: values.process.working_dir.trim() || null,
args: cleanList(values.process.args.map((argument) => argument.value)),
env: keyValuesToObject(values.process.env),
}
}
export function valuesToCreateRequest(
values: TaskFormValues,
): CreateTaskRequest {
return {
id: values.id.trim(),
...valuesToUpdateRequest(values),
}
}
export function valuesToUpdateRequest(
values: TaskFormValues,
): UpdateTaskRequest {
return {
name: values.name.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
export function mapServerFieldToFormField(
field: string | null | undefined,
actionType: TaskActionType,
): string | null {
if (!field) {
return null
}
if (!field.startsWith('action.')) {
return field
}
const actionField = field.slice('action.'.length)
return `${actionType}.${actionField}`
}
function cleanList(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean)
}
function keyValuesToObject(
values: KeyValueFormValue[],
): Record<string, string> {
return Object.fromEntries(
values
.map(({ key, value }) => [key.trim(), value] as const)
.filter(([key]) => key),
)
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs)
return () => window.clearTimeout(timer)
}, [delayMs, value])
return debounced
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, type RefObject } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function useFocusTrap(
open: boolean,
containerRef: RefObject<HTMLElement | null>,
initialFocusSelector?: string,
) {
useEffect(() => {
if (!open || !containerRef.current) {
return
}
const container = containerRef.current
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusFirst = () => {
const initial = initialFocusSelector
? container.querySelector<HTMLElement>(initialFocusSelector)
: null
const first =
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
;(first ?? container).focus()
}
const frame = window.requestAnimationFrame(focusFirst)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return
}
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.offsetParent !== null)
if (focusable.length === 0) {
event.preventDefault()
container.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [containerRef, initialFocusSelector, open])
}
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 15_000,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)
+577
View File
@@ -0,0 +1,577 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
createTask,
deleteTask,
fetchHealth,
listTasks,
runTask,
setTaskEnabled,
updateTask,
type Task,
type TaskActionType,
type TaskFilters,
} from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog'
import { RunLogDrawer } from '../components/RunLogDrawer'
import {
EnabledBadge,
} from '../components/StatusBadge'
import { TaskEditorModal } from '../components/TaskEditorModal'
import { actionTypeLabel } from '../lib/format'
import {
type TaskFormValues,
valuesToCreateRequest,
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
export function TaskDashboard() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const q = searchParams.get('q') ?? ''
const enabledValue = searchParams.get('enabled')
const actionTypeValue = searchParams.get('action_type')
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
const [editorOpen, setEditorOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
const [logTask, setLogTask] = useState<Task | null>(null)
const [initialRunId, setInitialRunId] = useState<number | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const enabled: boolean | undefined =
enabledValue === 'true'
? true
: enabledValue === 'false'
? false
: undefined
const actionType = isActionType(actionTypeValue)
? actionTypeValue
: undefined
const filters: TaskFilters = {
q: q || undefined,
enabled,
action_type: actionType,
page,
page_size: PAGE_SIZE,
}
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => fetchHealth(signal),
refetchInterval: 30_000,
})
const tasksQuery = useQuery({
queryKey: ['tasks', 'list', filters],
queryFn: ({ signal }) => listTasks(filters, signal),
placeholderData: keepPreviousData,
})
const totalTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'total'],
queryFn: ({ signal }) => listTasks({ page: 1, page_size: 1 }, signal),
})
const enabledTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'enabled'],
queryFn: ({ signal }) =>
listTasks({ enabled: true, page: 1, page_size: 1 }, signal),
})
const scriptTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'script'],
queryFn: ({ signal }) =>
listTasks(
{ action_type: 'script', page: 1, page_size: 1 },
signal,
),
})
const saveMutation = useMutation({
mutationFn: ({
task,
values,
}: {
task: Task | null
values: TaskFormValues
}) =>
task
? updateTask(task.id, valuesToUpdateRequest(values))
: createTask(valuesToCreateRequest(values)),
onSuccess: (task, variables) => {
setEditorOpen(false)
setEditingTask(null)
showNotice(
variables.task ? `任务 ${task.id} 已更新` : `任务 ${task.id} 已创建`,
)
void invalidateTaskData(queryClient)
},
})
const toggleMutation = useMutation({
mutationFn: (task: Task) => setTaskEnabled(task.id, !task.enabled),
onSuccess: (task) => {
showNotice(task.enabled ? `已启用 ${task.id}` : `已停用 ${task.id}`)
void invalidateTaskData(queryClient)
},
onError: (error) => setActionError(error.message),
})
const deleteMutation = useMutation({
mutationFn: (task: Task) => deleteTask(task.id),
onSuccess: (_data, task) => {
setDeleteTarget(null)
showNotice(`任务 ${task.id} 已删除`)
void invalidateTaskData(queryClient)
},
onError: (error) => setActionError(error.message),
})
const runMutation = useMutation({
mutationFn: (task: Task) => runTask(task.id),
onSuccess: (run, task) => {
setLogTask(task)
setInitialRunId(run.id)
setActionError(null)
showNotice(
run.status === 'skipped'
? `任务 ${task.id} 已有运行实例,本次触发已跳过`
: `任务 ${task.id} 已提交运行`,
)
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
},
onError: (error) => setActionError(error.message),
})
useEffect(() => {
if (!notice) {
return
}
const timer = window.setTimeout(() => setNotice(null), 4_000)
return () => window.clearTimeout(timer)
}, [notice])
const tasks = tasksQuery.data?.items ?? []
const total = tasksQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const updateFilters = (changes: Record<string, string | undefined>) => {
const next = new URLSearchParams(searchParams)
for (const [key, value] of Object.entries(changes)) {
if (value) {
next.set(key, value)
} else {
next.delete(key)
}
}
setSearchParams(next)
}
const openCreate = () => {
setEditingTask(null)
setEditorOpen(true)
}
const openEdit = (task: Task) => {
setEditingTask(task)
setEditorOpen(true)
}
const refreshAll = () => {
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
void queryClient.invalidateQueries({ queryKey: ['health'] })
}
const showNotice = (message: string) => {
setNotice(message)
}
return (
<main className="app-shell">
<header className="dashboard-header">
<div className="header-title">
<h1>TaskPilot</h1>
<p className="subtitle">
Rust 本地轻量化任务引擎 · Windows 原生调度中心
</p>
</div>
<div className="header-actions">
<button
type="button"
className="button button-primary"
onClick={openCreate}
>
+ 新建任务
</button>
</div>
</header>
{notice ? (
<div className="toast-message" role="status">
{notice}
</div>
) : null}
{actionError ? (
<div className="inline-alert inline-alert-error page-alert" role="alert">
<span>{actionError}</span>
<button
type="button"
className="text-button"
onClick={() => setActionError(null)}
>
关闭
</button>
</div>
) : null}
<section className="metrics-grid" aria-label="任务统计">
<MetricCard
label="已托管任务"
value={totalTasksQuery.data?.total}
loading={totalTasksQuery.isPending}
/>
<MetricCard
label="正常运行中"
value={enabledTasksQuery.data?.total}
loading={enabledTasksQuery.isPending}
/>
<MetricCard
label="可用脚本"
value={scriptTasksQuery.data?.total}
loading={scriptTasksQuery.isPending}
/>
<article className="metric-card">
<span>Rust 引擎</span>
<strong className={healthQuery.isError ? 'metric-danger' : ''}>
{healthQuery.isPending
? '...'
: healthQuery.isError
? '离线'
: '在线'}
</strong>
</article>
</section>
<section className="card task-card">
<div className="toolbar">
<form
className="search-form"
onSubmit={(event) => {
event.preventDefault()
const value = String(
new FormData(event.currentTarget).get('q') ?? '',
).trim()
updateFilters({ q: value || undefined, page: '1' })
}}
>
<label className="visually-hidden" htmlFor="task-search">
搜索任务
</label>
<input
id="task-search"
key={q}
name="q"
className="field-input search-input"
defaultValue={q}
placeholder="搜索任务名称或 ID"
/>
<button type="submit" className="button button-secondary">
搜索
</button>
</form>
<div className="filter-row">
<label>
<span className="visually-hidden">启用状态</span>
<select
className="field-input compact-select"
value={enabledValue ?? ''}
onChange={(event) =>
updateFilters({
enabled: event.target.value || undefined,
page: '1',
})
}
>
<option value="">全部状态</option>
<option value="true">已启用</option>
<option value="false">已停用</option>
</select>
</label>
<label>
<span className="visually-hidden">任务类型</span>
<select
className="field-input compact-select"
value={actionTypeValue ?? ''}
onChange={(event) =>
updateFilters({
action_type: event.target.value || undefined,
page: '1',
})
}
>
<option value="">全部类型</option>
<option value="script">脚本</option>
<option value="http">HTTP</option>
<option value="process">进程</option>
</select>
</label>
{(q || enabledValue || actionTypeValue) && (
<button
type="button"
className="text-button"
onClick={() => {
setSearchParams({})
}}
>
清除筛选
</button>
)}
<button
type="button"
className="text-button"
onClick={refreshAll}
disabled={tasksQuery.isFetching}
>
{tasksQuery.isFetching ? '同步中...' : '刷新'}
</button>
</div>
</div>
{tasksQuery.isError ? (
<div className="state-panel state-error">
<h2>任务列表加载失败</h2>
<p>{tasksQuery.error.message}</p>
<button
type="button"
className="button button-secondary"
onClick={() => void tasksQuery.refetch()}
>
重试
</button>
</div>
) : tasksQuery.isPending ? (
<div className="state-panel">
<span className="loading-mark" aria-hidden="true" />
<p>正在读取任务...</p>
</div>
) : tasks.length === 0 ? (
<div className="state-panel">
<p className="section-label">EMPTY</p>
<h2>{q || enabledValue || actionTypeValue ? '没有匹配任务' : '还没有任务'}</h2>
<p>
{q || enabledValue || actionTypeValue
? '请调整搜索条件后重试。'
: '创建第一个定时任务,开始使用真实调度能力。'}
</p>
{!q && !enabledValue && !actionTypeValue ? (
<button
type="button"
className="button button-primary"
onClick={openCreate}
>
新建任务
</button>
) : null}
</div>
) : (
<>
<div className="table-container">
<table className="task-table">
<thead>
<tr>
<th>名称 / 任务标识</th>
<th>调度周期</th>
<th>类型</th>
<th>状态</th>
<th>超时配置</th>
<th className="align-right">操作</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const isRunning =
runMutation.isPending &&
runMutation.variables?.id === task.id
return (
<tr key={task.id}>
<td>
<div className="task-name">{task.name}</div>
<div className="task-id mono">{task.id}</div>
</td>
<td>
<code>{task.cron}</code>
<div className="cell-note">{task.timezone}</div>
</td>
<td>
<span className="type-badge">
{actionTypeLabel(task.action.type)}
</span>
<div className="cell-note">
超时 {task.timeout_secs}s
</div>
</td>
<td>
<EnabledBadge enabled={task.enabled} />
</td>
<td className="cell-note">超时 {task.timeout_secs}s</td>
<td>
<div className="row-actions">
<button
type="button"
className="button button-secondary button-small"
disabled={isRunning}
title="立即运行"
onClick={() => runMutation.mutate(task)}
>
{isRunning ? '提交中' : '运行'}
</button>
<button
type="button"
className="button button-secondary button-small"
onClick={() => {
setLogTask(task)
setInitialRunId(null)
}}
>
日志
</button>
<button
type="button"
className="button button-secondary button-small"
onClick={() => openEdit(task)}
>
编辑
</button>
<button
type="button"
className="button button-secondary button-small"
disabled={
toggleMutation.isPending &&
toggleMutation.variables?.id === task.id
}
onClick={() => toggleMutation.mutate(task)}
>
{task.enabled ? '停用' : '启用'}
</button>
<button
type="button"
className="button button-ghost button-small danger-text"
onClick={() => setDeleteTarget(task)}
>
删除
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<footer className="pagination">
<span>
共 {total} 个任务,第 {page} / {totalPages} 页
</span>
<div>
<button
type="button"
className="button button-secondary button-small"
disabled={page <= 1}
onClick={() =>
updateFilters({ page: String(Math.max(1, page - 1)) })
}
>
上一页
</button>
<button
type="button"
className="button button-secondary button-small"
disabled={page >= totalPages}
onClick={() =>
updateFilters({ page: String(Math.min(totalPages, page + 1)) })
}
>
下一页
</button>
</div>
</footer>
</>
)}
</section>
<TaskEditorModal
open={editorOpen}
task={editingTask}
saving={saveMutation.isPending}
onClose={() => {
if (!saveMutation.isPending) {
setEditorOpen(false)
}
}}
onSubmit={async (values) => {
await saveMutation.mutateAsync({ task: editingTask, values })
}}
/>
<RunLogDrawer
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
open={Boolean(logTask)}
task={logTask}
initialRunId={initialRunId}
onClose={() => {
setLogTask(null)
setInitialRunId(null)
}}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除任务 ${deleteTarget?.id ?? ''}`}
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</main>
)
}
interface MetricCardProps {
label: string
value: number | undefined
loading: boolean
}
function MetricCard({ label, value, loading }: MetricCardProps) {
return (
<article className="metric-card">
<span>{label}</span>
<strong>{loading ? '...' : (value ?? '--')}</strong>
</article>
)
}
function isActionType(value: string | null): value is TaskActionType {
return value === 'script' || value === 'http' || value === 'process'
}
async function invalidateTaskData(queryClient: ReturnType<typeof useQueryClient>) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
queryClient.invalidateQueries({ queryKey: ['task-runs'] }),
])
}
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
})
+12
View File
@@ -0,0 +1,12 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
restoreMocks: true,
exclude: ['e2e/**', '**/node_modules/**', '**/dist/**'],
},
})
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.95.0"
components = ["rustfmt", "clippy"]
profile = "minimal"
targets = ["x86_64-pc-windows-msvc"]
+42
View File
@@ -0,0 +1,42 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$releaseRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'release\TaskPilot'))
$expectedPrefix = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'release')).TrimEnd('\') + '\'
if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to build outside release directory: $releaseRoot"
}
function Assert-ExitCode {
param([string]$Step)
if ($LASTEXITCODE -ne 0) {
throw "$Step failed with exit code $LASTEXITCODE"
}
}
Push-Location $projectRoot
try {
pnpm --dir frontend build
Assert-ExitCode 'Frontend build'
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
Assert-ExitCode 'Rust release build'
if (Test-Path -LiteralPath $releaseRoot) {
Remove-Item -LiteralPath $releaseRoot -Recurse -Force
}
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'data') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
Copy-Item -LiteralPath 'backend\target\release\task-pilot-backend.exe' -Destination $releaseRoot
Copy-Item -LiteralPath 'scripts\start.ps1' -Destination $releaseRoot
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
Write-Host "Release package: $releaseRoot"
}
finally {
Pop-Location
}
+34
View File
@@ -0,0 +1,34 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
function Assert-ExitCode {
param([string]$Step)
if ($LASTEXITCODE -ne 0) {
throw "$Step failed with exit code $LASTEXITCODE"
}
}
Push-Location $projectRoot
try {
pnpm --dir frontend lint
Assert-ExitCode 'Frontend lint'
pnpm --dir frontend typecheck
Assert-ExitCode 'Frontend typecheck'
pnpm --dir frontend test
Assert-ExitCode 'Frontend tests'
pnpm --dir frontend build
Assert-ExitCode 'Frontend build'
cargo fmt --manifest-path backend\Cargo.toml -- --check
Assert-ExitCode 'Rust formatting'
cargo clippy --manifest-path backend\Cargo.toml --all-targets -- -D warnings
Assert-ExitCode 'Rust clippy'
cargo test --manifest-path backend\Cargo.toml
Assert-ExitCode 'Rust tests'
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'e2e.ps1')
Assert-ExitCode 'Playwright end-to-end tests'
}
finally {
Pop-Location
}
+91
View File
@@ -0,0 +1,91 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$backendJob = $null
$viteJob = $null
function Assert-ExitCode {
param([string]$Step)
if ($LASTEXITCODE -ne 0) {
throw "$Step failed with exit code $LASTEXITCODE"
}
}
function Wait-HttpReady {
param([string]$Url)
for ($attempt = 0; $attempt -lt 60; $attempt++) {
try {
[void](Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2)
return
}
catch {
Start-Sleep -Milliseconds 200
}
}
throw "Service did not become ready: $Url"
}
Push-Location $projectRoot
try {
foreach ($port in 3000, 5173) {
if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) {
throw "Port $port is already in use"
}
}
cargo build --manifest-path backend\Cargo.toml
Assert-ExitCode 'Rust build'
$e2eData = [System.IO.Path]::GetFullPath((Join-Path $projectRoot '.e2e-data'))
$expectedE2eData = [System.IO.Path]::GetFullPath($projectRoot).TrimEnd('\') + '\.e2e-data'
if ($e2eData -ne $expectedE2eData) {
throw "Refusing to clean unexpected end-to-end data path: $e2eData"
}
if (Test-Path -LiteralPath $e2eData) {
Remove-Item -LiteralPath $e2eData -Recurse -Force
}
[void](New-Item -ItemType Directory -Path $e2eData -Force)
$env:TASK_PILOT_DATABASE_URL = 'sqlite://.e2e-data/task_pilot.db'
$env:TASK_PILOT_LOG_DIR = '.e2e-data/logs'
$env:TASKPILOT_E2E_EXTERNAL = '1'
$backendBinary = Join-Path $projectRoot 'backend\target\debug\task-pilot-backend.exe'
$nodeBinary = (Get-Command node).Source
$frontendRoot = Join-Path $projectRoot 'frontend'
$backendJob = Start-Job -ScriptBlock {
param($root, $binary)
Set-Location $root
& $binary
} -ArgumentList $projectRoot, $backendBinary
Wait-HttpReady 'http://127.0.0.1:3000/api/v1/health'
$viteJob = Start-Job -ScriptBlock {
param($root, $node)
Set-Location $root
& $node 'node_modules/vite/bin/vite.js' '--host' '127.0.0.1'
} -ArgumentList $frontendRoot, $nodeBinary
Wait-HttpReady 'http://127.0.0.1:5173'
pnpm --dir frontend exec playwright test
Assert-ExitCode 'Playwright tests'
}
finally {
foreach ($job in @($viteJob, $backendJob)) {
if ($job) {
Stop-Job -Job $job -ErrorAction SilentlyContinue
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
}
}
foreach ($port in 3000, 5173) {
$connection = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
if ($connection) {
Stop-Process -Id $connection.OwningProcess -Force -ErrorAction SilentlyContinue
}
}
Pop-Location
}
+23
View File
@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$contractPath = Join-Path $projectRoot 'contracts\openapi.json'
function Assert-ExitCode {
param([string]$Step)
if ($LASTEXITCODE -ne 0) {
throw "$Step failed with exit code $LASTEXITCODE"
}
}
Push-Location $projectRoot
try {
cargo run --quiet --manifest-path backend\Cargo.toml --bin export-openapi -- $contractPath
Assert-ExitCode 'OpenAPI export'
pnpm --dir frontend exec openapi-typescript ..\contracts\openapi.json -o src\api\generated.ts
Assert-ExitCode 'TypeScript API generation'
}
finally {
Pop-Location
}
+40
View File
@@ -0,0 +1,40 @@
param(
[switch]$OpenBrowser
)
$ErrorActionPreference = 'Stop'
$scriptRoot = [System.IO.Path]::GetFullPath($PSScriptRoot)
$projectRoot = Split-Path -Parent $scriptRoot
$packagedExecutable = Join-Path $scriptRoot 'task-pilot-backend.exe'
if (Test-Path -LiteralPath $packagedExecutable) {
$executable = $packagedExecutable
$workingDirectory = $scriptRoot
$staticDirectory = Join-Path $scriptRoot 'web'
}
else {
$executable = Join-Path $projectRoot 'backend\target\release\task-pilot-backend.exe'
$workingDirectory = $projectRoot
$staticDirectory = Join-Path $projectRoot 'frontend\dist'
if (-not (Test-Path -LiteralPath $executable)) {
throw 'Release executable not found. Run .\scripts\build-release.ps1 first.'
}
}
$env:TASK_PILOT_STATIC_DIR = $staticDirectory
$address = 'http://127.0.0.1:3000'
Write-Host "TaskPilot: $address"
Write-Host "Static assets: $staticDirectory"
if ($OpenBrowser) {
Start-Process $address
}
Push-Location $workingDirectory
try {
& $executable
}
finally {
Pop-Location
}