Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
"../src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.ts",
"../src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts",
"../src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts",
"../src/main/foreign-sqlite-readers/hermes-session-runs-result.ts",
"../src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts",
"../src/main/foreign-sqlite-readers/opencode-go-key-result.ts",
"../src/main/lazy-worker-thread-host.ts",
Expand Down
117 changes: 89 additions & 28 deletions src/main/automations/hermes-cron-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,31 @@ const fakeDbRows = vi.hoisted(() => ({
sessions: [] as Record<string, unknown>[],
messages: [] as Record<string, unknown>[]
}))
const fakePrepareSqls = vi.hoisted(() => [] as string[])
const fakeDatabase = vi.hoisted(() =>
vi.fn(function FakeDatabase() {
return {
prepare: vi.fn((sql: string) => {
fakePrepareSqls.push(sql)
return {
get: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions.find((session) => session.id === param)
: undefined
),
all: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions
: fakeDbRows.messages.filter((message) => message.session_id === param)
)
}
}),
close: vi.fn()
}
})
)
// Stands in for the worker reads; the SQL is covered by readers/hermes-session-runs.test.ts.
const fakeReads = vi.hoisted(() => ({
refs: vi.fn(async (_dbPath: string, _jobId: string): Promise<Record<string, unknown>[]> => []),
runs: vi.fn(async (_dbPath: string, _runIds: readonly string[]): Promise<unknown[]> => [])
}))

vi.mock('../sqlite/sync-database', () => ({
default: fakeDatabase
vi.mock('../foreign-sqlite-readers/foreign-sqlite-reader-spawn', () => ({
readHermesSessionRunRefRows: fakeReads.refs,
readHermesSessionRuns: fakeReads.runs
}))

function serveFakeRows(): void {
fakeReads.refs.mockImplementation(async () =>
fakeDbRows.sessions.map((session) => ({ id: session.id, started_at: session.started_at }))
)
fakeReads.runs.mockImplementation(async (_dbPath, runIds) =>
runIds.flatMap((id) => {
const session = fakeDbRows.sessions.find((row) => row.id === id)
return session
? [{ id, session, messages: fakeDbRows.messages.filter((row) => row.session_id === id) }]
: []
})
)
}

async function loadReader() {
vi.resetModules()
return import('./hermes-cron-output')
Expand All @@ -52,8 +49,9 @@ async function createHermesHome(): Promise<string> {
beforeEach(() => {
fakeDbRows.sessions = []
fakeDbRows.messages = []
fakePrepareSqls.length = 0
fakeDatabase.mockClear()
fakeReads.refs.mockReset()
fakeReads.runs.mockReset()
serveFakeRows()
})

afterEach(async () => {
Expand Down Expand Up @@ -274,7 +272,7 @@ Run summary: monitor automation completed successfully.
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })

expect(page).toEqual({ total: 1, runs: [] })
expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false)
expect(fakeReads.runs).not.toHaveBeenCalled()
})

it('skips date sorting for counts while keeping paginated runs newest first', async () => {
Expand Down Expand Up @@ -349,4 +347,67 @@ Run summary: monitor automation completed successfully.
runs: []
})
})

it('reads a page of session runs in one worker request', async () => {
const home = await createHermesHome()
await writeFile(join(home, 'state.db'), '')
fakeDbRows.sessions = [1, 2, 3].map((n) => ({
id: `cron_job-1_run${n}`,
title: `Run ${n}`,
started_at: 1000 * n,
ended_at: 1000 * n + 10
}))
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })

expect(fakeReads.runs).toHaveBeenCalledTimes(1)
expect(fakeReads.runs).toHaveBeenCalledWith(join(home, 'state.db'), [
'cron_job-1_run3',
'cron_job-1_run2',
'cron_job-1_run1'
])
expect(page.runs).toMatchObject([
{ id: 'cron_job-1_run3', status: 'completed', output_preview: 'Run 3' },
{ id: 'cron_job-1_run2', status: 'completed' },
{ id: 'cron_job-1_run1', status: 'completed' }
])
})

