diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5928732 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..652c62e --- /dev/null +++ b/PLAN.md @@ -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 响应断言、认证方案和请求重放。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..51b0286 --- /dev/null +++ b/README.md @@ -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 +``` + +访问 。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 +``` + +访问 。打包使用同源 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)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。 diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..15c7169 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,2927 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "croner" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b5b023fc2978536bbb2a3b71237964fc4630bec1eb31077b4977127f4cdb9c0" +dependencies = [ + "chrono", + "derive_builder", + "strum", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.1", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "task-pilot-backend" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "chrono-tz", + "croner", + "futures-util", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", + "url", + "utoipa", + "utoipa-axum", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8765fe27aeff71012a3f90fa474cf1df863a7d0dd81ed25de4e63fff2544994f" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865319162dc3d0032e1e40ed11bd49d5b78e8ef576740589fd6823e2e9977423" +dependencies = [ + "axum", + "pastey", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d935f1c83fdf8b88f09bbe050d0cea78ea4afee6d7b0317403228c1200b2c8ab" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 3.0.9", +] + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..757dce5 --- /dev/null +++ b/backend/Cargo.toml @@ -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"] } diff --git a/backend/migrations/0001_create_tasks.sql b/backend/migrations/0001_create_tasks.sql new file mode 100644 index 0000000..4f001ce --- /dev/null +++ b/backend/migrations/0001_create_tasks.sql @@ -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); diff --git a/backend/migrations/0002_create_task_runs.sql b/backend/migrations/0002_create_task_runs.sql new file mode 100644 index 0000000..a8556c9 --- /dev/null +++ b/backend/migrations/0002_create_task_runs.sql @@ -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); diff --git a/backend/src/api.rs b/backend/src/api.rs new file mode 100644 index 0000000..a2899f1 --- /dev/null +++ b/backend/src/api.rs @@ -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 { + 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) -> Json { + 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, +) -> ApiResult> { + 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 { + 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 路由不存在") +} diff --git a/backend/src/bin/export-openapi.rs b/backend/src/bin/export-openapi.rs new file mode 100644 index 0000000..748962a --- /dev/null +++ b/backend/src/bin/export-openapi.rs @@ -0,0 +1,16 @@ +use std::{env, error::Error, fs, path::PathBuf}; + +fn main() -> Result<(), Box> { + 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(()) +} diff --git a/backend/src/cron.rs b/backend/src/cron.rs new file mode 100644 index 0000000..23a7a74 --- /dev/null +++ b/backend/src/cron.rs @@ -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, +} + +/// Calculates the next occurrences of a cron expression in local time. +pub fn next_occurrences( + expression: &str, + from: DateTime, + count: usize, +) -> Result>, 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, + count: usize, +) -> Result { + 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 + .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() + ] + ); + } +} diff --git a/backend/src/database.rs b/backend/src/database.rs new file mode 100644 index 0000000..a54549a --- /dev/null +++ b/backend/src/database.rs @@ -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> { + 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 +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..37ebfa2 --- /dev/null +++ b/backend/src/error.rs @@ -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 = Result; + +pub struct ApiJson(pub T); + +impl FromRequest for ApiJson +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = ApiError; + + async fn from_request(request: Request, state: &S) -> Result { + Json::::from_request(request, state) + .await + .map(|Json(value)| Self(value)) + .map_err(json_rejection) + } +} + +pub struct ApiQuery(pub T); + +impl FromRequestParts for ApiQuery +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result { + Query::::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, + details: BTreeMap, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + pub details: BTreeMap, +} + +impl ApiError { + pub fn bad_request(code: &'static str, message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code, + message: message.into(), + field: None, + details: BTreeMap::new(), + } + } + + pub fn validation(field: impl Into, message: impl Into) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: "FORBIDDEN", + message: message.into(), + field: None, + details: BTreeMap::new(), + } + } + + pub fn conflict(message: impl Into) -> 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) -> 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 for ApiError { + fn from(error: sqlx::Error) -> Self { + Self::internal(error) + } +} + +impl From 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 {} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..7bc9489 --- /dev/null +++ b/backend/src/lib.rs @@ -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 { + 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()) +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..4bbc115 --- /dev/null +++ b/backend/src/main.rs @@ -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> { + init_tracing(); + + let port = env::var("TASK_PILOT_PORT") + .ok() + .and_then(|value| value.parse::().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> { + 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"); + } +} diff --git a/backend/src/process_job.rs b/backend/src/process_job.rs new file mode 100644 index 0000000..9c4555c --- /dev/null +++ b/backend/src/process_job.rs @@ -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 { + 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::(), + size_of::() 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 { + Ok(Self) + } + + pub fn assign(&self, _child: &Child) -> io::Result<()> { + Ok(()) + } + + pub fn terminate(&self) -> io::Result<()> { + Ok(()) + } + } +} + +pub use platform::ProcessJob; diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs new file mode 100644 index 0000000..c1dc57a --- /dev/null +++ b/backend/src/runs/executor.rs @@ -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, +} + +struct ExecutorInner { + pool: SqlitePool, + logs_dir: PathBuf, + shutdown: CancellationToken, + running: Mutex>, + events: broadcast::Sender, + http: reqwest::Client, + log_config: LogRetentionConfig, + cleanup_lock: AsyncMutex<()>, +} + +struct RunningHandle { + run_id: Option, + cancellation: CancellationToken, +} + +#[derive(Clone)] +struct LogSink { + file: Arc>, + size: Arc, + truncated: Arc, + run_id: i64, + events: broadcast::Sender, + secrets: Arc>, +} + +struct ScriptOutcome { + status: RunStatus, + exit_code: Option, + error_message: Option, +} + +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 { + 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 { + 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, + secrets: Vec, + ) -> std::io::Result { + 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>, +) -> std::sync::MutexGuard<'_, HashMap> { + running + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn spawn_log_reader( + 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 { + 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) -> 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::(); + 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::(); + assert!(total <= 10); + } +} diff --git a/backend/src/runs/mod.rs b/backend/src/runs/mod.rs new file mode 100644 index 0000000..c19e7ea --- /dev/null +++ b/backend/src/runs/mod.rs @@ -0,0 +1,4 @@ +pub mod executor; +pub mod model; +pub mod repository; +pub mod routes; diff --git a/backend/src/runs/model.rs b/backend/src/runs/model.rs new file mode 100644 index 0000000..56a6a35 --- /dev/null +++ b/backend/src/runs/model.rs @@ -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>, + pub finished_at: Option>, + pub duration_ms: Option, + pub exit_code: Option, + pub error_message: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TaskRunListResponse { + pub items: Vec, + 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, + pub page_size: Option, + pub status: Option, + pub trigger: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct LogChunkQuery { + pub offset: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct LogChunkResponse { + pub content: String, + pub next_offset: u64, + pub complete: bool, +} diff --git a/backend/src/runs/repository.rs b/backend/src/runs/repository.rs new file mode 100644 index 0000000..37e7b87 --- /dev/null +++ b/backend/src/runs/repository.rs @@ -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>, + finished_at: Option>, + duration_ms: Option, + exit_code: Option, + error_message: Option, + created_at: DateTime, +} + +impl TryFrom for TaskRunResponse { + type Error = ApiError; + + fn try_from(row: TaskRunRow) -> Result { + 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 { + 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 { + 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, +) -> 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, + duration_ms: u64, + exit_code: Option, + 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> { + 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 { + 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::>>()?; + + Ok(TaskRunListResponse { + items, + page, + page_size, + total: u64::try_from(total).map_err(ApiError::internal)?, + }) +} + +fn parse_trigger(value: &str) -> ApiResult { + 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 { + 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}"))), + } +} diff --git a/backend/src/runs/routes.rs b/backend/src/runs/routes.rs new file mode 100644 index 0000000..0689eb5 --- /dev/null +++ b/backend/src/runs/routes.rs @@ -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 { + 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, + Path(id): Path, +) -> ApiResult<(StatusCode, Json)> { + 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, + Path(id): Path, + ApiQuery(query): ApiQuery, +) -> ApiResult> { + 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, + Path(run_id): Path, +) -> ApiResult> { + 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, + Path(run_id): Path, + ApiQuery(query): ApiQuery, +) -> ApiResult> { + 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, + Path(run_id): Path, +) -> ApiResult<(StatusCode, Json)> { + 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, + Path(run_id): Path, +) -> ApiResult { + 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::() + .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, +) -> Sse>> { + 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))) +} diff --git a/backend/src/scheduler.rs b/backend/src/scheduler.rs new file mode 100644 index 0000000..4fddc24 --- /dev/null +++ b/backend/src/scheduler.rs @@ -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>, +} + +#[derive(Clone)] +pub struct Scheduler { + inner: Arc, +} + +impl Scheduler { + pub async fn start(pool: SqlitePool, executor: TaskExecutor) -> ApiResult { + 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) -> Result, 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>, +) -> MutexGuard<'_, HashMap> { + 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")); + } +} diff --git a/backend/src/security.rs b/backend/src/security.rs new file mode 100644 index 0000000..db311b7 --- /dev/null +++ b/backend/src/security.rs @@ -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, + request: Request, + next: Next, +) -> ApiResult { + 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")); + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs new file mode 100644 index 0000000..a053042 --- /dev/null +++ b/backend/src/state.rs @@ -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 { + 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::() + { + log_config.retention_days = days; + } + if let Ok(value) = env::var("TASK_PILOT_LOG_MAX_TOTAL_MB") + && let Ok(megabytes) = value.parse::() + { + 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 + } +} diff --git a/backend/src/tasks/mod.rs b/backend/src/tasks/mod.rs new file mode 100644 index 0000000..10fa71a --- /dev/null +++ b/backend/src/tasks/mod.rs @@ -0,0 +1,4 @@ +pub mod model; +pub(crate) mod repository; +pub mod routes; +pub mod service; diff --git a/backend/src/tasks/model.rs b/backend/src/tasks/model.rs new file mode 100644 index 0000000..b66f08b --- /dev/null +++ b/backend/src/tasks/model.rs @@ -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, + #[serde(default)] + env: BTreeMap, + }, + Http { + url: String, + method: HttpMethod, + #[serde(default)] + headers: BTreeMap, + #[serde(default)] + body: Option, + #[serde(default)] + expected_status: Vec, + }, + Process { + executable: String, + #[serde(default)] + working_dir: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + }, +} + +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, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TaskListResponse { + pub items: Vec, + 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, + pub enabled: Option, + pub action_type: Option, + pub page: Option, + pub page_size: Option, +} diff --git a/backend/src/tasks/repository.rs b/backend/src/tasks/repository.rs new file mode 100644 index 0000000..b6c1224 --- /dev/null +++ b/backend/src/tasks/repository.rs @@ -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, + pub enabled: Option, + pub action_type: Option, + 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, + updated_at: DateTime, +} + +impl TryFrom for TaskResponse { + type Error = ApiError; + + fn try_from(row: TaskRow) -> Result { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + let mut count_builder = QueryBuilder::::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::::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::() + .fetch_all(pool) + .await?; + let items = rows + .into_iter() + .map(TaskResponse::try_from) + .collect::>>()?; + + 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> { + 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::>>() +} + +fn apply_filters(builder: &mut QueryBuilder, 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()) +} diff --git a/backend/src/tasks/routes.rs b/backend/src/tasks/routes.rs new file mode 100644 index 0000000..fe78af8 --- /dev/null +++ b/backend/src/tasks/routes.rs @@ -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 { + 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, + ApiQuery(query): ApiQuery, +) -> ApiResult> { + 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, + ApiJson(request): ApiJson, +) -> ApiResult<(StatusCode, Json)> { + 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, + Path(id): Path, +) -> ApiResult> { + 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, + Path(id): Path, + ApiJson(request): ApiJson, +) -> ApiResult> { + 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, + Path(id): Path, +) -> ApiResult { + 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, + Path(id): Path, +) -> ApiResult> { + 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, + Path(id): Path, +) -> ApiResult> { + let task = service::set_enabled(&state.pool, &id, false).await?; + state.scheduler.cancel(&id); + Ok(Json(task)) +} diff --git a/backend/src/tasks/service.rs b/backend/src/tasks/service.rs new file mode 100644 index 0000000..4b8a0b7 --- /dev/null +++ b/backend/src/tasks/service.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) -> 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) -> 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"); + } +} diff --git a/backend/tests/api.rs b/backend/tests/api.rs new file mode 100644 index 0000000..71c7aef --- /dev/null +++ b/backend/tests/api.rs @@ -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()); +} diff --git a/backend/tests/runs.rs b/backend/tests/runs.rs new file mode 100644 index 0000000..56dc7a7 --- /dev/null +++ b/backend/tests/runs.rs @@ -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) -> 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; +} diff --git a/backend/tests/static_site.rs b/backend/tests/static_site.rs new file mode 100644 index 0000000..bdb783f --- /dev/null +++ b/backend/tests/static_site.rs @@ -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"), + "TaskPilot SPA", + ) + .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(); +} diff --git a/backend/tests/tasks.rs b/backend/tests/tasks.rs new file mode 100644 index 0000000..6937886 --- /dev/null +++ b/backend/tests/tasks.rs @@ -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) -> 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; +} diff --git a/contracts/openapi.json b/contracts/openapi.json new file mode 100644 index 0000000..b62c429 --- /dev/null +++ b/contracts/openapi.json @@ -0,0 +1,1323 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "TaskPilot API", + "description": "Local task scheduling and process management API.", + "license": { + "name": "" + }, + "version": "0.1.0" + }, + "paths": { + "/api/v1/cron/preview": { + "post": { + "tags": [ + "system" + ], + "operationId": "preview_cron", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronPreviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upcoming cron occurrences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronPreviewResponse" + } + } + } + }, + "400": { + "description": "Invalid cron preview request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/events": { + "get": { + "tags": [ + "runs" + ], + "operationId": "execution_events", + "responses": { + "200": { + "description": "Run status and log events", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "system" + ], + "operationId": "health", + "responses": { + "200": { + "description": "TaskPilot API is healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}": { + "get": { + "tags": [ + "runs" + ], + "operationId": "get_run", + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Task run detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunResponse" + } + } + } + }, + "404": { + "description": "Run not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/cancel": { + "post": { + "tags": [ + "runs" + ], + "operationId": "cancel_run", + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "202": { + "description": "Cancellation requested", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunResponse" + } + } + } + }, + "404": { + "description": "Run not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Run is not cancellable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/logs": { + "get": { + "tags": [ + "runs" + ], + "operationId": "get_run_logs", + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Incremental task run log", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogChunkResponse" + } + } + } + }, + "404": { + "description": "Run not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/logs/download": { + "get": { + "tags": [ + "runs" + ], + "operationId": "download_run_logs", + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Plain text task log", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Run or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/session": { + "get": { + "tags": [ + "system" + ], + "operationId": "session", + "responses": { + "200": { + "description": "Local API session token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks": { + "get": { + "tags": [ + "tasks" + ], + "operationId": "list_tasks", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "action_type", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/TaskActionType" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Paginated task list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "tasks" + ], + "operationId": "create_task", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Task created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Task ID already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks/{id}": { + "get": { + "tags": [ + "tasks" + ], + "operationId": "get_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "tasks" + ], + "operationId": "update_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Task updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "tasks" + ], + "operationId": "delete_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Task deleted" + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks/{id}/disable": { + "post": { + "tags": [ + "tasks" + ], + "operationId": "disable_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks/{id}/enable": { + "post": { + "tags": [ + "tasks" + ], + "operationId": "enable_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks/{id}/run": { + "post": { + "tags": [ + "runs" + ], + "operationId": "run_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Manual run accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunResponse" + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Action executor not implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/tasks/{id}/runs": { + "get": { + "tags": [ + "runs" + ], + "operationId": "list_task_runs", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Stable task identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/RunStatus" + } + }, + { + "name": "trigger", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/RunTrigger" + } + } + ], + "responses": { + "200": { + "description": "Task run history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunListResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ConcurrencyPolicy": { + "type": "string", + "enum": [ + "skip" + ] + }, + "CreateTaskRequest": { + "type": "object", + "required": [ + "id", + "name", + "cron", + "action" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/TaskAction" + }, + "concurrency_policy": { + "$ref": "#/components/schemas/ConcurrencyPolicy" + }, + "cron": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "retry_policy": { + "$ref": "#/components/schemas/RetryPolicy" + }, + "timeout_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "timezone": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CronPreviewRequest": { + "type": "object", + "required": [ + "expression", + "timezone", + "count" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "expression": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CronPreviewResponse": { + "type": "object", + "required": [ + "timezone", + "runs" + ], + "properties": { + "runs": { + "type": "array", + "items": { + "type": "string" + } + }, + "timezone": { + "type": "string" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "code", + "message", + "details" + ], + "properties": { + "code": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "field": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": "string" + } + } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "service", + "version" + ], + "properties": { + "service": { + "type": "string", + "description": "Stable service identifier." + }, + "status": { + "type": "string", + "description": "Current service status." + }, + "version": { + "type": "string", + "description": "Backend package version." + } + } + }, + "HttpMethod": { + "type": "string", + "enum": [ + "GET", + "POST" + ] + }, + "LogChunkResponse": { + "type": "object", + "required": [ + "content", + "next_offset", + "complete" + ], + "properties": { + "complete": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "next_offset": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "RetryPolicy": { + "type": "object", + "properties": { + "delay_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "max_attempts": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "RunStatus": { + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "timed_out", + "cancelled", + "skipped" + ] + }, + "RunTrigger": { + "type": "string", + "enum": [ + "schedule", + "manual", + "retry" + ] + }, + "SessionResponse": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Random per-process token required for mutating browser requests." + } + } + }, + "TaskAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "working_dir", + "interpreter", + "script", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "interpreter": { + "type": "string" + }, + "script": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "script" + ] + }, + "working_dir": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "url", + "method", + "type" + ], + "properties": { + "body": { + "type": [ + "string", + "null" + ] + }, + "expected_status": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "method": { + "$ref": "#/components/schemas/HttpMethod" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "url": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "executable", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "executable": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "process" + ] + }, + "working_dir": { + "type": [ + "string", + "null" + ] + } + } + } + ] + }, + "TaskActionType": { + "type": "string", + "enum": [ + "script", + "http", + "process" + ] + }, + "TaskListResponse": { + "type": "object", + "required": [ + "items", + "page", + "page_size", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskResponse" + } + }, + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "TaskResponse": { + "type": "object", + "required": [ + "id", + "name", + "cron", + "timezone", + "enabled", + "timeout_secs", + "concurrency_policy", + "retry_policy", + "action", + "created_at", + "updated_at" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/TaskAction" + }, + "concurrency_policy": { + "$ref": "#/components/schemas/ConcurrencyPolicy" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "cron": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "retry_policy": { + "$ref": "#/components/schemas/RetryPolicy" + }, + "timeout_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "timezone": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TaskRunListResponse": { + "type": "object", + "required": [ + "items", + "page", + "page_size", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskRunResponse" + } + }, + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "TaskRunResponse": { + "type": "object", + "required": [ + "id", + "task_id", + "trigger", + "status", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "exit_code": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/RunStatus" + }, + "task_id": { + "type": "string" + }, + "trigger": { + "$ref": "#/components/schemas/RunTrigger" + } + } + }, + "UpdateTaskRequest": { + "type": "object", + "required": [ + "name", + "cron", + "action" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/TaskAction" + }, + "concurrency_policy": { + "$ref": "#/components/schemas/ConcurrencyPolicy" + }, + "cron": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "retry_policy": { + "$ref": "#/components/schemas/RetryPolicy" + }, + "timeout_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "timezone": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } +} diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..8f50a9c --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,45 @@ +# TaskPilot Windows 本地版 + +## 启动 + +```powershell +.\start.ps1 +``` + +然后访问 。 + +需要自动打开浏览器时: + +```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 配置有效,并在日志抽屉查看错误。 +- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。 diff --git a/docs/decisions/0001-cron-engine.md b/docs/decisions/0001-cron-engine.md new file mode 100644 index 0000000..08fab31 --- /dev/null +++ b/docs/decisions/0001-cron-engine.md @@ -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 时区。 diff --git a/docs/decisions/0002-static-hosting.md b/docs/decisions/0002-static-hosting.md new file mode 100644 index 0000000..6c6801e --- /dev/null +++ b/docs/decisions/0002-static-hosting.md @@ -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 只完成开发代理。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..5126c11 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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" + } +} diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts new file mode 100644 index 0000000..64e7484 --- /dev/null +++ b/frontend/e2e/dashboard.spec.ts @@ -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() +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..595f0b3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + TaskPilot + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..305745b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "taskpilot-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@11.20.0", + "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" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..b0c06e9 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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 }, + }, + }, + ], +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..50642fa --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1894 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.9.2 + version: 5.9.2(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7) + '@tanstack/react-query': + specifier: ^5.104.3 + version: 5.104.3(react@19.3.1) + react: + specifier: ^19.2.8 + version: 19.3.1 + react-dom: + specifier: ^19.2.8 + version: 19.3.1(react@19.3.1) + react-hook-form: + specifier: ^7.90.0 + version: 7.90.0(@types/react@19.3.0)(react@19.3.1) + react-router-dom: + specifier: ^7.18.4 + version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + zod: + specifier: ^4.6.7 + version: 4.6.7 + devDependencies: + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.5(@types/node@24.19.2)(jsdom@30.1.3)(vite@8.3.4(@types/node@24.19.2))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@testing-library/user-event': + specifier: ^14.6.8 + version: 14.6.8(@testing-library/dom@10.4.2) + '@types/node': + specifier: ^24.13.3 + version: 24.19.2 + '@types/react': + specifier: ^19.2.18 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.2.7 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.2(vite@8.3.4(@types/node@24.19.2)) + jsdom: + specifier: ^30.1.3 + version: 30.1.3 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) + oxlint: + specifier: ^1.81.0 + version: 1.88.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.2) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@24.19.2)(jsdom@30.1.3)(vite@8.3.4(@types/node@24.19.2)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@7.1.6': + resolution: {integrity: sha512-jZJUZX0mR6JqnjQGb79yTYbR5WXmACmcLoFSgZk+7W+0qMJ8jWGRp9ncYvE1ihwL3dwbmfeCe6/O0rRwWTko4g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@10.0.4': + resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@hookform/resolvers@5.9.2': + resolution: {integrity: sha512-RtjTnZqsC38s4NKp2XO3fD8IA5uyN4tB7McQ5C6uiMPcV+WWO8F+wVa3e+BXUpH2ScgxghAlwFRMVqjT9WChAQ==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.23.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 || ^18.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=6.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.21': + resolution: {integrity: sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tanstack/query-core@5.104.3': + resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==} + + '@tanstack/react-query@5.104.3': + resolution: {integrity: sha512-5Ts1ImwJKqoJgPkLxl9WrPQy1JEoEmti3MaaODCfBamHUGFk57IFrc+AIksPq+BAm10EsroX56qm398Z42LG7w==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.8': + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsdom@30.1.3: + resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.90.0: + resolution: {integrity: sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-router-dom@7.18.4: + resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.12.2: + resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==} + engines: {node: '>=22.19.0'} + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@7.1.6': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@10.0.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.10': {} + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@exodus/bytes@1.16.0': {} + + '@hookform/resolvers@5.9.2(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.90.0(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + zod: 4.6.7 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.154.0': {} + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.21(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.2 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/utils@0.3.0': {} + + '@tanstack/query-core@5.104.3': {} + + '@tanstack/react-query@5.104.3(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.104.3 + react: 19.3.1 + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.5(@types/node@24.19.2)(jsdom@30.1.3)(vite@8.3.4(@types/node@24.19.2)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.5(@types/node@24.19.2)(jsdom@30.1.3)(vite@8.3.4(@types/node@24.19.2)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.19.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.2) + + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@24.19.2))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@24.19.2) + + '@vitest/spy@5.0.5': {} + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + chai@6.3.0: {} + + change-case@5.4.4: {} + + colorette@1.4.0: {} + + cookie@1.1.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@8.1.0: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + indent-string@4.0.0: {} + + index-to-position@1.2.0: {} + + is-potential-custom-element-name@1.0.1: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsdom@30.1.3: + dependencies: + '@asamuzakjp/css-color': 7.1.6 + '@asamuzakjp/dom-selector': 10.0.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.12.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.3: {} + + lz-string@1.5.0: {} + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + obug@3.0.0: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.21(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + oxlint@1.88.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + + pluralize@8.0.0: {} + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@17.0.2: {} + + react-router-dom@7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-router: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + + react-router@7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + cookie: 1.1.1 + react: 19.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + + react@19.3.1: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + set-cookie-parser@2.7.2: {} + + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@10.2.2: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + undici@8.12.2: {} + + uri-js-replace@1.0.1: {} + + vite@8.3.4(@types/node@24.19.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.2 + fsevents: 2.3.3 + + vitest@5.0.5(@types/node@24.19.2)(jsdom@30.1.3)(vite@8.3.4(@types/node@24.19.2)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@24.19.2)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@24.19.2) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.2 + jsdom: 30.1.3 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.3.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@3.2.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + zod@4.6.7: {} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..2de5b02 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,1251 @@ +.app-shell { + width: min(1040px, calc(100% - 48px)); + margin: 0 auto; + padding: 48px 0; +} + +.dashboard-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 32px; +} + +.eyebrow, +.section-label { + margin: 0 0 10px; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.14em; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 6px; + font-size: 26px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.03em; +} + +h2 { + margin-bottom: 8px; + font-size: 22px; + letter-spacing: -0.025em; +} + +h3 { + margin-bottom: 0; + font-size: 17px; + letter-spacing: -0.02em; +} + +.subtitle { + margin-bottom: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.connection-pill { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + border: 1px solid #d7f2e7; + border-radius: 999px; + padding: 0 14px; + background: var(--success-bg); + color: var(--success); + font-size: 13px; + font-weight: 600; +} + +.connection-pill .status-dot { + background: currentColor; +} + +.connection-pill.is-offline { + border-color: #ffd9df; + background: var(--danger-bg); + color: var(--danger); +} + +.button { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + text-decoration: none; + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease, + transform 150ms ease; +} + +.button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.button-primary { + background: var(--text); + color: #fff; +} + +.button-primary:hover:not(:disabled) { + background: #293241; +} + +.button-secondary { + border-color: var(--border); + background: var(--surface); + color: var(--text); +} + +.button-secondary:hover:not(:disabled) { + border-color: #c8ccd3; + background: #f7f8fa; +} + +.button-danger { + background: var(--danger); + color: #fff; +} + +.button-danger:hover:not(:disabled) { + background: #b91c3b; +} + +.button-ghost { + border-color: transparent; + background: transparent; +} + +.button-small { + min-height: 28px; + border-radius: 6px; + padding: 5px 11px; + font-size: 12px; +} + +.text-button { + border: 0; + padding: 3px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + text-decoration: underline; + text-underline-offset: 3px; +} + +.text-button:hover { + color: var(--text); +} + +.danger-text { + color: var(--danger) !important; +} + +.toast-message { + position: fixed; + z-index: 80; + right: 24px; + bottom: 24px; + max-width: min(420px, calc(100vw - 48px)); + border: 1px solid #2f3845; + border-radius: 12px; + padding: 14px 18px; + background: #111827; + color: #fff; + box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2); + font-size: 13px; + animation: toast-in 180ms ease-out; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +.inline-alert { + border: 1px solid var(--border); + border-radius: 11px; + padding: 12px 14px; + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; +} + +.inline-alert-error { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border-color: #ffd7df; + background: var(--danger-bg); + color: var(--danger); +} + +.page-alert { + margin-bottom: 18px; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 28px; +} + +.metric-card { + min-width: 0; + min-height: 0; + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + background: var(--surface); + box-shadow: var(--shadow-sm); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-card); +} + +.metric-card > span { + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; +} + +.metric-card > strong { + overflow: hidden; + display: block; + margin-top: 8px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-card > small { + display: none; +} + +.metric-danger { + color: var(--danger); +} + +.card { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-bottom: 1px solid var(--border); + padding: 12px 18px; + background: #fafafa; +} + +.search-form { + display: flex; + min-width: min(430px, 48vw); + gap: 8px; +} + +.search-form .button { + flex: none; + min-width: 68px; + white-space: nowrap; +} + +.filter-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.search-input { + width: 100%; +} + +.field-input { + width: 100%; + min-height: 38px; + border: 1px solid var(--border); + border-radius: 8px; + outline: 0; + padding: 8px 12px; + font-size: 13px; + background: var(--surface); + color: var(--text); + transition: + border-color 150ms ease, + box-shadow 150ms ease; +} + +.field-input:hover { + border-color: #cfd3da; +} + +.field-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.06); +} + +.field-input:disabled { + background: var(--surface-muted); + color: var(--text-tertiary); +} + +.compact-select { + min-width: 112px; + padding-right: 30px; + font-size: 12px; +} + +.mono, +code { + font-family: var(--font-mono); +} + +code { + display: inline-block; + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + background: var(--badge-bg); + color: #374151; + font-size: 12px; +} + +.table-container { + width: 100%; + overflow-x: auto; +} + +.task-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + text-align: left; +} + +.task-table th { + padding: 14px 12px; + background: #fafafa; + color: var(--text-tertiary); + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.task-table td { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 16px 12px; + font-size: 14px; + vertical-align: middle; +} + +.task-table tbody tr { + transition: background-color 120ms ease; +} + +.task-table tbody tr:hover { + background: #fbfcfd; +} + +.task-name { + max-width: 240px; + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-id { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 12px; +} + +.cell-note { + margin-top: 4px; + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; +} + +.align-right { + text-align: right !important; +} + +.row-actions { + display: flex; + justify-content: flex-end; + gap: 4px; + white-space: nowrap; +} + +.row-actions .button-small { + padding-right: 8px; + padding-left: 8px; +} + +.type-badge { + display: inline-flex; + padding: 0; + background: transparent; + color: #4b5563; + font-size: 12px; + font-weight: 400; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + background: var(--badge-bg); + color: #5d6470; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-enabled, +.status-succeeded { + background: #ecfdf5; + color: #067a56; +} + +.status-disabled, +.status-cancelled, +.status-skipped { + background: #f3f4f6; + color: #6b7280; +} + +.status-queued { + background: #eff6ff; + color: #2563eb; +} + +.status-running { + background: #fffbeb; + color: #b45309; +} + +.status-running .status-dot { + animation: pulse 1.2s ease-in-out infinite; +} + +.status-failed, +.status-timed_out { + background: var(--danger-bg); + color: var(--danger); +} + +@keyframes pulse { + 50% { + opacity: 0.3; + transform: scale(0.7); + } +} + +.state-panel { + display: flex; + min-height: 280px; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 40px 24px; + text-align: center; +} + +.state-panel h2 { + margin-bottom: 8px; +} + +.state-panel p { + max-width: 480px; + color: var(--text-secondary); + line-height: 1.7; +} + +.state-error h2 { + color: var(--danger); +} + +.loading-mark { + width: 26px; + height: 26px; + margin-bottom: 14px; + border: 2px solid var(--border); + border-top-color: var(--text); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--border); + padding: 14px 20px; + color: var(--text-secondary); + font-size: 12px; +} + +.pagination > div { + display: flex; + gap: 6px; +} + +.modal-overlay { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(17, 24, 39, 0.25); + backdrop-filter: blur(8px); + animation: overlay-in 150ms ease-out; +} + +@keyframes overlay-in { + from { + opacity: 0; + } +} + +.modal-card, +.confirm-card { + width: min(660px, 100%); + max-height: calc(100vh - 48px); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-modal); + animation: modal-in 180ms ease-out; +} + +@keyframes modal-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } +} + +.task-editor { + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--border); + padding: 20px 28px; +} + +.modal-header h2 { + margin-bottom: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.icon-button { + display: inline-flex; + width: 30px; + height: 30px; + flex: none; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 20px; + line-height: 1; +} + +.icon-button:hover:not(:disabled) { + background: var(--surface-muted); + color: var(--text); +} + +.modal-body { + overflow-y: auto; + padding: 24px 28px; + max-height: 75vh; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.form-grid-three { + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr); +} + +.cron-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cron-n-selector { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 12px; +} + +.cron-n-selector select { + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; + background: #fff; + color: var(--text); + font: inherit; +} + +.cron-preview-box { + display: flex; + max-height: 160px; + flex-direction: column; + gap: 6px; + overflow-y: auto; + border: 1px solid #eeeff2; + border-radius: 8px; + padding: 10px 12px; + background: #fafafa; +} + +.cron-run-item { + display: flex; + align-items: center; + gap: 8px; + color: #374151; + font-family: var(--font-mono); + font-size: 12px; +} + +.cron-run-index { + width: 28px; + color: var(--text-secondary); + font-size: 11px; + text-align: right; +} + +.cron-run-time { + border: 1px solid #e5e7eb; + border-radius: 4px; + padding: 2px 8px; + background: #fff; + color: var(--text); + font-weight: 500; +} + +.cron-preview-message, +.cron-preview-error { + color: var(--text-secondary); + font-size: 12px; +} + +.cron-preview-error { + color: var(--danger); +} + +.field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 7px; +} + +.field-label { + color: #374151; + font-size: 13px; + font-weight: 600; +} + +.field-hint, +.field-error { + font-size: 11px; + line-height: 1.5; +} + +.field-hint { + color: var(--text-tertiary); +} + +.field-error { + color: var(--danger); +} + +.field-input[aria-invalid='true'] { + border-color: #f08aa0; + background: #fffafb; +} + +.field-textarea { + min-height: 104px; + resize: vertical; +} + +.checkbox-field { + display: flex; + align-items: center; + align-self: end; + gap: 11px; + min-height: 48px; + border: 1px solid var(--border); + border: 0; + border-radius: 8px; + padding: 8px 0; + background: transparent; + cursor: pointer; + margin-top: 6px; +} + +.checkbox-field input { + width: 16px; + height: 16px; + accent-color: var(--text); +} + +.checkbox-field span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.checkbox-field strong { + font-size: 12px; +} + +.checkbox-field small, +.collection-header small { + color: var(--text-tertiary); + font-size: 10px; +} + +.action-panel { + margin: 22px 0 0; + border: 1px solid #eeeff2; + border-radius: 12px; + padding: 20px; + background: #fafafa; +} + +.action-panel legend { + padding: 0 8px; + color: #374151; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + letter-spacing: 0; +} + +.collection-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin: 18px 0 10px; +} + +.collection-header > div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.collection-header strong { + font-size: 12px; +} + +.collection-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.argument-row, +.key-value-row { + display: grid; + align-items: center; + gap: 8px; +} + +.argument-row { + grid-template-columns: 1fr auto; +} + +.key-value-row { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; +} + +.empty-inline { + margin: 0; + color: var(--text-tertiary); + font-size: 12px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 24px -28px -24px; + border-top: 1px solid var(--border); + padding: 16px 28px; + background: #fafafb; +} + +.confirm-card { + width: min(440px, 100%); + padding: 28px; +} + +.confirm-card .eyebrow { + margin-bottom: 12px; +} + +.muted-copy { + color: var(--text-secondary); + line-height: 1.7; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} + +.drawer-overlay { + align-items: center; + justify-content: center; + padding: 24px; +} + +.run-drawer { + display: flex; + width: min(1040px, 100%); + height: min(82vh, 820px); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-modal); + animation: drawer-in 220ms ease-out; +} + +@keyframes drawer-in { + from { + transform: translateY(8px) scale(0.985); + opacity: 0; + } +} + +.run-drawer .modal-header { + flex: none; +} + +.drawer-subtitle { + margin: 4px 0 0; + color: var(--text-tertiary); + font-size: 11px; +} + +.run-drawer-body { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: minmax(250px, 0.75fr) minmax(0, 1.7fr); +} + +.run-history-panel, +.run-detail-panel { + min-width: 0; + overflow-y: auto; + padding: 22px; +} + +.run-history-panel { + border-right: 1px solid var(--border); + background: #fafbfc; +} + +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-heading .section-label { + margin-bottom: 5px; +} + +.run-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.history-filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + margin-bottom: 12px; +} + +.run-detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + flex-wrap: wrap; +} + +.run-list-item { + display: flex; + width: 100%; + flex-direction: column; + gap: 8px; + border: 1px solid transparent; + border-radius: 12px; + padding: 12px; + background: transparent; + color: var(--text); + text-align: left; +} + +.run-list-item:hover { + background: #fff; +} + +.run-list-item.is-selected { + border-color: var(--border); + background: #fff; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.04); +} + +.run-list-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + font-weight: 600; +} + +.run-list-meta { + color: var(--text-tertiary); + font-size: 10px; + line-height: 1.5; +} + +.run-metadata { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 0 0 18px; +} + +.run-metadata > div { + border: 1px solid var(--border); + border-radius: 11px; + padding: 11px; + background: var(--surface-muted); +} + +.run-metadata dt { + margin-bottom: 5px; + color: var(--text-tertiary); + font-size: 10px; +} + +.run-metadata dd { + overflow-wrap: anywhere; + margin: 0; + font-family: var(--font-mono); + font-size: 11px; +} + +.run-error-metadata { + grid-column: 1 / -1; + border-color: #ffd7df !important; + background: var(--danger-bg) !important; + color: var(--danger); +} + +.log-viewer-wrap { + overflow: hidden; + border: 1px solid #252c38; + border-radius: 14px; + background: #0d1117; +} + +.log-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #252c38; + padding: 10px 14px; + color: #aab4c3; + font-family: var(--font-mono); + font-size: 10px; +} + +.log-streaming { + color: #fbbf24; +} + +.log-complete { + color: #6ee7b7; +} + +.log-viewer { + min-height: 340px; + max-height: calc(100vh - 410px); + overflow: auto; + margin: 0; + padding: 16px; + color: #d1d5db; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +.log-viewer-wrap .inline-alert { + margin: 10px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 1040px) { + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toolbar { + align-items: stretch; + flex-direction: column; + } + + .search-form { + width: 100%; + min-width: 0; + } + + .filter-row { + justify-content: flex-start; + flex-wrap: wrap; + } + + .run-drawer { + width: 100%; + } + + .run-drawer-body { + grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr); + } +} + +@media (max-width: 760px) { + .app-shell { + width: min(100% - 24px, 1040px); + padding: 24px 0 40px; + } + + .dashboard-header, + .header-actions { + align-items: stretch; + flex-direction: column; + } + + .header-actions { + gap: 8px; + } + + .connection-pill { + align-self: flex-start; + } + + .metrics-grid { + grid-template-columns: 1fr; + } + + .metric-card { + min-height: 0; + } + + .search-form { + flex-direction: column; + } + + .filter-row { + align-items: stretch; + } + + .filter-row label { + flex: 1; + } + + .compact-select { + width: 100%; + } + + .pagination { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .modal-overlay { + padding: 0; + } + + .modal-card, + .confirm-card { + width: 100%; + max-height: 100vh; + border-radius: 0; + } + + .modal-body { + padding: 18px; + } + + .form-actions { + margin: 24px -18px -18px; + padding: 16px 18px; + } + + .form-grid, + .form-grid-three { + grid-template-columns: 1fr; + } + + .key-value-row { + grid-template-columns: 1fr; + } + + .run-drawer-body { + display: flex; + overflow-y: auto; + flex-direction: column; + } + + .run-history-panel, + .run-detail-panel { + overflow: visible; + } + + .run-history-panel { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .run-metadata { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .log-viewer { + min-height: 260px; + max-height: none; + } + + .run-drawer { + height: 100%; + border-radius: 0; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..cc10ca5 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -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 => { + 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( + + + + + , + ) +} + +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') + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c6bab2a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { Link, Route, Routes } from 'react-router-dom' +import { TaskDashboard } from './pages/TaskDashboard' +import './App.css' + +function NotFoundPage() { + return ( +
+
+
+

