Files
JohnRiceMLandClaude Opus 4.6 602e9cd56d fix: /api/crons returns empty list instead of 500 on CLI failure (#22)
getCrons() now returns [] instead of throwing when OPENCLAW_BIN is
missing, the CLI binary is not found, or the command fails. This
prevents a 500 on /api/crons for users who haven't configured crons
or are missing the OpenClaw binary. The cron tab simply shows empty
state instead of an error. Bumps to v0.8.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:22:53 -05:00

132 lines
4.5 KiB
TypeScript

import { CronJob, CronDelivery } from '@/lib/types'
import { execSync } from 'child_process'
import { parseSchedule, describeCron } from './cron-utils'
import { requireEnv } from '@/lib/env'
import { loadRegistry } from '@/lib/agents-registry'
import { extractJson } from '@/lib/cli-utils'
/**
* Match a cron job name to an agent by prefix.
* Tries each known agent ID as a prefix (longest first to avoid
* partial matches, e.g. "seo-team" matches before "seo").
*/
function matchAgent(name: string, agentIds: string[]): string | null {
const sorted = [...agentIds].sort((a, b) => b.length - a.length)
for (const id of sorted) {
if (name === id || name.startsWith(id + '-')) return id
}
return null
}
let _cronsCache: { result: CronJob[]; ts: number } | null = null
const CRONS_TTL = 5000 // 5 seconds
/** Clear the crons cache (exported for testing). */
export function clearCronsCache(): void {
_cronsCache = null
}
export async function getCrons(): Promise<CronJob[]> {
if (_cronsCache && Date.now() - _cronsCache.ts < CRONS_TTL) {
return _cronsCache.result
}
try {
const openclawBin = process.env.OPENCLAW_BIN
if (!openclawBin) {
// No binary configured -- return empty list instead of crashing
return []
}
let raw: string
try {
raw = execSync(`${openclawBin} cron list --json`, {
encoding: 'utf-8',
timeout: 10000,
})
} catch {
// CLI failed (binary not found, no crons, gateway down) -- return empty
return []
}
const parsed = extractJson(raw) as Record<string, unknown>
const jobs: unknown[] = Array.isArray(parsed)
? parsed
: (parsed.jobs ?? parsed.data ?? []) as unknown[]
// Load known agent IDs for dynamic cron-to-agent matching
const agentIds = loadRegistry().map(a => a.id)
const result = jobs.map((job: unknown) => {
const j = job as Record<string, unknown>
const state = (j.state as Record<string, unknown>) || {}
const name = String(j.name || '')
const { expression: schedule, timezone } = parseSchedule(j.schedule)
// Status can be in state.status or directly on j.status
const rawStatus = state.status ?? j.status ?? ''
let status: 'ok' | 'error' | 'idle' = 'idle'
if (rawStatus === 'error' || rawStatus === 'failed') {
status = 'error'
} else if (rawStatus === 'ok' || rawStatus === 'success' || rawStatus === 'completed') {
status = 'ok'
}
// nextRun: try state.nextRunAtMs first, then state.nextRunAt
const nextRunMs = state.nextRunAtMs ?? state.nextRunAt ?? j.nextRunAtMs ?? j.nextRunAt
const nextRun = nextRunMs
? new Date(Number(nextRunMs)).toISOString()
: null
// lastRun: try state.lastRunAtMs, state.lastRunAt, or top-level equivalents
const lastRunRaw = state.lastRunAtMs ?? state.lastRunAt ?? j.lastRunAtMs ?? j.lastRunAt ?? j.last
const lastRun = lastRunRaw
? (typeof lastRunRaw === 'number' ? new Date(lastRunRaw).toISOString() : String(lastRunRaw))
: null
const lastError = (state.lastError ?? state.error ?? j.lastError) ? String(state.lastError ?? state.error ?? j.lastError) : null
// Delivery config
const rawDelivery = j.delivery as Record<string, unknown> | undefined
let delivery: CronDelivery | null = null
if (rawDelivery && typeof rawDelivery === 'object') {
delivery = {
mode: String(rawDelivery.mode || ''),
channel: String(rawDelivery.channel || ''),
to: rawDelivery.to ? String(rawDelivery.to) : null,
}
}
// Rich state fields
const lastDurationMs = typeof state.lastDurationMs === 'number' ? state.lastDurationMs : null
const consecutiveErrors = typeof state.consecutiveErrors === 'number' ? state.consecutiveErrors : 0
const lastDeliveryStatus = typeof state.lastDeliveryStatus === 'string' ? state.lastDeliveryStatus : null
return {
id: String(j.id || j.name || ''),
name,
schedule,
scheduleDescription: describeCron(schedule),
timezone,
status,
lastRun,
nextRun,
lastError,
agentId: matchAgent(name, agentIds),
description: typeof j.description === 'string' ? j.description : null,
enabled: j.enabled !== false,
delivery,
lastDurationMs,
consecutiveErrors,
lastDeliveryStatus,
}
})
_cronsCache = { result, ts: Date.now() }
return result
} catch {
// JSON extraction or parsing failed -- return empty rather than 500
return []
}
}