it('skips the worker when state.db is absent', async () => {
await createHermesHome()
fakeDbRows.sessions = [{ id: 'cron_job-1_run1', started_at: 1000 }]
const { readHermesCronOutputRunsPage } = await loadReader()
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })).resolves.toEqual(
{ total: 0, runs: [] }
)
expect(fakeReads.refs).not.toHaveBeenCalled()
expect(fakeReads.runs).not.toHaveBeenCalled()
})

it('keeps output-file runs when the worker answers its failure values', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), '# Cron Job: x\n', 'utf-8')
await writeFile(join(home, 'state.db'), '')
fakeDbRows.sessions = [{ id: 'cron_job-1_20260515_090000', started_at: 1000 }]
// [] from the run read is what the client resolves to on a timeout, crash or unreadable db.
fakeReads.runs.mockImplementation(async () => [])
const { readHermesCronOutputRunsPage } = await loadReader()
const withRefs = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect(withRefs.runs.length).toBeGreaterThan(0)
expect(withRefs.runs.every((run) => run !== null)).toBe(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion can never fail: hydrateHermesRunRef always falls back to ref, so no entry is ever null, and the length > 0 above only proves the array is non-empty. Pin the concrete run the failure path should still produce, otherwise this half of the test cannot catch a regression in the fallback.

Suggested change
expect(withRefs.runs.every((run) => run !== null)).toBe(true)
expect(withRefs.runs).toMatchObject([{ id: 'job-1:2026-05-15_09-02-00.md' }])


fakeReads.refs.mockImplementation(async () => [])
const { readHermesCronOutputRunsPage: readAgain } = await loadReader()
await expect(readAgain('job-1', { page: 1, pageSize: 25 })).resolves.toMatchObject({
total: 1,
runs: [
{
id: 'job-1:2026-05-15_09-02-00.md',
output_path: join(outputDir, '2026-05-15_09-02-00.md')
}
]
})
})
})
151 changes: 64 additions & 87 deletions src/main/automations/hermes-cron-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { existsSync } from 'node:fs'
import { readdir, readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import Database from '../sqlite/sync-database'
import {
readHermesSessionRunRefRows,
readHermesSessionRuns
} from '../foreign-sqlite-readers/foreign-sqlite-reader-spawn'
import type { HermesSessionRunRows } from '../foreign-sqlite-readers/hermes-session-runs-result'
import {
HERMES_OUTPUT_FILE_PATTERN,
escapeSqlLike,
parseHermesOutput,
runAtFromHermesOutputFile,
runAtFromUnixSeconds,
Expand Down Expand Up @@ -65,7 +68,7 @@ export async function readHermesCronOutputRuns(jobId: string): Promise<unknown[]

async function readHermesCronOutputRunRefs(jobId: string): Promise<HermesMergedRunRef[]> {
const outputRuns = await readHermesOutputFileRunRefs(jobId)
return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId))
return mergeHermesOutputAndSessionRunRefs(outputRuns, await readHermesSessionDbRunRefs(jobId))
}

// Why: opening the Automations page calls readHermesCronOutputRunsPage with
Expand All @@ -83,9 +86,12 @@ async function readHermesCronOutputRunCount(jobId: string): Promise<number> {
})
}

async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise<unknown> {
async function hydrateHermesRunRef(
ref: HermesMergedRunRef,
sessionRuns: ReadonlyMap<string, unknown>
): Promise<unknown> {
const outputRun = ref.output ? await readHermesOutputFileRun(ref.output) : null
const sessionRun = ref.session ? readHermesSessionDbRunById(jobId, ref.session.id) : null
const sessionRun = ref.session ? (sessionRuns.get(ref.session.id) ?? null) : null
return (
mergeHermesOutputAndSessionRuns(
outputRun ? [outputRun] : [],
Expand Down Expand Up @@ -128,9 +134,13 @@ export async function readHermesCronOutputRunsPage(
})
const start = (safePage - 1) * safePageSize
const pageRefs = runRefs.slice(start, start + safePageSize)
const sessionRuns = await readHermesSessionDbRuns(
jobId,
pageRefs.flatMap((ref) => (ref.session ? [ref.session.id] : []))
)
return {
total: runRefs.length,
runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(jobId, ref)))
runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(ref, sessionRuns)))
}
}