404 / ROUTE NOT FOUND

+

页面不存在

+

该地址尚未配置到 TaskPilot 路由。

+
+
+ + 返回任务列表 + +
+ ) +} + +function App() { + return ( + + } /> + } /> + + ) +} + +export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..7d8a461 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 + + 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( + path: string, + init: RequestInit = {}, +): Promise { + 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 { + 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 { + return requestJson('/api/v1/health', { signal }) +} + +export function listTasks( + filters: TaskFilters = {}, + signal?: AbortSignal, +): Promise { + return requestJson( + withQuery('/api/v1/tasks', filters), + { signal }, + ) +} + +export function getTask(id: string, signal?: AbortSignal): Promise { + return requestJson(`/api/v1/tasks/${encodeURIComponent(id)}`, { + signal, + }) +} + +export function createTask(payload: CreateTaskRequest): Promise { + return requestJson('/api/v1/tasks', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function updateTask( + id: string, + payload: UpdateTaskRequest, +): Promise { + return requestJson(`/api/v1/tasks/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + +export function deleteTask(id: string): Promise { + return requestJson(`/api/v1/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} + +export function setTaskEnabled( + id: string, + enabled: boolean, +): Promise { + const action = enabled ? 'enable' : 'disable' + return requestJson( + `/api/v1/tasks/${encodeURIComponent(id)}/${action}`, + { method: 'POST' }, + ) +} + +export function runTask(id: string): Promise { + return requestJson( + `/api/v1/tasks/${encodeURIComponent(id)}/run`, + { method: 'POST' }, + ) +} + +export function listTaskRuns( + taskId: string, + filters: RunFilters = {}, + signal?: AbortSignal, +): Promise { + return requestJson( + withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters), + { signal }, + ) +} + +export function getRun( + runId: number, + signal?: AbortSignal, +): Promise { + return requestJson(`/api/v1/runs/${runId}`, { signal }) +} + +export function getRunLogs( + runId: number, + offset = 0, + signal?: AbortSignal, +): Promise { + return requestJson( + withQuery(`/api/v1/runs/${runId}/logs`, { offset }), + { signal }, + ) +} + +export function cancelRun(runId: number): Promise { + return requestJson(`/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 { + return requestJson('/api/v1/cron/preview', { + method: 'POST', + body: JSON.stringify(request), + signal, + }) +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts new file mode 100644 index 0000000..b2e1d71 --- /dev/null +++ b/frontend/src/api/generated.ts @@ -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; +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; +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"]; + }; + }; + }; + }; +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..8f2bd5d --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -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(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 ( +
{ + if (event.currentTarget === event.target && !busy) { + onCancel() + } + }} + > +
+

CONFIRM ACTION

+

{title}

+

{description}

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/RunLogDrawer.tsx b/frontend/src/components/RunLogDrawer.tsx new file mode 100644 index 0000000..02a75f0 --- /dev/null +++ b/frontend/src/components/RunLogDrawer.tsx @@ -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( + initialRunId ?? null, + ) + const [historyStatus, setHistoryStatus] = useState('') + const [historyTrigger, setHistoryTrigger] = useState('') + const [sseConnected, setSseConnected] = useState(false) + const [logEvents] = useState(() => new EventTarget()) + const drawerRef = useRef(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) => { + 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) => { + 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 ( +
{ + if (event.currentTarget === event.target) { + onClose() + } + }} + > + +
+ ) +} + +function RunMetadata({ run }: { run: TaskRun }) { + return ( +
+
+
触发方式
+
{run.trigger}
+
+
+
开始时间
+
{formatDateTime(run.started_at)}
+
+
+
耗时
+
{formatDuration(run.duration_ms)}
+
+
+
退出码
+
{run.exit_code ?? '--'}
+
+ {run.error_message ? ( +
+
结果说明
+
{run.error_message}
+
+ ) : null} +
+ ) +} + +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(null) + const [reloadKey, setReloadKey] = useState(0) + const [reconcileKey, setReconcileKey] = useState(0) + const offsetRef = useRef(0) + const outputRef = useRef(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).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 ( +
+
+ 运行日志 + + {error + ? '读取失败' + : complete + ? '已完成' + : sseConnected + ? 'SSE 实时连接' + : '轮询读取中'} + +
+ {error ? ( +
+ {error} + +
+ ) : null} +
+        {content || '等待日志输出...'}
+      
+
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..9aacec0 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -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 ( + + + ) +} + +interface EnabledBadgeProps { + enabled: boolean +} + +export function EnabledBadge({ enabled }: EnabledBadgeProps) { + return ( + + + ) +} diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx new file mode 100644 index 0000000..ee2f062 --- /dev/null +++ b/frontend/src/components/TaskEditorModal.tsx @@ -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 +} + +export function TaskEditorModal({ + open, + task, + saving, + onClose, + onSubmit, +}: TaskEditorModalProps) { + const [submitError, setSubmitError] = useState(null) + const [cronPreviewCount, setCronPreviewCount] = useState(5) + const dialogRef = useRef(null) + useFocusTrap(open, dialogRef, '[data-dialog-autofocus]') + const { + register, + control, + handleSubmit, + reset, + watch, + setError, + formState: { errors }, + } = useForm({ + 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, { + type: 'server', + message: error.message, + }) + return + } + } + setSubmitError( + error instanceof Error ? error.message : '保存任务时发生未知错误', + ) + } + }) + + return ( +
{ + if (event.currentTarget === event.target && !saving) { + onClose() + } + }} + > +
+
+
+

+ {task ? `编辑任务 [${task.id}]` : '新建定时任务'} +

+
+ +
+ +
+ {submitError ? ( +
+ {submitError} +
+ ) : null} + +
+ + + + + + +
+ +
+
+
+ Cron 表达式 +
+ 预览未来 + +
+
+ +
+ {cronPreview.isFetching ? ( + 正在计算... + ) : cronPreview.isError ? ( + + {cronPreview.error.message} + + ) : cronPreview.data?.runs.length ? ( + cronPreview.data.runs.map((run, index) => ( +
+ #{index + 1} + {run} +
+ )) + ) : ( + + 等待输入有效 Cron 表达式... + + )} +
+ {errors.cron?.message ? ( + {errors.cron.message} + ) : ( + + 支持 5/6 位字段及 @daily、@hourly + + )} +
+ + + + 超时后将被 Windows Job Object 强制终止 + + +
+ +
+ + + + + + + + +
+ + + + {actionType === 'script' ? ( +
+ 脚本配置 +
+ + + + + + +
+ + + + +
+
+ 运行参数 + 每行一个参数,不经过 Shell 拼接 +
+ +
+
+ {scriptArgs.fields.map((field, index) => ( +
+ + +
+ ))} + {scriptArgs.fields.length === 0 ? ( +

暂无运行参数

+ ) : null} + {errors.script?.args?.message ? ( + + {errors.script.args.message} + + ) : null} +
+ + scriptEnv.append({ key: '', value: '' })} + onRemove={scriptEnv.remove} + /> +
+ ) : null} + + {actionType === 'http' ? ( +
+ HTTP 配置 +
+ + + + + + +
+
+ + + + +