From ccb04dfb8b40a6ecf53f05e8f1250556f0772796 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 2 Oct 2026 04:14:21 -0400 Subject: [PATCH] fix(automations): read Hermes's state.db on the foreign SQLite reader worker (STA-9122) Before: opening the Automations page read Hermes's state.db with node:sqlite on the main thread: once per Hermes job for the run-count badge, and once per run when a page of runs was shown, each run opening the database again. After: the two raw SQLite reads are pure readers in foreign-sqlite-readers/readers/hermes-session-runs.ts, run only on the worker. Mapping rows into runs, merging with output files, and the run-count cache stay on the main thread. - A page of runs is one hermesSessionRuns request carrying every session id, so state.db opens once per page instead of once per run. - Failure behaviour per call site is unchanged: the run-ref read resolves to [] (count and list fall back to output files), and a run the batch could not read hydrates from its output file or ref, as a null per-run read did. The existsSync guard stays on the main thread, so no thread starts without state.db. - Each kind has its own lane with the default 30 s idle teardown. Not changed: src/relay/ keeps its own Hermes reader (hermes-session-run-database.ts) and shares none of these functions. orcad does not bundle hermes-cron-output. Tests: readers against a real state.db (job filter with an escaped LIKE, newest first, message order, missing runs left out, missing/corrupt throw), dispatch routing, client (one request per page, failure values, dedupe per id set), and the page reader (one batched call, no call without state.db or for counts, output-file fallback on worker failure). --- config/tsconfig.cli.json | 1 + .../automations/hermes-cron-output.test.ts | 117 ++++++++++---- src/main/automations/hermes-cron-output.ts | 151 ++++++++---------- .../foreign-sqlite-reader-client.test.ts | 50 ++++++ .../foreign-sqlite-reader-client.ts | 61 ++++++- .../foreign-sqlite-reader-dispatch.test.ts | 20 +++ .../foreign-sqlite-reader-dispatch.ts | 5 + .../foreign-sqlite-reader-protocol.ts | 17 ++ .../foreign-sqlite-reader-spawn.ts | 23 +++ .../hermes-session-runs-result.ts | 38 +++++ .../readers/hermes-session-runs.test.ts | 81 ++++++++++ .../readers/hermes-session-runs.ts | 74 +++++++++ 12 files changed, 522 insertions(+), 116 deletions(-) create mode 100644 src/main/foreign-sqlite-readers/hermes-session-runs-result.ts create mode 100644 src/main/foreign-sqlite-readers/readers/hermes-session-runs.test.ts create mode 100644 src/main/foreign-sqlite-readers/readers/hermes-session-runs.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f1614491225b..f003ad5d120b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 55ddf8a5f585..d2c2762dcdc4 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -10,34 +10,31 @@ const fakeDbRows = vi.hoisted(() => ({ sessions: [] as Record[], messages: [] as Record[] })) -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[]> => []), + runs: vi.fn(async (_dbPath: string, _runIds: readonly string[]): Promise => []) +})) -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') @@ -52,8 +49,9 @@ async function createHermesHome(): Promise { beforeEach(() => { fakeDbRows.sessions = [] fakeDbRows.messages = [] - fakePrepareSqls.length = 0 - fakeDatabase.mockClear() + fakeReads.refs.mockReset() + fakeReads.runs.mockReset() + serveFakeRows() }) afterEach(async () => { @@ -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 () => { @@ -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) + + 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') + } + ] + }) + }) }) diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index d30babfceb9c..510984802240 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -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, @@ -65,7 +68,7 @@ export async function readHermesCronOutputRuns(jobId: string): Promise { const outputRuns = await readHermesOutputFileRunRefs(jobId) - return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)) + return mergeHermesOutputAndSessionRunRefs(outputRuns, await readHermesSessionDbRunRefs(jobId)) } // Why: opening the Automations page calls readHermesCronOutputRunsPage with @@ -83,9 +86,12 @@ async function readHermesCronOutputRunCount(jobId: string): Promise { }) } -async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { +async function hydrateHermesRunRef( + ref: HermesMergedRunRef, + sessionRuns: ReadonlyMap +): Promise { 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] : [], @@ -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))) } } @@ -198,93 +208,60 @@ async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { 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[] - 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> { + 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 | 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[] - 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 } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts index 671ead6d924d..8203628ab9c1 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts @@ -354,3 +354,53 @@ describe('ForeignSqliteReaderClient idle teardown', () => { } }) }) + +describe('ForeignSqliteReaderClient Hermes session runs', () => { + const RUN = { id: 'cron_j_a', session: { id: 'cron_j_a', title: 't' }, messages: [] } + + it('sends a whole page of runs as one request', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const read = client.readHermesSessionRuns('/h/state.db', ['cron_j_a', 'cron_j_b']) + expect(workers[0]?.posted).toEqual([ + { + id: expect.any(Number), + kind: 'hermesSessionRuns', + dbPath: '/h/state.db', + runIds: ['cron_j_a', 'cron_j_b'] + } + ]) + workers[0]?.reply([RUN]) + await expect(read).resolves.toEqual([RUN]) + client.dispose() + }) + + it('returns its failure value on an error or a malformed reply', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const refs = client.readHermesSessionRunRefRows('/h/state.db', 'j') + workers[0]?.emit('message', { id: workers[0].posted[0]?.id, ok: false, error: 'locked' }) + await expect(refs).resolves.toEqual([]) + const runs = client.readHermesSessionRuns('/h/state.db', ['cron_j_a']) + workers[1]?.reply([{ id: 'cron_j_a' }]) + await expect(runs).resolves.toEqual([]) + client.dispose() + }) + + it('shares an in-flight page only for the same run ids', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const first = client.readHermesSessionRuns('/h/state.db', ['a', 'b']) + const sharer = client.readHermesSessionRuns('/h/state.db', ['a', 'b']) + const other = client.readHermesSessionRuns('/h/state.db', ['a']) + workers[0]?.reply([]) + await expect(Promise.all([first, sharer])).resolves.toEqual([[], []]) + await settle() + workers[0]?.reply([]) + await other + expect( + workers[0]?.posted.map((request) => request.kind === 'hermesSessionRuns' && request.runIds) + ).toEqual([['a', 'b'], ['a']]) + client.dispose() + }) +}) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts index 55f75b34a09f..357ce44b3dda 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts @@ -9,6 +9,14 @@ import { parseCursorProfileReadResult, type CursorDesktopProfileReadResult } from './cursor-profile-result' +import { + hermesSessionRowsFailure, + hermesSessionRunsFailure, + parseHermesSessionRows, + parseHermesSessionRuns, + type HermesSessionRow, + type HermesSessionRunRows +} from './hermes-session-runs-result' import { openCodeBinderSessionsFailure, parseOpenCodeBinderSessions, @@ -38,7 +46,9 @@ const DEFAULT_IDLE_TEARDOWN_MS: Record = { // The OpenCode binder polls every 60 s. openCodeBinderSessions: 120_000, openCodeGoKey: 30_000, - codexIndexStatus: 30_000 + codexIndexStatus: 30_000, + hermesSessionRunRefs: 30_000, + hermesSessionRuns: 30_000 } const MAX_CONSECUTIVE_DEATHS = 3 const KEEP_ALIVE_INTERVAL_MS = 60_000 @@ -133,6 +143,8 @@ export class ForeignSqliteReaderClient { private readonly openCodeBinderSessions: ForeignSqliteReaderLane private readonly openCodeGoKey: ForeignSqliteReaderLane private readonly codexIndexStatus: ForeignSqliteReaderLane + private readonly hermesSessionRunRefs: ForeignSqliteReaderLane + private readonly hermesSessionRuns: ForeignSqliteReaderLane constructor(options: { workerFactory: WorkerThreadFactory @@ -166,6 +178,16 @@ export class ForeignSqliteReaderClient { parseCodexIndexStatusResult, settings('codexIndexStatus') ) + this.hermesSessionRunRefs = new ForeignSqliteReaderLane( + 'hermesSessionRunRefs', + parseHermesSessionRows, + settings('hermesSessionRunRefs') + ) + this.hermesSessionRuns = new ForeignSqliteReaderLane( + 'hermesSessionRuns', + parseHermesSessionRuns, + settings('hermesSessionRuns') + ) } /** @@ -235,11 +257,48 @@ export class ForeignSqliteReaderClient { return result ?? openCodeBinderSessionsFailure() } + /** + * List one Hermes cron job's session rows off the main thread. + * @param dbPath - Hermes's state.db. + * @param jobId - Hermes cron job id. + * @returns Raw rows newest first; `[]` when state.db or the worker cannot answer. + */ + async readHermesSessionRunRefRows(dbPath: string, jobId: string): Promise { + const result = await this.hermesSessionRunRefs.read(JSON.stringify([dbPath, jobId]), (id) => ({ + id, + kind: 'hermesSessionRunRefs', + dbPath, + jobId + })) + return result ?? hermesSessionRowsFailure() + } + + /** + * Read a page of Hermes runs in one request, so state.db opens once. + * @param dbPath - Hermes's state.db. + * @param runIds - Session ids of the page's runs. + * @returns The runs found; `[]` when state.db or the worker cannot answer. + */ + async readHermesSessionRuns( + dbPath: string, + runIds: readonly string[] + ): Promise { + const result = await this.hermesSessionRuns.read(JSON.stringify([dbPath, ...runIds]), (id) => ({ + id, + kind: 'hermesSessionRuns', + dbPath, + runIds: [...runIds] + })) + return result ?? hermesSessionRunsFailure() + } + dispose(): void { this.cursorProfile.dispose() this.openCodeBinderSessions.dispose() this.openCodeGoKey.dispose() this.codexIndexStatus.dispose() + this.hermesSessionRunRefs.dispose() + this.hermesSessionRuns.dispose() } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts index afd35b57c0d7..a35b5498709e 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts @@ -35,6 +35,26 @@ describe('handleForeignSqliteReaderRequest', () => { ).toEqual({ id: 7, ok: true, value: { type: 'sessionFileCount', count: 0 } }) }) + it('routes both Hermes kinds to their readers', () => { + const missing = '/definitely/missing/state.db' + expect( + handleForeignSqliteReaderRequest({ + id: 8, + kind: 'hermesSessionRunRefs', + dbPath: missing, + jobId: 'j' + }) + ).toMatchObject({ id: 8, ok: false }) + expect( + handleForeignSqliteReaderRequest({ + id: 10, + kind: 'hermesSessionRuns', + dbPath: missing, + runIds: ['r'] + }) + ).toMatchObject({ id: 10, ok: false }) + }) + it('rejects a kind no reader owns instead of running one', () => { // Parsed, as a structured clone arrives: untyped, with a kind outside the union. const request = JSON.parse('{"id":9,"kind":"list","dbPaths":[]}') diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts index 269957ce6332..51bac4d48137 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts @@ -4,6 +4,7 @@ import type { } from './foreign-sqlite-reader-protocol' import { readCodexIndexStatus } from './readers/codex-index-status' import { readCursorProfile } from './readers/cursor-profile' +import { readHermesSessionRunRefRows, readHermesSessionRuns } from './readers/hermes-session-runs' import { readOpenCodeBinderSessions } from './readers/opencode-binder-sessions' import { readOpenCodeGoKey } from './readers/opencode-go-key' @@ -27,6 +28,10 @@ export function handleForeignSqliteReaderRequest( return { id, ok: true, value: readOpenCodeGoKey(request.dbPaths) } case 'codexIndexStatus': return { id, ok: true, value: readCodexIndexStatus(request.query) } + case 'hermesSessionRunRefs': + return { id, ok: true, value: readHermesSessionRunRefRows(request.dbPath, request.jobId) } + case 'hermesSessionRuns': + return { id, ok: true, value: readHermesSessionRuns(request.dbPath, request.runIds) } } // A structured clone can carry any kind; one without a reader is refused, not guessed at. return unknownKind(id, kind) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts index 364e5ccb115f..f02c9695d9f1 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts @@ -38,11 +38,28 @@ type CodexIndexStatusRequest = { query: CodexIndexStatusQuery } +type HermesSessionRunRefsRequest = { + id: number + kind: 'hermesSessionRunRefs' + dbPath: string + jobId: string +} + +// One request for a whole page of runs, so state.db opens once per page. +type HermesSessionRunsRequest = { + id: number + kind: 'hermesSessionRuns' + dbPath: string + runIds: string[] +} + export type ForeignSqliteReaderRequest = | CursorProfileRequest | OpenCodeBinderSessionsRequest | OpenCodeGoKeyRequest | CodexIndexStatusRequest + | HermesSessionRunRefsRequest + | HermesSessionRunsRequest export type ForeignSqliteReaderKind = ForeignSqliteReaderRequest['kind'] diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts index 4cd41ee2a439..8a29e76db919 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts @@ -9,6 +9,7 @@ import { } from './foreign-sqlite-reader-client' import { resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' import type { CodexIndexStatusQuery } from './foreign-sqlite-reader-protocol' +import type { HermesSessionRow, HermesSessionRunRows } from './hermes-session-runs-result' import type { BinderSessionRow, OpenCodeSessionCursor } from './opencode-binder-sessions-result' import type { OpenCodeGoKeyReadResult } from './opencode-go-key-result' @@ -84,3 +85,25 @@ export const _internals = { workerFactory = factory ?? defaultWorkerFactory } } + +/** + * List one Hermes cron job's session rows on the foreign SQLite reader worker. + * @returns Raw rows newest first; `[]` when state.db or the worker cannot answer. + */ +export function readHermesSessionRunRefRows( + dbPath: string, + jobId: string +): Promise { + return getSharedClient().readHermesSessionRunRefRows(dbPath, jobId) +} + +/** + * Read a page of Hermes runs on the foreign SQLite reader worker, in one request. + * @returns The runs found; `[]` when state.db or the worker cannot answer. + */ +export function readHermesSessionRuns( + dbPath: string, + runIds: readonly string[] +): Promise { + return getSharedClient().readHermesSessionRuns(dbPath, runIds) +} diff --git a/src/main/foreign-sqlite-readers/hermes-session-runs-result.ts b/src/main/foreign-sqlite-readers/hermes-session-runs-result.ts new file mode 100644 index 000000000000..69a9bb2f4b9f --- /dev/null +++ b/src/main/foreign-sqlite-readers/hermes-session-runs-result.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +/** One `sessions` row as SQLite returned it; the main thread maps it into a run. */ +export type HermesSessionRow = Record + +/** One cron run's session row and its messages, oldest first. */ +export type HermesSessionRunRows = { + id: string + session: HermesSessionRow + messages: HermesSessionRow[] +} + +/** No session rows: what both Hermes reads have always answered when state.db can't be read. */ +export function hermesSessionRowsFailure(): HermesSessionRow[] { + return [] +} + +export function hermesSessionRunsFailure(): HermesSessionRunRows[] { + return [] +} + +const rowSchema = z.record(z.string(), z.unknown()) +const rowsSchema = z.array(rowSchema) +const runsSchema = z.array( + z.object({ id: z.string(), session: rowSchema, messages: z.array(rowSchema) }) +) + +/** A worker reply arrives as a structured clone; null when it is not a list of rows. */ +export function parseHermesSessionRows(value: unknown): HermesSessionRow[] | null { + const parsed = rowsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +/** A worker reply arrives as a structured clone; null when it is not a list of session runs. */ +export function parseHermesSessionRuns(value: unknown): HermesSessionRunRows[] | null { + const parsed = runsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/main/foreign-sqlite-readers/readers/hermes-session-runs.test.ts b/src/main/foreign-sqlite-readers/readers/hermes-session-runs.test.ts new file mode 100644 index 000000000000..a0fad1fc9274 --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/hermes-session-runs.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../../sqlite/sync-database' +import { readHermesSessionRunRefRows, readHermesSessionRuns } from './hermes-session-runs' + +let dir = '' +let dbPath = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-hermes-reader-')) + dbPath = join(dir, 'state.db') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeStateDb(): void { + const db = new SyncDatabase(dbPath) + try { + db.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, started_at REAL, ended_at REAL, + end_reason TEXT, model TEXT, message_count INTEGER, input_tokens INTEGER, output_tokens INTEGER, + estimated_cost_usd REAL)`) + db.exec(`CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, + tool_name TEXT, reasoning TEXT, reasoning_content TEXT, timestamp REAL)`) + const session = db.prepare('INSERT INTO sessions (id, title, started_at) VALUES (?, ?, ?)') + session.run('cron_job-1_a', 'First', 100) + session.run('cron_job-1_b', 'Second', 200) + // `_` is a LIKE wildcard: an unescaped pattern for job-1 would also match this one. + session.run('cron_job-1x_c', 'Other job', 300) + const message = db.prepare( + 'INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)' + ) + message.run('cron_job-1_a', 'assistant', 'later', 2) + message.run('cron_job-1_a', 'user', 'earlier', 1) + } finally { + db.close() + } +} + +describe('readHermesSessionRunRefRows', () => { + it("lists one job's cron sessions newest first", () => { + writeStateDb() + expect(readHermesSessionRunRefRows(dbPath, 'job-1')).toEqual([ + { id: 'cron_job-1_b', started_at: 200 }, + { id: 'cron_job-1_a', started_at: 100 } + ]) + }) + + it('throws for a missing or corrupt database so the client answers its failure value', () => { + expect(() => readHermesSessionRunRefRows(dbPath, 'job-1')).toThrow() + writeFileSync(dbPath, 'not a sqlite database'.repeat(100)) + expect(() => readHermesSessionRunRefRows(dbPath, 'job-1')).toThrow() + }) +}) + +describe('readHermesSessionRuns', () => { + it('reads every requested run, leaving missing runs out', () => { + writeStateDb() + const runs = readHermesSessionRuns(dbPath, [ + 'cron_job-1_a', + 'cron_job-1_missing', + 'cron_job-1_b' + ]) + expect(runs.map((run) => run.id)).toEqual(['cron_job-1_a', 'cron_job-1_b']) + expect(runs[0]).toMatchObject({ + session: { id: 'cron_job-1_a', title: 'First', started_at: 100 }, + messages: [ + { role: 'user', content: 'earlier' }, + { role: 'assistant', content: 'later' } + ] + }) + expect(runs[1]?.messages).toEqual([]) + }) + + it('throws for a missing database', () => { + expect(() => readHermesSessionRuns(dbPath, ['cron_job-1_a'])).toThrow() + }) +}) diff --git a/src/main/foreign-sqlite-readers/readers/hermes-session-runs.ts b/src/main/foreign-sqlite-readers/readers/hermes-session-runs.ts new file mode 100644 index 000000000000..c60bbf29f214 --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/hermes-session-runs.ts @@ -0,0 +1,74 @@ +import { escapeSqlLike } from '../../automations/hermes-cron-output-markdown' +import SyncDatabase from '../../sqlite/sync-database' +import type { HermesSessionRow, HermesSessionRunRows } from '../hermes-session-runs-result' + +function openStateDb(dbPath: string): SyncDatabase { + return new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) +} + +/** + * Every cron session row of one Hermes job, newest first. A failed open throws. + * @param dbPath - Hermes's state.db. + * @param jobId - Hermes cron job id; its sessions are named `cron__`. + */ +export function readHermesSessionRunRefRows(dbPath: string, jobId: string): HermesSessionRow[] { + const db = openStateDb(dbPath) + try { + const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` + return db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) + } finally { + db.close() + } +} + +/** + * The session row and messages of each run, from one open of state.db. A run + * that is missing or fails to read is left out; a failed open throws. + * @param dbPath - Hermes's state.db. + * @param runIds - Session ids to read. + */ +export function readHermesSessionRuns( + dbPath: string, + runIds: readonly string[] +): HermesSessionRunRows[] { + const db = openStateDb(dbPath) + try { + const runs: HermesSessionRunRows[] = [] + for (const id of runIds) { + try { + const session = 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(id) + if (!session) { + continue + } + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(id) + runs.push({ id, session, messages }) + } catch { + // One unreadable run reads as missing, as it did when each run opened state.db alone. + } + } + return runs + } finally { + db.close() + } +}