From 9ba0ba5185402040beb6421ec3c7b3a41364da0a Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Wed, 6 May 2026 01:44:53 +0200 Subject: [PATCH] fix: show cron scheduler history without output artifacts (#463) --- .../src/controllers/hermes/cron-history.ts | 246 +++++++++++++++--- tests/server/cron-history-controller.test.ts | 184 +++++++++++++ 2 files changed, 394 insertions(+), 36 deletions(-) create mode 100644 tests/server/cron-history-controller.test.ts diff --git a/packages/server/src/controllers/hermes/cron-history.ts b/packages/server/src/controllers/hermes/cron-history.ts index a976d57..006f3ae 100644 --- a/packages/server/src/controllers/hermes/cron-history.ts +++ b/packages/server/src/controllers/hermes/cron-history.ts @@ -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 { + 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) { diff --git a/tests/server/cron-history-controller.test.ts b/tests/server/cron-history-controller.test.ts new file mode 100644 index 0000000..113aa43 --- /dev/null +++ b/tests/server/cron-history-controller.test.ts @@ -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 = {}) { + 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') + }) +})