fix: show cron scheduler history without output artifacts (#463)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user