codev / src /server /services /cronService.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
5.28 kB
/**
* 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'}`,
)
}
}