Spaces:
Runtime error
Runtime error
| import { NextRequest, NextResponse } from 'next/server' | |
| import { requireRole } from '@/lib/auth' | |
| import { config } from '@/lib/config' | |
| import { logger } from '@/lib/logger' | |
| import { readFile, writeFile } from 'node:fs/promises' | |
| import path from 'node:path' | |
| interface CronJob { | |
| name: string | |
| schedule: string | |
| command: string | |
| enabled: boolean | |
| lastRun?: number | |
| nextRun?: number | |
| lastStatus?: 'success' | 'error' | 'running' | |
| lastError?: string | |
| // Extended fields from OpenClaw format | |
| id?: string | |
| agentId?: string | |
| timezone?: string | |
| model?: string | |
| delivery?: string | |
| } | |
| /** | |
| * OpenClaw cron jobs live in ~/.openclaw/cron/jobs.json | |
| * Format: { version: 1, jobs: [ { id, agentId, name, enabled, schedule: { kind, expr, tz }, payload, delivery, state } ] } | |
| */ | |
| interface OpenClawCronJob { | |
| id: string | |
| agentId: string | |
| name: string | |
| enabled: boolean | |
| createdAtMs?: number | |
| updatedAtMs?: number | |
| schedule: { | |
| kind: string | |
| expr: string | |
| tz?: string | |
| } | |
| sessionTarget?: string | |
| wakeMode?: string | |
| payload: { | |
| kind: string | |
| message?: string | |
| model?: string | |
| thinking?: string | |
| timeoutSeconds?: number | |
| } | |
| delivery?: { | |
| mode: string | |
| channel?: string | |
| to?: string | |
| } | |
| state?: { | |
| nextRunAtMs?: number | |
| lastRunAtMs?: number | |
| lastStatus?: string | |
| lastDurationMs?: number | |
| lastError?: string | |
| } | |
| } | |
| interface OpenClawCronFile { | |
| version: number | |
| jobs: OpenClawCronJob[] | |
| } | |
| function getCronFilePath(): string { | |
| const openclawStateDir = config.openclawStateDir | |
| if (!openclawStateDir) return '' | |
| return path.join(openclawStateDir, 'cron', 'jobs.json') | |
| } | |
| async function loadCronFile(): Promise<OpenClawCronFile | null> { | |
| const filePath = getCronFilePath() | |
| if (!filePath) return null | |
| try { | |
| const raw = await readFile(filePath, 'utf-8') | |
| return JSON.parse(raw) | |
| } catch { | |
| return null | |
| } | |
| } | |
| async function saveCronFile(data: OpenClawCronFile): Promise<boolean> { | |
| const filePath = getCronFilePath() | |
| if (!filePath) return false | |
| try { | |
| await writeFile(filePath, JSON.stringify(data, null, 2)) | |
| return true | |
| } catch (err) { | |
| logger.error({ err }, 'Failed to write cron file') | |
| return false | |
| } | |
| } | |
| function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined { | |
| if (!status) return undefined | |
| const s = status.toLowerCase() | |
| if (s === 'success' || s === 'completed' || s === 'updated') return 'success' | |
| if (s === 'error' || s === 'failed') return 'error' | |
| if (s === 'running' || s === 'pending') return 'running' | |
| return 'success' // default for unknown non-error statuses | |
| } | |
| function mapOpenClawJob(job: OpenClawCronJob): CronJob { | |
| // Build a human-readable command description from the payload | |
| const payloadSummary = job.payload.message | |
| ? job.payload.message.slice(0, 200) + (job.payload.message.length > 200 ? '...' : '') | |
| : `${job.payload.kind} (${job.agentId})` | |
| const scheduleStr = job.schedule.tz | |
| ? `${job.schedule.expr} (${job.schedule.tz})` | |
| : job.schedule.expr | |
| return { | |
| id: job.id, | |
| name: job.name, | |
| schedule: scheduleStr, | |
| command: payloadSummary, | |
| enabled: job.enabled, | |
| lastRun: job.state?.lastRunAtMs, | |
| nextRun: job.state?.nextRunAtMs, | |
| lastStatus: mapLastStatus(job.state?.lastStatus), | |
| lastError: job.state?.lastError, | |
| agentId: job.agentId, | |
| timezone: job.schedule.tz, | |
| model: job.payload.model, | |
| delivery: job.delivery?.mode === 'none' ? undefined : job.delivery?.channel, | |
| } | |
| } | |
| export async function GET(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| try { | |
| const { searchParams } = new URL(request.url) | |
| const action = searchParams.get('action') | |
| if (action === 'list') { | |
| const cronFile = await loadCronFile() | |
| if (!cronFile || !cronFile.jobs) { | |
| return NextResponse.json({ jobs: [] }) | |
| } | |
| const jobs = cronFile.jobs.map(mapOpenClawJob) | |
| return NextResponse.json({ jobs }) | |
| } | |
| if (action === 'logs') { | |
| const jobId = searchParams.get('job') | |
| if (!jobId) { | |
| return NextResponse.json({ error: 'Job ID required' }, { status: 400 }) | |
| } | |
| // Find the job to get its state info | |
| const cronFile = await loadCronFile() | |
| const job = cronFile?.jobs.find(j => j.id === jobId || j.name === jobId) | |
| const logs: Array<{ timestamp: number; message: string; level: string }> = [] | |
| if (job?.state) { | |
| if (job.state.lastRunAtMs) { | |
| logs.push({ | |
| timestamp: job.state.lastRunAtMs, | |
| message: `Job executed — status: ${job.state.lastStatus || 'unknown'}${job.state.lastDurationMs ? ` (${job.state.lastDurationMs}ms)` : ''}`, | |
| level: job.state.lastStatus === 'error' || job.state.lastStatus === 'failed' ? 'error' : 'info', | |
| }) | |
| } | |
| if (job.state.lastError) { | |
| logs.push({ | |
| timestamp: job.state.lastRunAtMs || Date.now(), | |
| message: `Error: ${job.state.lastError}`, | |
| level: 'error', | |
| }) | |
| } | |
| if (job.state.nextRunAtMs) { | |
| logs.push({ | |
| timestamp: Date.now(), | |
| message: `Next scheduled run: ${new Date(job.state.nextRunAtMs).toLocaleString()}`, | |
| level: 'info', | |
| }) | |
| } | |
| } | |
| return NextResponse.json({ logs }) | |
| } | |
| if (action === 'history') { | |
| const jobId = searchParams.get('jobId') | |
| if (!jobId) { | |
| return NextResponse.json({ error: 'Job ID required' }, { status: 400 }) | |
| } | |
| const page = parseInt(searchParams.get('page') || '1', 10) | |
| const query = searchParams.get('query') || '' | |
| // Try to load run history from the cron runs log file | |
| const openclawStateDir = config.openclawStateDir | |
| if (!openclawStateDir) { | |
| return NextResponse.json({ entries: [], total: 0, hasMore: false }) | |
| } | |
| try { | |
| const runsPath = path.join(openclawStateDir, 'cron', 'runs.json') | |
| const raw = await readFile(runsPath, 'utf-8') | |
| const runsData = JSON.parse(raw) | |
| let entries: any[] = Array.isArray(runsData.runs) ? runsData.runs : Array.isArray(runsData) ? runsData : [] | |
| // Filter to this job | |
| entries = entries.filter((r: any) => r.jobId === jobId || r.id === jobId) | |
| // Apply search filter | |
| if (query) { | |
| const q = query.toLowerCase() | |
| entries = entries.filter((r: any) => | |
| (r.status || '').toLowerCase().includes(q) || | |
| (r.error || '').toLowerCase().includes(q) || | |
| (r.deliveryStatus || '').toLowerCase().includes(q) | |
| ) | |
| } | |
| // Sort by timestamp descending | |
| entries.sort((a: any, b: any) => (b.timestamp || b.startedAtMs || 0) - (a.timestamp || a.startedAtMs || 0)) | |
| const pageSize = 20 | |
| const start = (page - 1) * pageSize | |
| const paged = entries.slice(start, start + pageSize) | |
| return NextResponse.json({ | |
| entries: paged, | |
| total: entries.length, | |
| hasMore: start + pageSize < entries.length, | |
| page, | |
| }) | |
| } catch { | |
| // No runs file — fall back to state-based info | |
| const cronFile = await loadCronFile() | |
| const job = cronFile?.jobs.find(j => j.id === jobId || j.name === jobId) | |
| const entries: any[] = [] | |
| if (job?.state?.lastRunAtMs) { | |
| entries.push({ | |
| jobId: job.id, | |
| status: job.state.lastStatus || 'unknown', | |
| timestamp: job.state.lastRunAtMs, | |
| durationMs: job.state.lastDurationMs, | |
| error: job.state.lastError, | |
| }) | |
| } | |
| return NextResponse.json({ entries, total: entries.length, hasMore: false, page: 1 }) | |
| } | |
| } | |
| return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) | |
| } catch (error) { | |
| logger.error({ err: error }, 'Cron API error') | |
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | |
| } | |
| } | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| try { | |
| const body = await request.json() | |
| const { action, jobName, jobId } = body | |
| if (action === 'toggle') { | |
| const id = jobId || jobName | |
| if (!id) { | |
| return NextResponse.json({ error: 'Job ID or name required' }, { status: 400 }) | |
| } | |
| const cronFile = await loadCronFile() | |
| if (!cronFile) { | |
| return NextResponse.json({ error: 'Cron file not found' }, { status: 404 }) | |
| } | |
| const job = cronFile.jobs.find(j => j.id === id || j.name === id) | |
| if (!job) { | |
| return NextResponse.json({ error: 'Job not found' }, { status: 404 }) | |
| } | |
| job.enabled = !job.enabled | |
| job.updatedAtMs = Date.now() | |
| if (!(await saveCronFile(cronFile))) { | |
| return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 }) | |
| } | |
| return NextResponse.json({ success: true, enabled: job.enabled }) | |
| } | |
| if (action === 'trigger') { | |
| const id = jobId || jobName | |
| if (!id) { | |
| return NextResponse.json({ error: 'Job ID required' }, { status: 400 }) | |
| } | |
| if (process.env.MISSION_CONTROL_ALLOW_COMMAND_TRIGGER !== '1') { | |
| return NextResponse.json( | |
| { error: 'Manual triggers disabled. Set MISSION_CONTROL_ALLOW_COMMAND_TRIGGER=1 to enable.' }, | |
| { status: 403 } | |
| ) | |
| } | |
| const cronFile = await loadCronFile() | |
| const job = cronFile?.jobs.find(j => j.id === id || j.name === id) | |
| if (!job) { | |
| return NextResponse.json({ error: 'Job not found' }, { status: 404 }) | |
| } | |
| // For OpenClaw cron jobs, trigger via the openclaw CLI | |
| const triggerMode = body.mode || 'force' | |
| const { runCommand } = await import('@/lib/command') | |
| try { | |
| const args = ['cron', 'trigger', job.id] | |
| if (triggerMode === 'due') { | |
| args.push('--if-due') | |
| } | |
| const { stdout, stderr } = await runCommand(config.openclawBin, args, { timeoutMs: 30000 }) | |
| return NextResponse.json({ | |
| success: true, | |
| stdout: stdout.trim(), | |
| stderr: stderr.trim() | |
| }) | |
| } catch (execError: any) { | |
| return NextResponse.json({ | |
| success: false, | |
| error: execError.message, | |
| stdout: execError.stdout?.trim() || '', | |
| stderr: execError.stderr?.trim() || '' | |
| }, { status: 500 }) | |
| } | |
| } | |
| if (action === 'remove') { | |
| const id = jobId || jobName | |
| if (!id) { | |
| return NextResponse.json({ error: 'Job ID or name required' }, { status: 400 }) | |
| } | |
| const cronFile = await loadCronFile() | |
| if (!cronFile) { | |
| return NextResponse.json({ error: 'Cron file not found' }, { status: 404 }) | |
| } | |
| const idx = cronFile.jobs.findIndex(j => j.id === id || j.name === id) | |
| if (idx === -1) { | |
| return NextResponse.json({ error: 'Job not found' }, { status: 404 }) | |
| } | |
| cronFile.jobs.splice(idx, 1) | |
| if (!(await saveCronFile(cronFile))) { | |
| return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 }) | |
| } | |
| return NextResponse.json({ success: true }) | |
| } | |
| if (action === 'add') { | |
| const { schedule, command, model, description, staggerSeconds } = body | |
| const name = jobName || body.name | |
| if (!schedule || !command || !name) { | |
| return NextResponse.json( | |
| { error: 'Schedule, command, and name required' }, | |
| { status: 400 } | |
| ) | |
| } | |
| const cronFile = (await loadCronFile()) || { version: 1, jobs: [] } | |
| // Prevent duplicates: remove existing jobs with the same name | |
| cronFile.jobs = cronFile.jobs.filter(j => j.name !== name) | |
| const newJob: OpenClawCronJob = { | |
| id: `mc-${Date.now().toString(36)}`, | |
| agentId: String(process.env.MC_CRON_AGENT_ID || process.env.MC_COORDINATOR_AGENT || 'system'), | |
| name, | |
| enabled: true, | |
| createdAtMs: Date.now(), | |
| updatedAtMs: Date.now(), | |
| schedule: { | |
| kind: 'cron', | |
| expr: schedule, | |
| ...(typeof staggerSeconds === 'number' && staggerSeconds > 0 | |
| ? { staggerMs: staggerSeconds * 1000 } as any | |
| : {}), | |
| }, | |
| payload: { | |
| kind: 'agentTurn', | |
| message: command, | |
| ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}), | |
| }, | |
| delivery: { | |
| mode: 'none', | |
| }, | |
| state: {}, | |
| } | |
| cronFile.jobs.push(newJob) | |
| if (!(await saveCronFile(cronFile))) { | |
| return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 }) | |
| } | |
| return NextResponse.json({ success: true }) | |
| } | |
| if (action === 'clone') { | |
| const id = jobId || jobName | |
| if (!id) { | |
| return NextResponse.json({ error: 'Job ID required' }, { status: 400 }) | |
| } | |
| const cronFile = await loadCronFile() | |
| if (!cronFile) { | |
| return NextResponse.json({ error: 'Cron file not found' }, { status: 404 }) | |
| } | |
| const sourceJob = cronFile.jobs.find(j => j.id === id || j.name === id) | |
| if (!sourceJob) { | |
| return NextResponse.json({ error: 'Job not found' }, { status: 404 }) | |
| } | |
| // Generate unique clone name | |
| const existingNames = new Set(cronFile.jobs.map(j => j.name.toLowerCase())) | |
| let cloneName = `${sourceJob.name} (copy)` | |
| let counter = 2 | |
| while (existingNames.has(cloneName.toLowerCase())) { | |
| cloneName = `${sourceJob.name} (copy ${counter})` | |
| counter++ | |
| } | |
| const clonedJob: OpenClawCronJob = { | |
| ...JSON.parse(JSON.stringify(sourceJob)), | |
| id: `mc-${Date.now().toString(36)}`, | |
| name: cloneName, | |
| createdAtMs: Date.now(), | |
| updatedAtMs: Date.now(), | |
| state: {}, | |
| } | |
| cronFile.jobs.push(clonedJob) | |
| if (!(await saveCronFile(cronFile))) { | |
| return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 }) | |
| } | |
| return NextResponse.json({ success: true, clonedName: cloneName }) | |
| } | |
| return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) | |
| } catch (error) { | |
| logger.error({ err: error }, 'Cron management error') | |
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | |
| } | |
| } | |