Share

外观
风格

本地青龙:用 TypeScript 搭一个轻量任务调度器

2026年2月8日 · 专栏

封面图

本地青龙:用 TypeScript 搭一个轻量任务调度器

我有一堆定时脚本:RSS 日报每天 8 点跑、数据库备份凌晨 3 点跑、Homebrew 每周日更新。

以前全靠 crontab,但问题是:

  • 跑没跑不知道,得自己去看日志
  • 失败了没通知,第二天才发现昨天的备份挂了
  • 想临时手动跑一下,得记住完整命令

后来写了个任务调度器,解决这三个问题:统一管理 + Web 界面 + 失败通知


效果长这样

$ mt list
┌────────────────┬─────────────┬──────┬────────────┬──────┬────────────┐
│ ID             │ 名称        │ 类型 │ 调度       │ 状态 │ 上次执行   │
├────────────────┼─────────────┼──────┼────────────┼──────┼────────────┤
│ <REDACTED_TASK_ID>     │ RSS 日报    │ 定时 │ 0 8 * * *  │ ●    │ 2 小时前   │
│ <REDACTED_TASK_ID>      │ 数据库备份  │ 定时 │ 0 3 * * *  │ ●    │ 7 小时前   │
│ <REDACTED_TASK_ID>   │ R2 备份检查 │ 定时 │ 0 9 * * *  │ ●    │ 1 小时前   │
│ <REDACTED_TASK_ID>    │ Homebrew 更新│ 定时 │ 0 2 * * 0  │ ●    │ 3 天前     │
└────────────────┴─────────────┴──────┴────────────┴──────┴────────────┘

$ mt run <REDACTED_TASK_ID>
🚀 开始执行: RSS 日报
✅ 执行完成 (耗时 45s)

还有个 Web 界面(:5700),可以看状态、查日志、手动触发。


核心能力

能力 说明
YAML 定义任务 一个文件管理所有定时任务
CLI 管理 mt listmt runmt logs
Web 界面 实时状态、历史记录、一键执行
失败通知 支持
统一生命周期 CLI、Scheduler、Web 触发行为一致

5 分钟跑通

mkdir task-scheduler && cd task-scheduler
bun init -y
bun add commander chalk cli-table3 dayjs croner hono yaml
mkdir -p config scripts/{<REDACTED_TASK_ID>,<REDACTED_TASK_ID>} src/core data logs

创建 config/tasks.yaml

tasks:
  - id: <REDACTED_TASK_ID>
    name: Hello World
    cron: "*/5 * * * *"  # 每 5 分钟
    command: echo "Hello from scheduler!"
    workdir: .
    timeout: 60
    enabled: true

创建核心代码后运行:

bun run src/index.ts list
bun run src/index.ts run <REDACTED_TASK_ID>

核心代码结构(点击展开)
src/
├── index.ts        # CLI 入口
├── scheduler.ts    # 调度器(cron 触发)
├── server.ts       # Web UI + API
└── core/
    ├── config.ts   # 加载 YAML 配置
    ├── runner.ts   # 统一执行生命周期
    ├── executor.ts # 执行命令
    ├── logger.ts   # 日志管理
    ├── state.ts    # 状态/历史持久化
    └── notifier.ts # 通知发送

关键设计:所有入口都走 runner.ts

// src/core/runner.ts 核心逻辑
export async function runTaskWithLifecycle(task: TaskConfig) {
  // 1. 标记 running
  updateState(task.id, { status: 'running', lastRun: new Date() });

  // 2. 记录历史开始
  const historyId = createHistory(task.id);

  // 3. 执行命令
  const result = await executeCommand(task.command, {
    cwd: task.workdir,
    timeout: task.timeout * 1000,
    env: task.env,
  });

  // 4. 归一化状态
  const status = result.timeout ? 'timeout'
    : result.exitCode === 0 ? 'success' : 'failed';

  // 5. 更新历史
  updateHistory(historyId, { status, exitCode: result.exitCode });

  // 6. 发通知
  if (status !== 'success' && task.notify?.on_failure) {
    await sendNotification(task, status);
  }

  return result;
}

这样不管从 CLI、Web、还是 Scheduler 触发,行为都一致。


同步到 launchd(macOS 开机自启)

# 生成 plist 并安装
bun run src/index.ts install

# 启动服务
launchctl load ~/Library/LaunchAgents/com.envvar.task-scheduler.plist

