fix: show cron scheduler history without output artifacts (#463)
This commit is contained in:
@@ -1,11 +1,10 @@
|
|||||||
import type { Context } from 'koa'
|
import type { Context } from 'koa'
|
||||||
import { readdir, stat, readFile } from 'fs/promises'
|
import { readdir, stat, readFile } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { homedir } from 'os'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
|
|
||||||
const HERMES_BASE = join(homedir(), '.hermes')
|
const SYNTHETIC_RUN_FILE = '__scheduler_metadata__.md'
|
||||||
|
|
||||||
function getCronOutputDir(): string {
|
function getCronOutputDir(): string {
|
||||||
// Use the active profile's directory, so cron history follows profile switches
|
// Use the active profile's directory, so cron history follows profile switches
|
||||||
@@ -13,11 +12,21 @@ function getCronOutputDir(): string {
|
|||||||
return join(profileDir, 'cron', 'output')
|
return join(profileDir, 'cron', 'output')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCronJobsFile(): string {
|
||||||
|
const profileDir = getActiveProfileDir()
|
||||||
|
return join(profileDir, 'cron', 'jobs.json')
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunEntry {
|
export interface RunEntry {
|
||||||
jobId: string
|
jobId: string
|
||||||
fileName: string
|
fileName: string
|
||||||
runTime: string
|
runTime: string
|
||||||
size: number
|
size: number
|
||||||
|
hasOutput?: boolean
|
||||||
|
synthetic?: boolean
|
||||||
|
runCount?: number
|
||||||
|
status?: string | null
|
||||||
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunDetail {
|
export interface RunDetail {
|
||||||
@@ -27,20 +36,159 @@ export interface RunDetail {
|
|||||||
content: string
|
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 */
|
/** List all run output files, optionally filtered by job ID */
|
||||||
export async function listRuns(ctx: Context) {
|
export async function listRuns(ctx: Context) {
|
||||||
const jobId = ctx.query.jobId as string | undefined
|
const jobId = ctx.query.jobId as string | undefined
|
||||||
const cronOutput = getCronOutputDir()
|
const cronOutput = getCronOutputDir()
|
||||||
|
|
||||||
if (!existsSync(cronOutput)) {
|
|
||||||
ctx.body = { runs: [] }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dirs = await readdir(cronOutput)
|
|
||||||
const runs: RunEntry[] = []
|
const runs: RunEntry[] = []
|
||||||
|
|
||||||
|
if (existsSync(cronOutput)) {
|
||||||
|
const dirs = await readdir(cronOutput)
|
||||||
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
|
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
|
||||||
|
|
||||||
for (const dir of targetDirs) {
|
for (const dir of targetDirs) {
|
||||||
@@ -58,20 +206,28 @@ export async function listRuns(ctx: Context) {
|
|||||||
const filePath = join(dirPath, file)
|
const filePath = join(dirPath, file)
|
||||||
try {
|
try {
|
||||||
const fileStat = await stat(filePath)
|
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
|
|
||||||
|
|
||||||
runs.push({
|
runs.push({
|
||||||
jobId: dir,
|
jobId: dir,
|
||||||
fileName: file,
|
fileName: file,
|
||||||
runTime,
|
runTime: parseRunTimeFromFileName(file),
|
||||||
size: fileStat.size,
|
size: fileStat.size,
|
||||||
|
hasOutput: true,
|
||||||
})
|
})
|
||||||
} catch { /* skip unreadable files */ }
|
} catch { /* skip unreadable files */ }
|
||||||
}
|
}
|
||||||
} catch { /* skip unreadable dirs */ }
|
} 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
|
// Sort all runs by runTime descending
|
||||||
runs.sort((a, b) => b.runTime.localeCompare(a.runTime))
|
runs.sort((a, b) => b.runTime.localeCompare(a.runTime))
|
||||||
@@ -100,6 +256,25 @@ export async function readRun(ctx: Context) {
|
|||||||
return
|
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 cronOutput = getCronOutputDir()
|
||||||
const filePath = join(cronOutput, jobId, fileName)
|
const filePath = join(cronOutput, jobId, fileName)
|
||||||
|
|
||||||
@@ -111,8 +286,7 @@ export async function readRun(ctx: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readFile(filePath, 'utf-8')
|
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 = parseRunTimeFromFileName(fileName)
|
||||||
const runTime = match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : fileName
|
|
||||||
|
|
||||||
ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail
|
ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail
|
||||||
} catch (err: any) {
|
} 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