File size: 5,276 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | /**
* CronService — 管理定时任务的增删改查
*
* 任务持久化到 ~/.claude/scheduled_tasks.json(JSON 文件)。
* 文件格式: { "tasks": [ CronTask, ... ] }
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import * as crypto from 'crypto'
import { ApiError } from '../middleware/errorHandler.js'
export type TaskNotificationConfig = {
enabled: boolean
channels: ('desktop' | 'telegram' | 'feishu')[]
}
export type CronTask = {
id: string
name?: string
description?: string
cron: string // 5-field cron expression
prompt: string
createdAt: number // epoch ms
lastFiredAt?: string // ISO timestamp of last execution
enabled?: boolean // allow disabling without deleting (default true)
recurring?: boolean
permanent?: boolean
permissionMode?: string
model?: string
providerId?: string | null
folderPath?: string
useWorktree?: boolean
notification?: TaskNotificationConfig
}
type TasksFile = {
tasks: CronTask[]
}
const TASKS_FILE_WRITE_ATTEMPTS = 2
export class CronService {
/** 任务文件路径 */
private getTasksFilePath(): string {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'scheduled_tasks.json')
}
// ---------------------------------------------------------------------------
// 公开方法
// ---------------------------------------------------------------------------
/** 获取所有任务 */
async listTasks(): Promise<CronTask[]> {
const data = await this.readTasksFile()
return data.tasks
}
/** 创建新任务 */
async createTask(
task: Omit<CronTask, 'id' | 'createdAt'>,
): Promise<CronTask> {
if (!task.cron || !task.prompt) {
throw ApiError.badRequest('Fields "cron" and "prompt" are required')
}
const data = await this.readTasksFile()
const newTask: CronTask = {
...task,
id: crypto.randomBytes(4).toString('hex'),
createdAt: Date.now(),
}
data.tasks.push(newTask)
await this.writeTasksFile(data)
return newTask
}
/** 更新已有任务 */
async updateTask(id: string, updates: Partial<CronTask>): Promise<CronTask> {
const data = await this.readTasksFile()
const index = data.tasks.findIndex((t) => t.id === id)
if (index === -1) {
throw ApiError.notFound(`Task not found: ${id}`)
}
// 不允许修改 id 和 createdAt
const { id: _id, createdAt: _ca, ...safeUpdates } = updates
data.tasks[index] = { ...data.tasks[index], ...safeUpdates }
await this.writeTasksFile(data)
return data.tasks[index]
}
/** 删除任务 */
async deleteTask(id: string): Promise<void> {
const data = await this.readTasksFile()
const index = data.tasks.findIndex((t) => t.id === id)
if (index === -1) {
throw ApiError.notFound(`Task not found: ${id}`)
}
data.tasks.splice(index, 1)
await this.writeTasksFile(data)
}
/** 更新任务的最后执行时间 */
async updateLastFired(taskId: string, timestamp: string): Promise<void> {
const data = await this.readTasksFile()
const index = data.tasks.findIndex((t) => t.id === taskId)
if (index === -1) {
return // Task may have been deleted; silently ignore
}
data.tasks[index].lastFiredAt = timestamp
await this.writeTasksFile(data)
}
// ---------------------------------------------------------------------------
// 内部: 文件读写
// ---------------------------------------------------------------------------
/** 读取任务 JSON 文件。文件不存在时返回空列表。 */
private async readTasksFile(): Promise<TasksFile> {
try {
const raw = await fs.readFile(this.getTasksFilePath(), 'utf-8')
const parsed = JSON.parse(raw) as TasksFile
// 兼容异常格式
if (!Array.isArray(parsed.tasks)) {
return { tasks: [] }
}
return parsed
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { tasks: [] }
}
throw ApiError.internal(
`Failed to read scheduled tasks: ${(err as Error).message}`,
)
}
}
/** 原子写入任务 JSON 文件 */
private async writeTasksFile(data: TasksFile): Promise<void> {
const filePath = this.getTasksFilePath()
const dir = path.dirname(filePath)
const contents = JSON.stringify(data, null, 2) + '\n'
let lastError: Error | undefined
for (let attempt = 0; attempt < TASKS_FILE_WRITE_ATTEMPTS; attempt++) {
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(tmpFile, contents, 'utf-8')
await fs.rename(tmpFile, filePath)
return
} catch (err) {
lastError = err as Error
await fs.unlink(tmpFile).catch(() => {})
if (
(err as NodeJS.ErrnoException).code !== 'ENOENT' ||
attempt === TASKS_FILE_WRITE_ATTEMPTS - 1
) {
break
}
}
}
throw ApiError.internal(
`Failed to write scheduled tasks: ${lastError?.message ?? 'unknown error'}`,
)
}
}
|