fix: show cron scheduler history without output artifacts (#463)

This commit is contained in:
Zhicheng Han
2026-05-06 01:44:53 +02:00
committed by GitHub
parent 9c57d1a0f1
commit 9ba0ba5185
2 changed files with 394 additions and 36 deletions
@@ -1,11 +1,10 @@
import type { Context } from 'koa'
import { readdir, stat, readFile } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { existsSync } from 'fs'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
const HERMES_BASE = join(homedir(), '.hermes')
const SYNTHETIC_RUN_FILE = '__scheduler_metadata__.md'
function getCronOutputDir(): string {
// Use the active profile's directory, so cron history follows profile switches
@@ -13,11 +12,21 @@ function getCronOutputDir(): string {
return join(profileDir, 'cron', 'output')
}
function getCronJobsFile(): string {
const profileDir = getActiveProfileDir()
return join(profileDir, 'cron', 'jobs.json')
}
export interface RunEntry {
jobId: string
fileName: string
runTime: string
size: number
hasOutput?: boolean
synthetic?: boolean
runCount?: number
status?: string | null
error?: string | null
}
export interface RunDetail {
@@ -27,50 +36,197 @@ export interface RunDetail {
content: string
}
interface CronJobMetadata {
id?: string
job_id?: string
name?: string
last_run_at?: string | null
last_status?: string | null
last_error?: string | null
run_count?: number | string | null
no_agent?: boolean
script?: string | null
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getJobId(job: CronJobMetadata): string | null {
return stringOrNull(job.job_id) || stringOrNull(job.id)
}
function isCronJobMetadata(value: unknown): value is CronJobMetadata {
return Boolean(value && typeof value === 'object')
}
function normaliseJobsPayload(payload: unknown): CronJobMetadata[] {
if (Array.isArray(payload)) return payload.filter(isCronJobMetadata)
if (payload && typeof payload === 'object') {
const maybeJobs = (payload as { jobs?: unknown }).jobs
if (Array.isArray(maybeJobs)) return maybeJobs.filter(isCronJobMetadata)
}
return []
}
async function readCronJobs(): Promise<CronJobMetadata[]> {
const jobsFile = getCronJobsFile()
if (!existsSync(jobsFile)) return []
try {
const raw = await readFile(jobsFile, 'utf-8')
return normaliseJobsPayload(JSON.parse(raw))
} catch {
return []
}
}
function coerceRunCount(value: CronJobMetadata['run_count']): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function toDisplayTime(value: string): string {
const isoLike = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/)
if (isoLike) return `${isoLike[1]} ${isoLike[2]}:${isoLike[3]}:${isoLike[4]}`
const legacy = value.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})$/)
if (legacy) return `${legacy[1]} ${legacy[2].replace(/-/g, ':')}`
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) {
return new Date(parsed).toISOString().replace('T', ' ').slice(0, 19)
}
return value
}
function parseRunTimeFromFileName(fileName: string): string {
const base = fileName.endsWith('.md') ? fileName.slice(0, -3) : fileName
return toDisplayTime(base)
}
function syntheticRunEntry(job: CronJobMetadata): RunEntry | null {
const jobId = getJobId(job)
const lastRunAt = stringOrNull(job.last_run_at)
if (!jobId || !lastRunAt) return null
return {
jobId,
fileName: SYNTHETIC_RUN_FILE,
runTime: toDisplayTime(lastRunAt),
size: 0,
hasOutput: false,
synthetic: true,
runCount: coerceRunCount(job.run_count),
status: stringOrNull(job.last_status),
error: stringOrNull(job.last_error),
}
}
function hasRunForJobAtOrAfter(runs: RunEntry[], jobId: string, runTime: string): boolean {
return runs.some(run => run.jobId === jobId && run.runTime >= runTime)
}
function inlineCode(value: unknown): string {
const text = String(value)
let longestBacktickRun = 0
let currentBacktickRun = 0
for (const char of text) {
if (char === '`') {
currentBacktickRun += 1
if (currentBacktickRun > longestBacktickRun) longestBacktickRun = currentBacktickRun
} else {
currentBacktickRun = 0
}
}
const delimiter = '`'.repeat(longestBacktickRun + 1)
return `${delimiter} ${text} ${delimiter}`
}
function buildSyntheticContent(job: CronJobMetadata, runTime: string): string {
const explanation = job.no_agent || stringOrNull(job.script)
? 'This is expected for script-only/no-agent watchdog jobs when the script exits successfully with empty stdout: Hermes treats the run as silent, so there is nothing to deliver and no output file to display.'
: 'This can happen when a cron run updates scheduler metadata but does not produce a markdown output artifact to display.'
const lines = [
'# Scheduler run recorded',
'',
'Hermes recorded this cron job as having run, but no markdown output artifact was written for this job.',
'',
explanation,
'',
`- Job: ${inlineCode(job.name || getJobId(job) || 'unknown')}`,
`- Last run: ${inlineCode(runTime)}`,
]
const runCount = coerceRunCount(job.run_count)
const lastStatus = stringOrNull(job.last_status)
const lastError = stringOrNull(job.last_error)
const script = stringOrNull(job.script)
if (runCount !== undefined) lines.push(`- Recorded runs: ${inlineCode(runCount)}`)
if (lastStatus) lines.push(`- Last status: ${inlineCode(lastStatus)}`)
if (lastError) lines.push(`- Last error: ${inlineCode(lastError)}`)
if (script) lines.push(`- Script: ${inlineCode(script)}`)
if (job.no_agent) lines.push('- Mode: `no-agent/script-only`')
return `${lines.join('\n')}\n`
}
/** List all run output files, optionally filtered by job ID */
export async function listRuns(ctx: Context) {
const jobId = ctx.query.jobId as string | undefined
const cronOutput = getCronOutputDir()
if (!existsSync(cronOutput)) {
ctx.body = { runs: [] }
return
}
try {
const dirs = await readdir(cronOutput)
const runs: RunEntry[] = []
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
if (existsSync(cronOutput)) {
const dirs = await readdir(cronOutput)
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
for (const dir of targetDirs) {
const dirPath = join(cronOutput, dir)
try {
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) continue
for (const dir of targetDirs) {
const dirPath = join(cronOutput, dir)
try {
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) continue
const files = await readdir(dirPath)
// Sort by filename descending (newest first, since filenames are timestamps)
const sorted = files.sort().reverse()
const files = await readdir(dirPath)
// Sort by filename descending (newest first, since filenames are timestamps)
const sorted = files.sort().reverse()
for (const file of sorted) {
if (!file.endsWith('.md')) continue
const filePath = join(dirPath, file)
try {
const fileStat = await stat(filePath)
// Parse run time from filename: 2026-04-18_12-01-40.md → 2026-04-18 12:01:40
const match = file.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.md$/)
const runTime = match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : file
for (const file of sorted) {
if (!file.endsWith('.md')) continue
const filePath = join(dirPath, file)
try {
const fileStat = await stat(filePath)
runs.push({
jobId: dir,
fileName: file,
runTime,
size: fileStat.size,
})
} catch { /* skip unreadable files */ }
}
} catch { /* skip unreadable dirs */ }
runs.push({
jobId: dir,
fileName: file,
runTime: parseRunTimeFromFileName(file),
size: fileStat.size,
hasOutput: true,
})
} catch { /* skip unreadable files */ }
}
} catch { /* skip unreadable dirs */ }
}
}
const jobs = await readCronJobs()
const targetJobs = jobId ? jobs.filter(job => getJobId(job) === jobId) : jobs
for (const job of targetJobs) {
const id = getJobId(job)
if (!id) continue
const synthetic = syntheticRunEntry(job)
if (synthetic && !hasRunForJobAtOrAfter(runs, id, synthetic.runTime)) runs.push(synthetic)
}
// Sort all runs by runTime descending
@@ -100,6 +256,25 @@ export async function readRun(ctx: Context) {
return
}
if (fileName === SYNTHETIC_RUN_FILE) {
const jobs = await readCronJobs()
const job = jobs.find(candidate => getJobId(candidate) === jobId)
const synthetic = job ? syntheticRunEntry(job) : null
if (!job || !synthetic) {
ctx.status = 404
ctx.body = { error: 'Run output not found' }
return
}
ctx.body = {
jobId,
fileName,
runTime: synthetic.runTime,
content: buildSyntheticContent(job, synthetic.runTime),
} satisfies RunDetail
return
}
const cronOutput = getCronOutputDir()
const filePath = join(cronOutput, jobId, fileName)
@@ -111,8 +286,7 @@ export async function readRun(ctx: Context) {
try {
const content = await readFile(filePath, 'utf-8')
const match = fileName.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.md$/)
const runTime = match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : fileName
const runTime = parseRunTimeFromFileName(fileName)
ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail
} catch (err: any) {
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const profileDirState = vi.hoisted(() => ({ value: '' }))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: () => profileDirState.value,
}))
function createCtx(overrides: Record<string, any> = {}) {
return {
query: {},
params: {},
status: 200,
body: null,
...overrides,
} as any
}
function writeJobs(jobs: unknown[]) {
const cronDir = join(profileDirState.value, 'cron')
mkdirSync(cronDir, { recursive: true })
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({ jobs }))
}
describe('Hermes cron history controller', () => {
beforeEach(() => {
vi.resetModules()
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-cron-history-'))
})
afterEach(() => {
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
})
it('surfaces scheduler metadata when a job ran without an output artifact', async () => {
writeJobs([
{
id: 'silent-job',
name: 'Silent watchdog',
last_run_at: '2026-05-05T13:01:32.580693+00:00',
last_status: 'ok',
run_count: 47,
script: 'monitor_github_issues.py',
no_agent: true,
},
])
const { listRuns, readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
const listCtx = createCtx({ query: { jobId: 'silent-job' } })
await listRuns(listCtx)
expect(listCtx.body).toEqual({
runs: [
expect.objectContaining({
jobId: 'silent-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 13:01:32',
size: 0,
hasOutput: false,
synthetic: true,
runCount: 47,
status: 'ok',
}),
],
})
const readCtx = createCtx({ params: { jobId: 'silent-job', fileName: '__scheduler_metadata__.md' } })
await readRun(readCtx)
expect(readCtx.body).toMatchObject({
jobId: 'silent-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 13:01:32',
})
expect(readCtx.body.content).toContain('Hermes recorded this cron job as having run')
expect(readCtx.body.content).toContain('Recorded runs:')
expect(readCtx.body.content).toContain('47')
expect(readCtx.body.content).toContain('script-only/no-agent')
})
it('keeps real output files as history entries and parses ISO-style Hermes filenames', async () => {
writeJobs([
{
id: 'output-job',
name: 'Output job',
last_run_at: '2026-05-05T05:00:00.429347+00:00',
run_count: 1,
},
])
const outputDir = join(profileDirState.value, 'cron', 'output', 'output-job')
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, '2026-05-05T05-00-00.429347+00-00.md'), '# ok\n')
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'output-job' } })
await listRuns(ctx)
expect(ctx.body).toEqual({
runs: [
expect.objectContaining({
jobId: 'output-job',
fileName: '2026-05-05T05-00-00.429347+00-00.md',
runTime: '2026-05-05 05:00:00',
hasOutput: true,
}),
],
})
})
it('adds scheduler metadata when the latest recorded run is newer than the newest output file', async () => {
writeJobs([
{
id: 'mixed-job',
name: 'Mixed job',
last_run_at: '2026-05-05T06:00:00+00:00',
run_count: 2,
},
])
const outputDir = join(profileDirState.value, 'cron', 'output', 'mixed-job')
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, '2026-05-05T05-00-00.000000+00-00.md'), '# older output\n')
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'mixed-job' } })
await listRuns(ctx)
expect(ctx.body.runs).toHaveLength(2)
expect(ctx.body.runs[0]).toMatchObject({
jobId: 'mixed-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 06:00:00',
hasOutput: false,
})
expect(ctx.body.runs[1]).toMatchObject({
fileName: '2026-05-05T05-00-00.000000+00-00.md',
hasOutput: true,
})
})
it('skips malformed scheduler metadata instead of failing the request', async () => {
writeJobs([
null,
{
id: 'bad-job',
name: 'Bad job',
last_run_at: 123,
last_status: { nested: true },
},
])
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'bad-job' } })
await listRuns(ctx)
expect(ctx.body).toEqual({ runs: [] })
})
it('renders metadata with many backticks without throwing', async () => {
const name = Array.from({ length: 2000 }, () => '`x').join('')
writeJobs([
{
id: 'ticks-job',
name,
last_run_at: '2026-05-05T07:00:00+00:00',
},
])
const { readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ params: { jobId: 'ticks-job', fileName: '__scheduler_metadata__.md' } })
await readRun(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.content).toContain('Scheduler run recorded')
expect(ctx.body.content).toContain('`x')
})
})