Expand Down Expand Up @@ -198,93 +208,60 @@ async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise<unknown
}
}

function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] {
async function readHermesSessionDbRunRefs(jobId: string): Promise<HermesSessionRunRef[]> {
if (!existsSync(HERMES_STATE_DB)) {
return []
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%`
const rows = db
.prepare(
`SELECT id, started_at
FROM sessions
WHERE id LIKE ? ESCAPE '\\'
ORDER BY started_at DESC`
)
.all(pattern) as Record<string, unknown>[]
return rows.map((row) => {
const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}`
return {
kind: 'session',
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null
}
})
} finally {
db.close()
const rows = await readHermesSessionRunRefRows(HERMES_STATE_DB, jobId)
return rows.map((row) => {
const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}`
return {
kind: 'session',
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null
}
} catch {
return []
}
})
}

function readHermesSessionDbRunById(jobId: string, runId: string): unknown {
if (!existsSync(HERMES_STATE_DB)) {
return null
/** One page's session runs from a single worker read; a run that can't be read is absent. */
async function readHermesSessionDbRuns(
jobId: string,
runIds: string[]
): Promise<Map<string, unknown>> {
if (runIds.length === 0 || !existsSync(HERMES_STATE_DB)) {
return new Map()
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const row = db
.prepare(
`SELECT id, title, started_at, ended_at, end_reason, model, message_count,
input_tokens, output_tokens, estimated_cost_usd
FROM sessions
WHERE id = ?`
)
.get(runId) as Record<string, unknown> | undefined
if (!row) {
return null
}
const messages = db
.prepare(
`SELECT role, content, tool_name, reasoning, reasoning_content
FROM messages
WHERE session_id = ?
ORDER BY timestamp, id`
)
.all(runId) as Record<string, unknown>[]
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null
const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null
const messageCount = typeof row.message_count === 'number' ? row.message_count : null
const tokenCount =
(typeof row.input_tokens === 'number' ? row.input_tokens : 0) +
(typeof row.output_tokens === 'number' ? row.output_tokens : 0)
const summaryParts = [
title,
model ? `Model: ${model}` : null,
messageCount !== null ? `${messageCount} messages` : null,
tokenCount > 0 ? `${tokenCount} tokens` : null
].filter(Boolean)
return {
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null,
status: typeof row.ended_at === 'number' ? 'completed' : 'unknown',
output_preview: summaryParts.join(' · ') || null,
output_content: formatSessionMessages(messages),
error: null,
output_path: HERMES_STATE_DB
}
} finally {
db.close()
}
} catch {
return null
const runs = await readHermesSessionRuns(HERMES_STATE_DB, runIds)
return new Map(runs.map((run) => [run.id, toHermesSessionRun(jobId, run)]))
}

function toHermesSessionRun(
jobId: string,
{ id: runId, session: row, messages }: HermesSessionRunRows
): unknown {
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null
const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null
const messageCount = typeof row.message_count === 'number' ? row.message_count : null
const tokenCount =
(typeof row.input_tokens === 'number' ? row.input_tokens : 0) +
(typeof row.output_tokens === 'number' ? row.output_tokens : 0)
const summaryParts = [
title,
model ? `Model: ${model}` : null,
messageCount !== null ? `${messageCount} messages` : null,
tokenCount > 0 ? `${tokenCount} tokens` : null
].filter(Boolean)
return {
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null,
status: typeof row.ended_at === 'number' ? 'completed' : 'unknown',
output_preview: summaryParts.join(' · ') || null,
output_content: formatSessionMessages(messages),
error: null,
output_path: HERMES_STATE_DB
}
}
Loading
Loading