cc-haha/src/server/services/cronService.ts
程序员阿江(Relakkes) 7696e21dc2 Support provider runtimes for scheduled tasks
Scheduled tasks used the model selector in model-only mode, so a task could store a third-party model id without the provider runtime needed to execute it. The desktop task editor now stores the selected provider with the model, and the scheduler injects the same provider-scoped runtime env that desktop sessions use before launching the sidecar.

Constraint: Scheduled tasks run outside an active desktop websocket session, so provider runtime must be serialized on the task itself.
Rejected: Reuse session runtime localStorage for tasks | scheduled executions can run long after the UI session is gone.
Confidence: high
Scope-risk: moderate
Directive: Keep task execution provider env isolated from inherited process env; stale parent ANTHROPIC_* values must not override task provider choices.
Tested: cd desktop && bun run test -- src/components/tasks/NewTaskModal.test.tsx src/components/controls/ModelSelector.test.tsx
Tested: bun test src/server/__tests__/scheduled-tasks.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts
Tested: bun run check:desktop
Tested: bun run check:server
Tested: git diff --check
Not-tested: Live third-party provider API call; covered by sidecar launch integration with provider proxy env.
2026-05-08 09:49:16 +08:00

175 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CronService — 管理定时任务的增删改查
*
* 任务持久化到 ~/.claude/scheduled_tasks.jsonJSON 文件)。
* 文件格式: { "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'}`,
)
}
}