mission-control/src/app/api/cron/route.ts

464 lines
14 KiB
TypeScript

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 })
}
}