diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/20260201-000000/index.tsx
index b3fa2cd..f8640b8 100644
--- a/apps/cron/20260201-000000/index.tsx
+++ b/apps/cron/20260201-000000/index.tsx
@@ -4,8 +4,8 @@ import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor'
-import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
-import { SCHEDULES, type CronJob } from './lib/schedules'
+import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
+import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
import type { Child } from 'hono/jsx'
import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises'
@@ -92,6 +92,24 @@ const EmptyState = define('EmptyState', {
color: theme('colors-textMuted'),
})
+const InvalidItem = define('InvalidItem', {
+ padding: '12px 15px',
+ borderBottom: `1px solid ${theme('colors-border')}`,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '15px',
+ opacity: 0.7,
+ states: {
+ ':last-child': { borderBottom: 'none' },
+ },
+})
+
+const ErrorText = define('ErrorText', {
+ fontSize: '12px',
+ color: theme('colors-error'),
+ flex: 1,
+})
+
const ActionRow = define('ActionRow', {
display: 'flex',
justifyContent: 'flex-end',
@@ -226,19 +244,24 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/', async c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
+ let invalid = getInvalidJobs()
if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter)
+ invalid = invalid.filter(j => j.app === appFilter)
}
jobs.sort((a, b) => a.id.localeCompare(b.id))
+ invalid.sort((a, b) => a.id.localeCompare(b.id))
+
+ const hasAny = jobs.length > 0 || invalid.length > 0
return c.html(
New Job
- {jobs.length === 0 ? (
+ {!hasAny ? (
No cron jobs found.
@@ -260,6 +283,13 @@ app.get('/', async c => {
))}
+ {invalid.map(job => (
+
+
+ {job.app}/{job.name}
+ {job.error}
+
+ ))}
)}
@@ -336,8 +366,9 @@ export default async function() {
console.log(`[cron] Created ${appName}:${name}`)
// Trigger rediscovery
- const jobs = await discoverCronJobs()
+ const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
+ setInvalidJobs(invalid)
for (const job of jobs) {
if (job.id === `${appName}:${name}`) {
scheduleJob(job, broadcast)
@@ -364,21 +395,26 @@ app.post('/run/:app/:name', async c => {
// Initialize
async function init() {
- const jobs = await discoverCronJobs()
+ const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
- console.log(`[cron] Discovered ${jobs.length} jobs`)
+ setInvalidJobs(invalid)
+ console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`)
for (const job of jobs) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
+
+ for (const job of invalid) {
+ console.log(`[cron] Invalid ${job.id}: ${job.error}`)
+ }
}
// Watch for cron file changes
let debounceTimer: Timer | null = null
async function rediscover() {
- const jobs = await discoverCronJobs()
+ const { jobs, invalid } = await discoverCronJobs()
const existing = getAllJobs()
// Stop removed jobs
@@ -407,6 +443,7 @@ async function rediscover() {
}
setJobs(jobs)
+ setInvalidJobs(invalid)
}
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
diff --git a/apps/cron/20260201-000000/lib/discovery.ts b/apps/cron/20260201-000000/lib/discovery.ts
index 3cb268f..c540e6a 100644
--- a/apps/cron/20260201-000000/lib/discovery.ts
+++ b/apps/cron/20260201-000000/lib/discovery.ts
@@ -1,7 +1,7 @@
import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
-import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
+import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR!
@@ -22,8 +22,14 @@ export async function getApps(): Promise {
return apps.sort()
}
-export async function discoverCronJobs(): Promise {
+export type DiscoveryResult = {
+ jobs: CronJob[]
+ invalid: InvalidJob[]
+}
+
+export async function discoverCronJobs(): Promise {
const jobs: CronJob[] = []
+ const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) {
@@ -38,25 +44,26 @@ export async function discoverCronJobs(): Promise {
const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '')
+ const id = `${app.name}:${name}`
try {
const source = await readFile(filePath, 'utf-8')
const match = source.match(SCHEDULE_RE)
if (!match) {
- console.error(`No schedule export found in ${filePath}`)
+ invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
continue
}
const schedule = match[1] as Schedule
if (!isValidSchedule(schedule)) {
- console.error(`Invalid schedule in ${filePath}: ${schedule}`)
+ invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
continue
}
jobs.push({
- id: `${app.name}:${name}`,
+ id,
app: app.name,
name,
file: filePath,
@@ -65,10 +72,11 @@ export async function discoverCronJobs(): Promise {
state: 'idle',
})
} catch (e) {
- console.error(`Failed to load cron file ${filePath}:`, e)
+ const msg = e instanceof Error ? e.message : String(e)
+ invalid.push({ id, app: app.name, name, file: filePath, error: msg })
}
}
}
- return jobs
+ return { jobs, invalid }
}
diff --git a/apps/cron/20260201-000000/lib/schedules.ts b/apps/cron/20260201-000000/lib/schedules.ts
index 20a3456..6d995ca 100644
--- a/apps/cron/20260201-000000/lib/schedules.ts
+++ b/apps/cron/20260201-000000/lib/schedules.ts
@@ -20,6 +20,14 @@ export type CronJob = {
nextRun?: number
}
+export type InvalidJob = {
+ id: string
+ app: string
+ name: string
+ file: string
+ error: string
+}
+
export const SCHEDULES = [
'1 minute',
'5 minutes',
diff --git a/apps/cron/20260201-000000/lib/state.ts b/apps/cron/20260201-000000/lib/state.ts
index 214d502..965aac3 100644
--- a/apps/cron/20260201-000000/lib/state.ts
+++ b/apps/cron/20260201-000000/lib/state.ts
@@ -1,8 +1,10 @@
-import type { CronJob } from './schedules'
+import type { CronJob, InvalidJob } from './schedules'
const jobs = new Map()
const listeners = new Set<() => void>()
+let invalidJobs: InvalidJob[] = []
+
export function setJobs(newJobs: CronJob[]) {
jobs.clear()
for (const job of newJobs) {
@@ -11,6 +13,10 @@ export function setJobs(newJobs: CronJob[]) {
broadcast()
}
+export function setInvalidJobs(newInvalid: InvalidJob[]) {
+ invalidJobs = newInvalid
+}
+
export function getJob(id: string): CronJob | undefined {
return jobs.get(id)
}
@@ -19,6 +25,10 @@ export function getAllJobs(): CronJob[] {
return Array.from(jobs.values())
}
+export function getInvalidJobs(): InvalidJob[] {
+ return invalidJobs
+}
+
export function broadcast() {
listeners.forEach(cb => cb())
}