# 查看状态
launchctl list | grep task-scheduler

这样重启后调度器自动运行,不用手动启动。


我踩过的坑

1. cron 表达式时区问题

默认用的是系统时区,但如果你在 Docker 或服务器上跑,可能是 UTC。我直接在配置加载时强制指定:

const cron = new Cron(task.cron, { timezone: '<REDACTED_TIMEZONE>' }, callback);

2. 日志文件太大

每个任务每次执行都写日志,跑久了日志目录几个 G。加了个简单的轮转:只保留最近 7 天的日志文件。

3. Web 界面和 CLI 状态不同步

一开始 Web 有自己的状态缓存,CLI 有自己的。后来统一到 data/state.json,所有入口读写同一个文件。


任务配置示例

tasks:
  # RSS 日报
  - id: <REDACTED_TASK_ID>
    name: RSS 日报
    cron: "0 8 * * *"          # 每天 8:00
    command: bun run src/index.ts
    workdir: <REDACTED_TASK_ID>
    env:
      AI_BASE_URL: <REDACTED_API_ENDPOINT>
      SUMMARIZER_MODEL: <REDACTED_AI_MODEL>
    timeout: 300
    notify:
      on_failure: true
    enabled: true

  # 数据库备份
  - id: <REDACTED_TASK_ID>
    name: 数据库备份
    cron: "0 3 * * *"          # 每天 3:00
    command: ./run.sh
    workdir: <REDACTED_TASK_ID>
    timeout: 300
    notify:
      on_failure: true
    enabled: true

下一步

  • 加任务依赖:A 跑完再跑 B
  • 加重试机制:失败后自动重试 N 次
  • 加 Dashboard:汇总所有任务的成功率、平均耗时

crontab 能用,但不够用。花半天时间搭个调度器,以后省心很多。


附录:给 AI 的复现指令

复制下面这段给 Claude/GPT,让它帮你从零搭建:

帮我搭建一个本地任务调度系统,类似青龙面板但更轻量。

目标:用 YAML 定义定时任务,支持 CLI 管理、Web 界面、失败通知。

技术栈:Bun + TypeScript
依赖:commander, chalk, cli-table3, dayjs, croner, hono, yaml

项目结构:
task-scheduler/
  config/
    tasks.yaml     -- 任务定义
    env.yaml       -- 环境变量(可选)
    notify.yaml    -- 通知配置(可选)
  scripts/         -- 各任务脚本目录
  src/
    index.ts       -- CLI 入口
    scheduler.ts   -- cron 调度器
    server.ts      -- Web UI + API (Hono)
    core/
      config.ts    -- 加载 YAML 配置
      runner.ts    -- 统一执行生命周期
      executor.ts  -- 执行命令(spawn)
      logger.ts    -- 日志管理
      state.ts     -- 状态/历史持久化(JSON 文件)
      notifier.ts  -- 通知(ntfy/telegram/bark)
  data/            -- 运行时状态
  logs/            -- 任务日志

tasks.yaml 格式:
tasks:
  - id: task-id
    name: 任务名称
    cron: "0 8 * * *"
    command: bun run src/index.ts
    workdir: task-dir
    env: { KEY: value }
    timeout: 300
    notify: { on_failure: true }
    enabled: true

CLI 命令:
  mt list              -- 列出任务
  mt show <id>         -- 任务详情
  mt run <id>          -- 立即执行
  mt logs <id>         -- 查看日志
  mt history <id>      -- 执行历史
  mt serve --port 5700 -- 启动 Web
  mt scheduler         -- 启动调度器
  mt install           -- 安装 launchd 服务

核心设计:
- 所有入口(CLI/Web/Scheduler)都走 runner.ts 的统一生命周期
- 生命周期:标记 running → 记录历史 → 执行命令 → 更新状态 → 发通知
- 状态持久化到 data/state.json,确保多入口一致

Web API:
  GET /                -- 管理页面
  GET /api/status/:id  -- 任务状态
  GET /api/logs/:id    -- 日志
  POST /api/run/:id    -- 触发执行

定时任务(launchd):
- 提供 mt install 命令生成 plist
- 安装到 ~/Library/LaunchAgents/
- launchctl load 启动

成功标志:
- mt list 显示任务列表
- mt run <REDACTED_TASK_ID> 执行成功
- localhost:5700 显示 Web 界面

请创建完整项目结构和代码。