mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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.
175 lines
5.2 KiB
TypeScript
175 lines
5.2 KiB
TypeScript
/**
|
||
* 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'}`,
|
||
)
|
||
}
|
||
}
|