464 lines
14 KiB
TypeScript
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 })
|
|
}
|
|
}
|